From bcd1d4e73badd7880e3c6082678f8cbb4b945758 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Sun, 21 Jun 2026 19:22:52 -0300 Subject: [PATCH 1/8] feat(deck-detail): expose deck cover image from shared ViewModel DeckDetailUiState.Content only carried coverEmoji, so a deck's actual cover image (Deck.coverImageRef) was never surfaced to the UI. Add coverImageUrl (remote/Unsplash) and coverImageBase64 (homeserver blob, fetched via MediaRepository and Base64-encoded for transport) to Content so native UIs can render the real cover, falling back to the emoji box. Blob fetching stays in the ViewModel (viewModelScope) per the shared-logic rule; remote URLs are carried synchronously. Inject MediaRepository into the VM and its Koin binding. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../github/jvsena42/echo/di/SharedModule.kt | 1 + .../presentation/decks/DeckDetailViewModel.kt | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/di/SharedModule.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/di/SharedModule.kt index f0027b0..291e0dc 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/di/SharedModule.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/di/SharedModule.kt @@ -81,6 +81,7 @@ val sharedModule = module { cardRepository = get(), identityRepository = get(), srsRepository = get(), + mediaRepository = get(), ) } viewModel { params -> StudySessionViewModel(deckId = params.getOrNull(), srsRepository = get(), deckRepository = get()) } diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DeckDetailViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DeckDetailViewModel.kt index 1a0bf53..e0ac38e 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DeckDetailViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DeckDetailViewModel.kt @@ -5,9 +5,11 @@ import androidx.lifecycle.viewModelScope import com.github.jvsena42.echo.data.repository.CardRepository import com.github.jvsena42.echo.data.repository.DeckRepository import com.github.jvsena42.echo.data.repository.IdentityRepository +import com.github.jvsena42.echo.data.repository.MediaRepository import com.github.jvsena42.echo.data.repository.SrsRepository import com.github.jvsena42.echo.domain.model.Card import com.github.jvsena42.echo.domain.model.Deck +import com.github.jvsena42.echo.domain.model.MediaRef import com.github.jvsena42.echo.util.Log import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow @@ -18,7 +20,10 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +@OptIn(ExperimentalEncodingApi::class) @Suppress("LongParameterList") class DeckDetailViewModel( private val deckId: String, @@ -27,6 +32,7 @@ class DeckDetailViewModel( private val cardRepository: CardRepository, private val identityRepository: IdentityRepository, private val srsRepository: SrsRepository, + private val mediaRepository: MediaRepository, ) : ViewModel() { private val _state = MutableStateFlow(DeckDetailUiState.Loading) val state: StateFlow = _state.asStateFlow() @@ -73,6 +79,7 @@ class DeckDetailViewModel( val mastered = masteredPercent(cards) _state.update { deck.toContent(cards, myPubky, dueCount, mastered) } Log.d(TAG, "load: cards=${cards.size} due=$dueCount mastered=$mastered") + loadCoverBlob(deck.coverImageRef) } .onFailure { err -> Log.e(TAG, "load: FAILED โ€” ${err::class.simpleName}: ${err.message}", err) @@ -142,6 +149,22 @@ class DeckDetailViewModel( return "${mastered * PERCENT / cards.size}%" } + /** + * Fetches a homeserver blob cover and folds its Base64 bytes into the current [Content] so the + * UI can render the real image. Remote (URL) covers need no fetch โ€” they are already carried by + * [DeckDetailUiState.Content.coverImageUrl]. No-ops on null/remote refs or while not in Content. + */ + private suspend fun loadCoverBlob(ref: MediaRef.Image?) { + if (ref == null || ref.isRemote) return + val bytes = mediaRepository.get(deckId, ref) + .onFailure { Log.e(TAG, "loadCoverBlob: FAILED โ€” ${it.message}", it) } + .getOrNull() ?: return + val encoded = Base64.encode(bytes) + _state.update { current -> + (current as? DeckDetailUiState.Content)?.copy(coverImageBase64 = encoded) ?: current + } + } + private fun Deck.toContent( cards: List, myPubky: String?, @@ -154,6 +177,7 @@ class DeckDetailViewModel( title = title, description = description, coverEmoji = coverEmoji ?: title.firstOrNull()?.toString() ?: "๐Ÿ“š", + coverImageUrl = coverImageRef?.url, authorName = null, authorPubky = authorPubky, authorInitial = authorPubky.firstOrNull()?.uppercaseChar() ?: '?', @@ -188,6 +212,8 @@ sealed interface DeckDetailUiState { val title: String, val description: String?, val coverEmoji: String, + val coverImageUrl: String? = null, + val coverImageBase64: String? = null, val authorName: String?, val authorPubky: String, val authorInitial: Char, From b3609dddc2fcbea391a732aa6ed0ad3b6c30a98a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Sun, 21 Jun 2026 19:22:59 -0300 Subject: [PATCH 2/8] feat(ios): render deck cover image, fallback, and @you author MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeckDetailView drew only an emoji box, so decks with a real cover image showed a letter/emoji placeholder. Render the cover in priority order: remote URL (AsyncImage) โ†’ homeserver blob (Base64 โ†’ UIImage) โ†’ the accent-soft emoji box. The emoji box now blank-guards coverEmoji and falls back to the title initial, then a book glyph. Owned decks now read "@you" in the author row (dropping the redundant pk:โ€ฆ subtitle), matching the decks-list convention. Co-Authored-By: Claude Opus 4.8 (1M context) --- iosApp/iosApp/Views/DeckDetailScreen.swift | 2 + iosApp/iosApp/Views/DeckDetailView.swift | 100 +++++++++++++++++---- 2 files changed, 86 insertions(+), 16 deletions(-) diff --git a/iosApp/iosApp/Views/DeckDetailScreen.swift b/iosApp/iosApp/Views/DeckDetailScreen.swift index 7745790..93129bf 100644 --- a/iosApp/iosApp/Views/DeckDetailScreen.swift +++ b/iosApp/iosApp/Views/DeckDetailScreen.swift @@ -45,6 +45,8 @@ struct DeckDetailScreen: View { title: content.title, description: content.description_, coverEmoji: content.coverEmoji, + coverImageUrl: content.coverImageUrl, + coverImageBase64: content.coverImageBase64, authorName: content.authorName, authorPubky: content.authorPubky, authorInitial: KotlinInterop.charToString(content.authorInitial), diff --git a/iosApp/iosApp/Views/DeckDetailView.swift b/iosApp/iosApp/Views/DeckDetailView.swift index 50a3ca2..1906578 100644 --- a/iosApp/iosApp/Views/DeckDetailView.swift +++ b/iosApp/iosApp/Views/DeckDetailView.swift @@ -10,6 +10,8 @@ struct DeckDetailContent { let title: String let description: String? let coverEmoji: String + let coverImageUrl: String? + let coverImageBase64: String? let authorName: String? let authorPubky: String let authorInitial: String @@ -78,14 +80,10 @@ struct DeckDetailView: View { header(isOwned: content.isOwned) // Cover - ZStack { - RoundedRectangle(cornerRadius: 28) - .fill(EchoColor.accentPrimarySoft) - Text(content.coverEmoji) - .font(.system(size: content.isOwned ? 64 : 80)) - } - .frame(maxWidth: .infinity) - .frame(height: content.isOwned ? 120 : 160) + coverView(content) + .frame(maxWidth: .infinity) + .frame(height: content.isOwned ? 120 : 160) + .clipShape(RoundedRectangle(cornerRadius: 28)) // Badge (owned variant) if content.isOwned { @@ -126,12 +124,18 @@ struct DeckDetailView: View { .foregroundColor(EchoColor.accentSecondary) } VStack(alignment: .leading) { - Text(content.authorName ?? (content.isOwned ? "You" : "pk:\(content.authorPubky.prefix(6))")) - .font(.system(size: 13, weight: .bold)) - .foregroundColor(EchoColor.foregroundPrimary) - Text("pk:\(content.authorPubky.prefix(6))โ€ฆ\(content.authorPubky.suffix(6))") - .font(.system(size: 11)) - .foregroundColor(EchoColor.foregroundMuted) + if content.isOwned { + Text("@you") + .font(.system(size: 13, weight: .bold)) + .foregroundColor(EchoColor.foregroundPrimary) + } else { + Text(content.authorName ?? "pk:\(content.authorPubky.prefix(6))") + .font(.system(size: 13, weight: .bold)) + .foregroundColor(EchoColor.foregroundPrimary) + Text("pk:\(content.authorPubky.prefix(6))โ€ฆ\(content.authorPubky.suffix(6))") + .font(.system(size: 11)) + .foregroundColor(EchoColor.foregroundMuted) + } } Spacer() } @@ -193,6 +197,44 @@ struct DeckDetailView: View { .padding(.bottom, 20) } + /// Resolves the cover in priority order: remote URL image โ†’ homeserver blob image โ†’ emoji box. + @ViewBuilder + private func coverView(_ content: DeckDetailContent) -> some View { + if let urlString = content.coverImageUrl, let url = URL(string: urlString) { + AsyncImage(url: url) { image in + image.resizable().scaledToFill() + } placeholder: { + coverFallback(content) + } + } else if let base64 = content.coverImageBase64, + let data = Data(base64Encoded: base64), + let uiImage = UIImage(data: data) { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + } else { + coverFallback(content) + } + } + + /// Accent-soft box with the cover glyph โ€” shown when a deck has no cover image. + private func coverFallback(_ content: DeckDetailContent) -> some View { + ZStack { + Rectangle() + .fill(EchoColor.accentPrimarySoft) + Text(coverGlyph(content)) + .font(.system(size: content.isOwned ? 64 : 80)) + } + } + + /// The deck's emoji, or its title initial when no emoji is set, falling back to a book glyph. + private func coverGlyph(_ content: DeckDetailContent) -> String { + let emoji = content.coverEmoji.trimmingCharacters(in: .whitespacesAndNewlines) + if !emoji.isEmpty { return emoji } + if let first = content.title.first { return String(first).uppercased() } + return "๐Ÿ“š" + } + private var studyLabel: String { if case .content(let content) = state, content.isOwned { return String(format: NSLocalizedString("deck_detail_start_studying", comment: ""), content.dueCards) @@ -280,16 +322,42 @@ private struct StatColumn: View { } } -#Preview { +#Preview("Owned ยท no cover image") { + DeckDetailView( + state: .content(DeckDetailContent( + title: "Spanish Basics", + description: "Core 500 words for everyday conversations.", + coverEmoji: "", + coverImageUrl: nil, + coverImageBase64: nil, + authorName: nil, + authorPubky: "abc123xyz789", + authorInitial: "A", + isOwned: true, + tags: ["spanish", "language", "beginner"], + totalCards: 42, + dueCards: 12, + masteredPercent: "68%", + cards: [ + CardPreviewData(id: "1", front: "el zorro", back: "the fox"), + CardPreviewData(id: "2", front: "la casa", back: "the house"), + ] + )) + ) +} + +#Preview("Other author ยท remote cover") { DeckDetailView( state: .content(DeckDetailContent( title: "Spanish Basics", description: "Core 500 words for everyday conversations.", coverEmoji: "๐Ÿ‡ช๐Ÿ‡ธ", + coverImageUrl: "https://images.unsplash.com/photo-1505765050516-f72dcac9c60e", + coverImageBase64: nil, authorName: "Maria Lopez", authorPubky: "abc123xyz789", authorInitial: "M", - isOwned: true, + isOwned: false, tags: ["spanish", "language", "beginner"], totalCards: 42, dueCards: 12, From db787206b2b63faff811454a46921aee307728f8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Sun, 21 Jun 2026 19:31:46 -0300 Subject: [PATCH 3/8] feat(android): show "@you" author row for owned decks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a self-created deck the AuthorRow showed the full pubky plus a Follow button โ€” you can't follow yourself. Add an isOwned flag: when owned, the row reads "@you", drops the pubky subtitle, and hides the Follow button, matching the iOS deck-detail author treatment. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jvsena42/echo/ui/components/AuthorRow.kt | 73 +++++++++++-------- .../echo/ui/decks/DeckDetailScreen.kt | 1 + .../src/androidMain/res/values/strings.xml | 1 + 3 files changed, 44 insertions(+), 31 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/AuthorRow.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/AuthorRow.kt index ac276ef..d0e7336 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/AuthorRow.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/AuthorRow.kt @@ -30,6 +30,7 @@ fun AuthorRow( pubky: String, initial: Char, modifier: Modifier = Modifier, + isOwned: Boolean = false, isFollowing: Boolean = false, onFollowClick: () -> Unit = {}, ) { @@ -58,48 +59,51 @@ fun AuthorRow( Spacer(modifier = Modifier.width(10.dp)) - // Name column + // Name column โ€” owned decks read "@you" with no pubky subtitle. Column(modifier = Modifier.weight(1f)) { Text( - text = name ?: pubky, + text = if (isOwned) stringResource(R.string.component_author_row_you) else name ?: pubky, fontSize = 13.sp, fontWeight = FontWeight.W700, color = colors.foregroundPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Text( - text = pubky, - fontSize = 11.sp, - color = colors.foregroundMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + if (!isOwned) { + Text( + text = pubky, + fontSize = 11.sp, + color = colors.foregroundMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } - Spacer(modifier = Modifier.width(10.dp)) - - // Follow button - Box( - modifier = Modifier - .clip(pillShape) - .background( - if (isFollowing) colors.accentSecondarySoft else colors.accentSecondary, + // Follow button โ€” hidden for your own deck. + if (!isOwned) { + Spacer(modifier = Modifier.width(10.dp)) + Box( + modifier = Modifier + .clip(pillShape) + .background( + if (isFollowing) colors.accentSecondarySoft else colors.accentSecondary, + ) + .clickable(onClick = onFollowClick) + .padding(horizontal = 14.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (isFollowing) { + stringResource(R.string.component_author_row_following) + } else { + stringResource(R.string.component_author_row_follow) + }, + fontSize = 13.sp, + fontWeight = FontWeight.W700, + color = if (isFollowing) colors.accentSecondary else colors.foregroundOnAccent, ) - .clickable(onClick = onFollowClick) - .padding(horizontal = 14.dp, vertical = 6.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = if (isFollowing) { - stringResource(R.string.component_author_row_following) - } else { - stringResource(R.string.component_author_row_follow) - }, - fontSize = 13.sp, - fontWeight = FontWeight.W700, - color = if (isFollowing) colors.accentSecondary else colors.foregroundOnAccent, - ) + } } } } @@ -128,6 +132,13 @@ private fun AuthorRowPreview() { isFollowing = true, onFollowClick = {}, ) + Spacer(modifier = Modifier.size(12.dp)) + AuthorRow( + name = null, + pubky = "pubky:you9xqz1...", + initial = 'Y', + isOwned = true, + ) } } } diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt index 51c0f69..407d804 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt @@ -260,6 +260,7 @@ private fun DeckDetailContent( name = state.authorName, pubky = state.authorPubky, initial = state.authorInitial, + isOwned = state.isOwned, modifier = Modifier.fillMaxWidth(), ) diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml index d4ee10b..c0647b9 100644 --- a/composeApp/src/androidMain/res/values/strings.xml +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -285,6 +285,7 @@ %1$d cards Following Follow + \@you Total Due Mastered From 7da38185126b06e765c72cd2e1fc1f0b1ab20c36 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Sun, 21 Jun 2026 19:36:21 -0300 Subject: [PATCH 4/8] feat(android): render deck cover image in DeckDetail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoverSection drew only the emoji box, so a deck's real cover image (saved on creation via coverImageRef) never showed. Render the cover from the shared ViewModel fields in priority order: remote URL โ†’ homeserver blob (Base64 bytes already loaded by the VM, decoded to a ByteArray) โ†’ the accent-soft emoji box. Coil's AsyncImage renders both a URL and a ByteArray, so no second fetch is needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../echo/ui/decks/DeckDetailScreen.kt | 49 ++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt index 407d804..984e767 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt @@ -36,12 +36,14 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -50,6 +52,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage import com.github.jvsena42.echo.R import com.github.jvsena42.echo.presentation.decks.CardPreviewModel import com.github.jvsena42.echo.presentation.decks.DeckDetailEffect @@ -65,6 +68,8 @@ import com.github.jvsena42.echo.ui.theme.EchoTheme import kotlinx.coroutines.flow.collectLatest import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi @Composable fun DeckDetailRoute( @@ -245,7 +250,12 @@ private fun DeckDetailContent( ) // Cover - CoverSection(coverEmoji = state.coverEmoji, isOwned = state.isOwned) + CoverSection( + coverEmoji = state.coverEmoji, + coverImageUrl = state.coverImageUrl, + coverImageBase64 = state.coverImageBase64, + isOwned = state.isOwned, + ) // Owned badge if (state.isOwned) { @@ -400,12 +410,30 @@ private fun DeleteDeckDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { ) } +/** + * Cover in priority order: remote URL โ†’ homeserver blob (Base64 bytes loaded by the ViewModel) โ†’ + * the accent-soft emoji box. Coil renders both a URL string and a decoded [ByteArray] directly. + */ +@OptIn(ExperimentalEncodingApi::class) @Composable -private fun CoverSection(coverEmoji: String, isOwned: Boolean) { +private fun CoverSection( + coverEmoji: String, + coverImageUrl: String?, + coverImageBase64: String?, + isOwned: Boolean, +) { val colors = EchoTheme.colors val coverHeight = if (isOwned) 120.dp else 160.dp val emojiSize = if (isOwned) 64.sp else 80.sp + val coverModel: Any? = remember(coverImageUrl, coverImageBase64) { + when { + !coverImageUrl.isNullOrEmpty() -> coverImageUrl + !coverImageBase64.isNullOrEmpty() -> runCatching { Base64.decode(coverImageBase64) }.getOrNull() + else -> null + } + } + Box( modifier = Modifier .fillMaxWidth() @@ -414,10 +442,19 @@ private fun CoverSection(coverEmoji: String, isOwned: Boolean) { .background(colors.accentPrimarySoft), contentAlignment = Alignment.Center, ) { - Text( - text = coverEmoji, - fontSize = emojiSize, - ) + if (coverModel != null) { + AsyncImage( + model = coverModel, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Text( + text = coverEmoji, + fontSize = emojiSize, + ) + } } } From ae923540235c2714e7680cafe2300a2a8e443f59 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Sun, 21 Jun 2026 19:44:20 -0300 Subject: [PATCH 5/8] feat(deck-detail): expose author avatar URL from shared ViewModel The deck-detail author row only had the pubky initial. Fetch the author's pubky.app profile (IdentityRepository.fetchProfile) and fold its avatar URL into Content.authorAvatarUrl so native UIs can render the real picture, falling back to the initial when absent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../echo/presentation/decks/DeckDetailViewModel.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DeckDetailViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DeckDetailViewModel.kt index e0ac38e..c89db18 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DeckDetailViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DeckDetailViewModel.kt @@ -80,6 +80,7 @@ class DeckDetailViewModel( _state.update { deck.toContent(cards, myPubky, dueCount, mastered) } Log.d(TAG, "load: cards=${cards.size} due=$dueCount mastered=$mastered") loadCoverBlob(deck.coverImageRef) + loadAuthorAvatar(deck.authorPubky) } .onFailure { err -> Log.e(TAG, "load: FAILED โ€” ${err::class.simpleName}: ${err.message}", err) @@ -165,6 +166,18 @@ class DeckDetailViewModel( } } + /** + * Fetches the author's pubky.app profile and folds its avatar URL into the current [Content] so + * the author row can show a real picture (falling back to the initial when absent or unset). + */ + private suspend fun loadAuthorAvatar(authorPubky: String) { + val avatar = identityRepository.fetchProfile(authorPubky).getOrNull()?.avatarUrl + if (avatar.isNullOrBlank()) return + _state.update { current -> + (current as? DeckDetailUiState.Content)?.copy(authorAvatarUrl = avatar) ?: current + } + } + private fun Deck.toContent( cards: List, myPubky: String?, @@ -215,6 +228,7 @@ sealed interface DeckDetailUiState { val coverImageUrl: String? = null, val coverImageBase64: String? = null, val authorName: String?, + val authorAvatarUrl: String? = null, val authorPubky: String, val authorInitial: Char, val isOwned: Boolean, From 0366e61bd506fa4ed62338771236a4c5364ece78 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Sun, 21 Jun 2026 19:44:20 -0300 Subject: [PATCH 6/8] feat(android): render author avatar in AuthorRow AuthorRow drew only the initial letter. Add an optional avatarUrl: when set, render the picture via Coil over the initial (which stays underneath as the placeholder/error fallback while the image loads or if it fails). DeckDetail passes the author's avatar from the shared state. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jvsena42/echo/ui/components/AuthorRow.kt | 15 ++++++++++++++- .../jvsena42/echo/ui/decks/DeckDetailScreen.kt | 1 + 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/AuthorRow.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/AuthorRow.kt index d0e7336..b7f8544 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/AuthorRow.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/AuthorRow.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -15,12 +16,14 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage import com.github.jvsena42.echo.R import com.github.jvsena42.echo.ui.theme.EchoTheme @@ -30,6 +33,7 @@ fun AuthorRow( pubky: String, initial: Char, modifier: Modifier = Modifier, + avatarUrl: String? = null, isOwned: Boolean = false, isFollowing: Boolean = false, onFollowClick: () -> Unit = {}, @@ -41,7 +45,8 @@ fun AuthorRow( modifier = modifier, verticalAlignment = Alignment.CenterVertically, ) { - // Avatar + // Avatar โ€” the picture when set, otherwise the initial. The initial sits underneath so it + // also shows while the image loads or if it fails. Box( modifier = Modifier .size(32.dp) @@ -55,6 +60,14 @@ fun AuthorRow( fontWeight = FontWeight.W800, color = colors.accentSecondary, ) + if (!avatarUrl.isNullOrBlank()) { + AsyncImage( + model = avatarUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } } Spacer(modifier = Modifier.width(10.dp)) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt index 984e767..7b9675f 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt @@ -270,6 +270,7 @@ private fun DeckDetailContent( name = state.authorName, pubky = state.authorPubky, initial = state.authorInitial, + avatarUrl = state.authorAvatarUrl, isOwned = state.isOwned, modifier = Modifier.fillMaxWidth(), ) From 8ac8dde726a845909d6d1a61c797254147f46502 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Sun, 21 Jun 2026 19:51:44 -0300 Subject: [PATCH 7/8] chore(deck-detail): drop untracked "last studied" line and field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deck's "last studied" date was never tracked โ€” Deck.lastStudiedAt was always null (never read or written) and the OwnedBadgeRow showed a static "Last studied..." placeholder. Remove the dead field, the unused string resource, and the placeholder line, leaving just the "In your library" badge. Real last-studied tracking can be added later via SRS review state. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../echo/ui/decks/DeckDetailScreen.kt | 46 ++++++++----------- .../src/androidMain/res/values/strings.xml | 1 - .../github/jvsena42/echo/domain/model/Deck.kt | 1 - 3 files changed, 18 insertions(+), 30 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt index 7b9675f..f505a73 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt @@ -462,34 +462,24 @@ private fun CoverSection( @Composable private fun OwnedBadgeRow() { val colors = EchoTheme.colors - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - AssistChip( - onClick = {}, - label = { - Text( - text = stringResource(R.string.deck_detail_in_your_library), - fontSize = 11.sp, - fontWeight = FontWeight.W700, - letterSpacing = 0.5.sp, - ) - }, - shape = RoundedCornerShape(50), - colors = AssistChipDefaults.assistChipColors( - containerColor = colors.srsGood, - labelColor = colors.foregroundOnAccent, - ), - border = null, - ) - - Text( - text = stringResource(R.string.deck_detail_last_studied), - color = colors.foregroundMuted, - fontSize = 11.sp, - ) - } + // The "last studied" date is not tracked yet, so only the library badge is shown for now. + AssistChip( + onClick = {}, + label = { + Text( + text = stringResource(R.string.deck_detail_in_your_library), + fontSize = 11.sp, + fontWeight = FontWeight.W700, + letterSpacing = 0.5.sp, + ) + }, + shape = RoundedCornerShape(50), + colors = AssistChipDefaults.assistChipColors( + containerColor = colors.srsGood, + labelColor = colors.foregroundOnAccent, + ), + border = null, + ) } @Composable diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml index c0647b9..4775d46 100644 --- a/composeApp/src/androidMain/res/values/strings.xml +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -51,7 +51,6 @@ Delete Cancel IN YOUR LIBRARY - Last studied... New Deck diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/Deck.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/Deck.kt index 02745d3..4829cdb 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/Deck.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/Deck.kt @@ -13,7 +13,6 @@ data class Deck( val createdAt: Long, val updatedAt: Long, val cardIndex: List, - val lastStudiedAt: Long? = null, /** Opt-in: play TTS audio of the card back during study. */ val listenEnabled: Boolean = true, /** Opt-in: pronunciation practice (speech recognition) on the card back during study. */ From febf2411d2ccb5d62f905cf99d7318b4891de506 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Sun, 21 Jun 2026 19:57:14 -0300 Subject: [PATCH 8/8] design: polish deck detail --- design/main/phone-echo.pen | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/design/main/phone-echo.pen b/design/main/phone-echo.pen index 440b854..db12136 100644 --- a/design/main/phone-echo.pen +++ b/design/main/phone-echo.pen @@ -1668,6 +1668,8 @@ "gap": 8, "padding": [ 18, + 24, + 34, 24 ], "justifyContent": "center", @@ -1694,6 +1696,14 @@ "fontWeight": "700" } ] + }, + { + "type": "frame", + "id": "z0g4l1", + "name": "spacer", + "width": "fill_container", + "height": "fill_container", + "fill": "#00000000" } ] } @@ -8147,16 +8157,6 @@ "letterSpacing": 0.5 } ] - }, - { - "type": "text", - "id": "ugFgf", - "name": "lastStudied", - "fill": "$foreground-muted", - "content": "ยท Last studied 2h ago", - "fontFamily": "Inter", - "fontSize": 11, - "fontWeight": "500" } ] }, @@ -8452,6 +8452,14 @@ } ] }, + { + "type": "frame", + "id": "z81PsD", + "name": "spacer", + "width": "fill_container", + "height": "fill_container", + "fill": "#00000000" + }, { "type": "frame", "id": "dnymj",