Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -30,6 +33,8 @@ fun AuthorRow(
pubky: String,
initial: Char,
modifier: Modifier = Modifier,
avatarUrl: String? = null,
isOwned: Boolean = false,
isFollowing: Boolean = false,
onFollowClick: () -> Unit = {},
) {
Expand All @@ -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)
Expand All @@ -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,
)
}
}
}
}
Expand Down Expand Up @@ -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,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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) {
Expand All @@ -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(),
)

Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion composeApp/src/androidMain/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@
<string name="deck_detail_delete_confirm">Delete</string>
<string name="deck_detail_delete_cancel">Cancel</string>
<string name="deck_detail_in_your_library">IN YOUR LIBRARY</string>
<string name="deck_detail_last_studied">Last studied...</string>

<!-- Deck editor -->
<string name="deck_editor_title_new">New Deck</string>
Expand Down Expand Up @@ -285,6 +284,7 @@
<string name="component_deck_tile_card_count">%1$d cards</string>
<string name="component_author_row_following">Following</string>
<string name="component_author_row_follow">Follow</string>
<string name="component_author_row_you">\@you</string>
<string name="component_stats_bar_total">Total</string>
<string name="component_stats_bar_due">Due</string>
<string name="component_stats_bar_mastered">Mastered</string>
Expand Down
28 changes: 18 additions & 10 deletions design/main/phone-echo.pen
Original file line number Diff line number Diff line change
Expand Up @@ -1668,6 +1668,8 @@
"gap": 8,
"padding": [
18,
24,
34,
24
],
"justifyContent": "center",
Expand All @@ -1694,6 +1696,14 @@
"fontWeight": "700"
}
]
},
{
"type": "frame",
"id": "z0g4l1",
"name": "spacer",
"width": "fill_container",
"height": "fill_container",
"fill": "#00000000"
}
]
}
Expand Down Expand Up @@ -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"
}
]
},
Expand Down Expand Up @@ -8452,6 +8452,14 @@
}
]
},
{
"type": "frame",
"id": "z81PsD",
"name": "spacer",
"width": "fill_container",
"height": "fill_container",
"fill": "#00000000"
},
{
"type": "frame",
"id": "dnymj",
Expand Down
2 changes: 2 additions & 0 deletions iosApp/iosApp/Views/DeckDetailScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading