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..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,8 @@ fun AuthorRow( pubky: String, initial: Char, modifier: Modifier = Modifier, + avatarUrl: String? = null, + isOwned: Boolean = false, isFollowing: Boolean = false, onFollowClick: () -> Unit = {}, ) { @@ -40,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) @@ -54,52 +60,63 @@ 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)) - // 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 +145,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..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 @@ -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) { @@ -260,6 +270,8 @@ private fun DeckDetailContent( name = state.authorName, pubky = state.authorPubky, initial = state.authorInitial, + avatarUrl = state.authorAvatarUrl, + isOwned = state.isOwned, modifier = Modifier.fillMaxWidth(), ) @@ -399,12 +411,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() @@ -413,44 +443,43 @@ 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, + ) + } } } @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 d4ee10b..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 @@ -285,6 +284,7 @@ %1$d cards Following Follow + \@you Total Due Mastered 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", 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, 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/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. */ 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..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 @@ -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,8 @@ 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) + loadAuthorAvatar(deck.authorPubky) } .onFailure { err -> Log.e(TAG, "load: FAILED — ${err::class.simpleName}: ${err.message}", err) @@ -142,6 +150,34 @@ 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 + } + } + + /** + * 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?, @@ -154,6 +190,7 @@ class DeckDetailViewModel( title = title, description = description, coverEmoji = coverEmoji ?: title.firstOrNull()?.toString() ?: "📚", + coverImageUrl = coverImageRef?.url, authorName = null, authorPubky = authorPubky, authorInitial = authorPubky.firstOrNull()?.uppercaseChar() ?: '?', @@ -188,7 +225,10 @@ sealed interface DeckDetailUiState { val title: String, val description: String?, val coverEmoji: String, + val coverImageUrl: String? = null, + val coverImageBase64: String? = null, val authorName: String?, + val authorAvatarUrl: String? = null, val authorPubky: String, val authorInitial: Char, val isOwned: Boolean,