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,