From 05efc947bda3eebb5525944ff3a60c877e9645a1 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 16 Jun 2026 20:34:26 -0300 Subject: [PATCH 01/20] feat(deck): add listen/speak deck options and web image refs to schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add listen_enabled/speak_enabled to the deck manifest and an optional url field to media refs (web images saved by URL, no blob). Add SpeakMatcher for pronunciation comparison. All additive — schema stays version 1. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/Architecture.md | 4 + .../jvsena42/echo/data/pubky/DeckDtos.kt | 14 ++- .../github/jvsena42/echo/domain/model/Deck.kt | 4 + .../jvsena42/echo/domain/model/Media.kt | 10 +- .../echo/domain/model/SpeakMatcher.kt | 56 +++++++++++ .../jvsena42/echo/data/pubky/DeckDtosTest.kt | 93 +++++++++++++++++++ .../echo/domain/model/SpeakMatcherTest.kt | 44 +++++++++ 7 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/SpeakMatcher.kt create mode 100644 shared/src/commonTest/kotlin/com/github/jvsena42/echo/data/pubky/DeckDtosTest.kt create mode 100644 shared/src/commonTest/kotlin/com/github/jvsena42/echo/domain/model/SpeakMatcherTest.kt diff --git a/docs/Architecture.md b/docs/Architecture.md index efd114b..58f2cc0 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -289,6 +289,8 @@ Published decks live under the author's pubky, one record per card plus a manife "tags": ["spanish", "a1"], "created_at": 1739000000000, "updated_at": 1739000500000, + "listen_enabled": true, + "speak_enabled": true, "cards": [ { "id": "uuid-1", "updated_at": 1739000100000 }, { "id": "uuid-2", "updated_at": 1739000200000 } @@ -298,6 +300,8 @@ Published decks live under the author's pubky, one record per card plus a manife - `cards[]` order **is** the study order. - Manifest `updated_at` bumps on any deck-metadata change or any card add/remove/reorder. A per-card edit bumps the card record and its entry in the manifest. +- `listen_enabled` / `speak_enabled` are deck-level study opt-ins (TTS playback of the back / pronunciation practice). Both default `true`; manifests written before these fields existed decode to `true`. Additive — schema stays `1`. +- A media ref (`cover_image_ref`, `image_ref`, `audio_ref`) may instead carry a `"url"` field for a **web image** (e.g. an Unsplash photo). When `url` is set, `path`/`sha256` are empty (`""`) and no blob is stored on the homeserver; the client loads the remote URL directly. **`cards/{cardId}.json`:** diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/pubky/DeckDtos.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/pubky/DeckDtos.kt index 9b7fcad..3e1c783 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/pubky/DeckDtos.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/pubky/DeckDtos.kt @@ -23,6 +23,8 @@ internal data class ManifestDto( val created_at: Long, val updated_at: Long, val cards: List = emptyList(), + val listen_enabled: Boolean = true, + val speak_enabled: Boolean = true, ) @Serializable @@ -50,12 +52,14 @@ internal data class CardSideDto( @Serializable internal data class MediaRefDto( - val path: String, + val path: String = "", val mime: String, - val sha256: String, + val sha256: String = "", val width: Int? = null, val height: Int? = null, val duration_ms: Long? = null, + /** Set for web images referenced by URL; [path]/[sha256] are then empty. */ + val url: String? = null, ) // --- Mapping ------------------------------------------------------------------ @@ -71,6 +75,8 @@ internal fun Deck.toDto() = ManifestDto( created_at = createdAt, updated_at = updatedAt, cards = cardIndex.map { CardIndexDto(it.id, it.updatedAt) }, + listen_enabled = listenEnabled, + speak_enabled = speakEnabled, ) internal fun ManifestDto.toDomain() = Deck( @@ -84,6 +90,8 @@ internal fun ManifestDto.toDomain() = Deck( createdAt = created_at, updatedAt = updated_at, cardIndex = cards.map { CardIndexEntry(it.id, it.updated_at) }, + listenEnabled = listen_enabled, + speakEnabled = speak_enabled, ) internal fun Card.toDto() = CardDto( @@ -120,6 +128,7 @@ internal fun MediaRef.Image.toDto() = MediaRefDto( sha256 = sha256, width = width, height = height, + url = url, ) internal fun MediaRef.Audio.toDto() = MediaRefDto( @@ -135,6 +144,7 @@ internal fun MediaRefDto.toImageDomain() = MediaRef.Image( sha256 = sha256, width = width, height = height, + url = url, ) internal fun MediaRefDto.toAudioDomain() = MediaRef.Audio( 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 a6200ab..02745d3 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 @@ -14,6 +14,10 @@ data class Deck( 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. */ + val speakEnabled: Boolean = true, ) { val cardCount: Int get() = cardIndex.size val pubkyUri: PubkyUri get() = PubkyUri("pubky://$authorPubky/pub/echo/decks/$id/manifest.json") diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/Media.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/Media.kt index 5b3b40e..9e48ec7 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/Media.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/Media.kt @@ -11,7 +11,15 @@ sealed class MediaRef { override val sha256: String, val width: Int?, val height: Int?, - ) : MediaRef() + /** + * When set, this image is a remote web image referenced by URL (e.g. an Unsplash + * photo) rather than a blob stored on the homeserver. For remote images [path] and + * [sha256] are empty and no blob is uploaded. + */ + val url: String? = null, + ) : MediaRef() { + val isRemote: Boolean get() = url != null + } data class Audio( override val path: String, diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/SpeakMatcher.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/SpeakMatcher.kt new file mode 100644 index 0000000..7f8f75b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/SpeakMatcher.kt @@ -0,0 +1,56 @@ +package com.github.jvsena42.echo.domain.model + +/** + * Outcome of comparing a spoken utterance against the expected card text. + * + * @property correct whether the normalized utterance matched the expected text + * @property heard the raw transcript the recognizer returned (shown on the "wrong" sheet) + * @property expected the raw expected text (shown on the "wrong" sheet) + */ +data class SpeakResult( + val correct: Boolean, + val heard: String, + val expected: String, +) + +/** + * Pure, testable comparison of a spoken answer to a card's expected text. + * + * Both sides are normalized (lowercased, diacritics stripped, punctuation removed, whitespace + * collapsed) before comparison so "El Zorro!" matches "el zorro". Kept framework-free so it can + * be unit-tested in commonTest and reused across platforms. + */ +object SpeakMatcher { + + fun match(spoken: String, expected: String): SpeakResult = SpeakResult( + correct = normalize(spoken) == normalize(expected) && normalize(expected).isNotEmpty(), + heard = spoken.trim(), + expected = expected.trim(), + ) + + /** Lowercase, strip diacritics, drop non-alphanumeric chars, collapse whitespace. */ + fun normalize(text: String): String = buildString { + for (ch in text.lowercase()) { + val base = stripDiacritic(ch) + when { + base.isLetterOrDigit() -> append(base) + base.isWhitespace() -> append(' ') + // drop everything else (punctuation, symbols) + } + } + }.trim().replace(WHITESPACE, " ") + + private val WHITESPACE = Regex("\\s+") + + /** Map the common Latin accented letters used by supported languages to their base form. */ + private fun stripDiacritic(ch: Char): Char = when (ch) { + 'á', 'à', 'â', 'ä', 'ã', 'å' -> 'a' + 'é', 'è', 'ê', 'ë' -> 'e' + 'í', 'ì', 'î', 'ï' -> 'i' + 'ó', 'ò', 'ô', 'ö', 'õ' -> 'o' + 'ú', 'ù', 'û', 'ü' -> 'u' + 'ñ' -> 'n' + 'ç' -> 'c' + else -> ch + } +} diff --git a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/data/pubky/DeckDtosTest.kt b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/data/pubky/DeckDtosTest.kt new file mode 100644 index 0000000..5ef126c --- /dev/null +++ b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/data/pubky/DeckDtosTest.kt @@ -0,0 +1,93 @@ +package com.github.jvsena42.echo.data.pubky + +import com.github.jvsena42.echo.data.repository.impl.echoJson +import com.github.jvsena42.echo.domain.model.CardIndexEntry +import com.github.jvsena42.echo.domain.model.Deck +import com.github.jvsena42.echo.domain.model.MediaRef +import com.github.jvsena42.echo.domain.model.Tag +import kotlinx.serialization.encodeToString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DeckDtosTest { + + private fun deck( + listenEnabled: Boolean = true, + speakEnabled: Boolean = true, + cover: MediaRef.Image? = null, + ) = Deck( + id = "deck1", + authorPubky = "pk:author", + title = "Spanish Basics", + description = "Greetings", + coverEmoji = "🇪🇸", + coverImageRef = cover, + tags = listOf(Tag("spanish"), Tag("a1")), + createdAt = 1_739_000_000_000L, + updatedAt = 1_739_000_500_000L, + cardIndex = listOf(CardIndexEntry("c1", 1L)), + listenEnabled = listenEnabled, + speakEnabled = speakEnabled, + ) + + @Test + fun manifestRoundTripPreservesOptions() { + val deck = deck(listenEnabled = false, speakEnabled = true) + val json = echoJson.encodeToString(deck.toDto()) + val back = echoJson.decodeFromString(json).toDomain() + assertFalse(back.listenEnabled) + assertTrue(back.speakEnabled) + } + + @Test + fun legacyManifestWithoutOptionsDefaultsToEnabled() { + // A manifest written before listen/speak existed. + val legacy = """ + {"schema_version":1,"deck_id":"d","author_pubky":"pk","title":"T", + "created_at":1,"updated_at":2,"cards":[]} + """.trimIndent() + val back = echoJson.decodeFromString(legacy).toDomain() + assertTrue(back.listenEnabled) + assertTrue(back.speakEnabled) + } + + @Test + fun webCoverImageRoundTripsWithUrlAndNoBlob() { + val cover = MediaRef.Image( + path = "", + mime = "image/jpeg", + sha256 = "", + width = null, + height = null, + url = "https://images.unsplash.com/photo-1.jpg", + ) + val back = echoJson.decodeFromString( + echoJson.encodeToString(deck(cover = cover).toDto()), + ).toDomain() + val image = back.coverImageRef!! + assertTrue(image.isRemote) + assertEquals("https://images.unsplash.com/photo-1.jpg", image.url) + assertEquals("", image.sha256) + } + + @Test + fun blobCoverImageHasNoUrl() { + val cover = MediaRef.Image( + path = "media/abc.jpg", + mime = "image/jpeg", + sha256 = "abc", + width = 512, + height = 512, + ) + val back = echoJson.decodeFromString( + echoJson.encodeToString(deck(cover = cover).toDto()), + ).toDomain() + val image = back.coverImageRef!! + assertFalse(image.isRemote) + assertNull(image.url) + assertEquals("abc", image.sha256) + } +} diff --git a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/domain/model/SpeakMatcherTest.kt b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/domain/model/SpeakMatcherTest.kt new file mode 100644 index 0000000..e16dce2 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/domain/model/SpeakMatcherTest.kt @@ -0,0 +1,44 @@ +package com.github.jvsena42.echo.domain.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SpeakMatcherTest { + + @Test + fun exactMatch() { + assertTrue(SpeakMatcher.match("el zorro", "el zorro").correct) + } + + @Test + fun caseInsensitive() { + assertTrue(SpeakMatcher.match("EL Zorro", "el zorro").correct) + } + + @Test + fun ignoresPunctuationAndExtraWhitespace() { + assertTrue(SpeakMatcher.match(" El, Zorro! ", "el zorro").correct) + } + + @Test + fun ignoresDiacritics() { + assertTrue(SpeakMatcher.match("el zorro", "él zörró").correct) + assertTrue(SpeakMatcher.match("buenos dias", "buenos días").correct) + } + + @Test + fun mismatchReportsHeardAndExpected() { + val result = SpeakMatcher.match("el zoro", "el zorro") + assertFalse(result.correct) + assertEquals("el zoro", result.heard) + assertEquals("el zorro", result.expected) + } + + @Test + fun emptyExpectedNeverMatches() { + assertFalse(SpeakMatcher.match("", "").correct) + assertFalse(SpeakMatcher.match("anything", "").correct) + } +} From b5a067c77a1f774c1690eb6ccd447edb6e4df170 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 16 Jun 2026 20:43:56 -0300 Subject: [PATCH 02/20] feat(import): add review-cards triage step and route create-deck to paste flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deck creation now always starts at the Paste import flow (design h9wya) — the home empty-state and Decks 'Create' no longer open the manual editor. Add a Triage/Review-cards screen (U92Nh) between paste preview and publish: keep, discard, edit, or approve-all each parsed card. ImportRepository tracks the per-row decisions and edits; publish consumes keptRows(). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/importflow/TriageEditCardScreen.kt | 183 ++++++++++ .../echo/ui/importflow/TriageScreen.kt | 315 ++++++++++++++++++ .../jvsena42/echo/ui/nav/EchoNavigation.kt | 22 +- .../com/github/jvsena42/echo/ui/nav/Routes.kt | 3 + .../src/androidMain/res/values/strings.xml | 40 +++ .../echo/data/repository/Repositories.kt | 13 + .../repository/impl/ImportRepositoryImpl.kt | 40 +++ .../github/jvsena42/echo/di/SharedModule.kt | 2 + .../jvsena42/echo/domain/model/Import.kt | 15 + .../importflow/PublishDeckViewModel.kt | 10 +- .../importflow/TriageViewModel.kt | 139 ++++++++ .../impl/ImportRepositoryImplTest.kt | 39 +++ .../jvsena42/echo/testing/FakeRepositories.kt | 18 + 13 files changed, 833 insertions(+), 6 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageEditCardScreen.kt create mode 100644 composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/TriageViewModel.kt diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageEditCardScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageEditCardScreen.kt new file mode 100644 index 0000000..4ab14f1 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageEditCardScreen.kt @@ -0,0 +1,183 @@ +package com.github.jvsena42.echo.ui.importflow + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.github.jvsena42.echo.R +import com.github.jvsena42.echo.data.repository.ImportRepository +import com.github.jvsena42.echo.domain.model.frontBackOf +import com.github.jvsena42.echo.ui.theme.EchoTheme +import org.koin.compose.koinInject + +@Composable +fun TriageEditCardRoute( + rowIndex: Int, + onBack: () -> Unit = {}, +) { + val importRepository = koinInject() + val currentBack by rememberUpdatedState(onBack) + + val initial = remember(rowIndex) { + val draft = importRepository.currentDraft() + val row = draft?.rows?.firstOrNull { it.index == rowIndex } + if (draft != null && row != null) draft.frontBackOf(row) else "" to "" + } + var front by remember(rowIndex) { mutableStateOf(initial.first) } + var back by remember(rowIndex) { mutableStateOf(initial.second) } + + TriageEditCardScreen( + front = front, + back = back, + onFrontChange = { front = it }, + onBackChange = { back = it }, + onCancel = currentBack, + onSave = { + importRepository.updateRow(rowIndex, front, back) + currentBack() + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TriageEditCardScreen( + front: String, + back: String, + onFrontChange: (String) -> Unit, + onBackChange: (String) -> Unit, + onCancel: () -> Unit, + onSave: () -> Unit, +) { + val colors = EchoTheme.colors + + Scaffold( + containerColor = colors.surfacePrimary, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(R.string.edit_card_title), + fontSize = 18.sp, + fontWeight = FontWeight.ExtraBold, + ) + }, + navigationIcon = { + IconButton(onClick = onCancel) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.edit_card_cancel), + tint = colors.foregroundPrimary, + ) + } + }, + actions = { + TextButton( + onClick = onSave, + modifier = Modifier.testTag("triage_edit_save"), + colors = ButtonDefaults.textButtonColors(contentColor = colors.accentPrimary), + ) { + Text(text = stringResource(R.string.edit_card_save), fontSize = 16.sp, fontWeight = FontWeight.Bold) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = colors.surfacePrimary, + titleContentColor = colors.foregroundPrimary, + ), + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + FieldSection( + label = stringResource(R.string.triage_front_label), + value = front, + onValueChange = onFrontChange, + placeholder = stringResource(R.string.edit_card_front_placeholder), + textStyle = TextStyle(fontSize = 20.sp, fontWeight = FontWeight.Bold), + tag = "triage_edit_front", + ) + FieldSection( + label = stringResource(R.string.triage_back_label), + value = back, + onValueChange = onBackChange, + placeholder = stringResource(R.string.edit_card_back_placeholder), + textStyle = TextStyle(fontSize = 16.sp), + tag = "triage_edit_back", + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FieldSection( + label: String, + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + textStyle: TextStyle, + tag: String, +) { + val colors = EchoTheme.colors + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = label, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + color = colors.foregroundMuted, + ) + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .testTag(tag), + textStyle = textStyle.copy(color = colors.foregroundPrimary), + placeholder = { Text(text = placeholder, style = textStyle, color = colors.foregroundMuted) }, + shape = RoundedCornerShape(16.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = colors.accentPrimary, + unfocusedBorderColor = colors.borderSubtle, + cursorColor = colors.accentPrimary, + ), + ) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageScreen.kt new file mode 100644 index 0000000..8ad3d3d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageScreen.kt @@ -0,0 +1,315 @@ +package com.github.jvsena42.echo.ui.importflow + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.github.jvsena42.echo.R +import com.github.jvsena42.echo.presentation.importflow.TriageCard +import com.github.jvsena42.echo.presentation.importflow.TriageEffect +import com.github.jvsena42.echo.presentation.importflow.TriageUiState +import com.github.jvsena42.echo.presentation.importflow.TriageViewModel +import com.github.jvsena42.echo.ui.theme.EchoTheme +import kotlinx.coroutines.flow.collectLatest +import org.koin.compose.koinInject + +@Composable +fun TriageRoute( + onBack: () -> Unit = {}, + onEditCard: (Int) -> Unit = {}, + onNext: () -> Unit = {}, +) { + val viewModel = koinInject() + DisposableEffect(viewModel) { onDispose { viewModel.onDispose() } } + + // Re-read the draft each time this screen resumes (e.g. after editing a card). + LaunchedEffect(viewModel) { viewModel.refresh() } + + val currentBack by rememberUpdatedState(onBack) + val currentEdit by rememberUpdatedState(onEditCard) + val currentNext by rememberUpdatedState(onNext) + + LaunchedEffect(viewModel) { + viewModel.effects.collectLatest { effect -> + when (effect) { + TriageEffect.NavigateBack -> currentBack() + is TriageEffect.NavigateEditCard -> currentEdit(effect.rowIndex) + TriageEffect.NavigatePublish -> currentNext() + } + } + } + + val state by viewModel.state.collectAsStateWithLifecycle() + TriageScreen( + state = state, + onBackClick = viewModel::onBackClick, + onApproveAll = viewModel::onApproveAll, + onDiscard = viewModel::onDiscard, + onEditClick = viewModel::onEditClick, + onKeep = viewModel::onKeep, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TriageScreen( + state: TriageUiState, + onBackClick: () -> Unit, + onApproveAll: () -> Unit, + onDiscard: () -> Unit, + onEditClick: () -> Unit, + onKeep: () -> Unit, +) { + val colors = EchoTheme.colors + + Scaffold( + containerColor = colors.surfaceSecondary, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(R.string.triage_title), + fontSize = 18.sp, + fontWeight = FontWeight.ExtraBold, + ) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.publish_back), + tint = colors.foregroundPrimary, + ) + } + }, + actions = { + TextButton( + onClick = onApproveAll, + modifier = Modifier.testTag("triage_approve_all"), + colors = ButtonDefaults.textButtonColors(contentColor = colors.accentPrimary), + ) { + Text( + text = stringResource(R.string.triage_approve_all), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = colors.surfaceSecondary, + titleContentColor = colors.foregroundPrimary, + ), + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Progress + stats + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.triage_progress, state.currentIndex + 1, state.total), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = colors.foregroundMuted, + modifier = Modifier.testTag("triage_progress"), + ) + Text( + text = stringResource(R.string.triage_stats, state.keptCount, state.discardedCount), + fontSize = 12.sp, + color = colors.foregroundMuted, + ) + } + + val card = state.currentCard + if (card != null) { + TriageCardView(card = card, modifier = Modifier.weight(1f)) + } else { + Spacer(Modifier.weight(1f)) + } + + state.error?.let { Text(it, fontSize = 13.sp, color = colors.danger) } + + // Action buttons: discard / edit / keep + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + CircleActionButton( + onClick = onDiscard, + tag = "triage_discard", + background = colors.dangerSoft, + iconTint = colors.srsAgain, + size = 56, + ) { + Icon(Icons.Default.Close, stringResource(R.string.triage_discard), modifier = Modifier.size(28.dp)) + } + CircleActionButton( + onClick = onEditClick, + tag = "triage_edit", + background = colors.surfaceCard, + iconTint = colors.foregroundSecondary, + size = 48, + ) { + Icon(Icons.Default.Edit, stringResource(R.string.triage_edit), modifier = Modifier.size(20.dp)) + } + CircleActionButton( + onClick = onKeep, + tag = "triage_keep", + background = colors.srsGood, + iconTint = colors.foregroundOnAccent, + size = 56, + ) { + Icon(Icons.Default.Check, stringResource(R.string.triage_keep), modifier = Modifier.size(28.dp)) + } + } + } + } +} + +@Composable +private fun TriageCardView(card: TriageCard, modifier: Modifier = Modifier) { + val colors = EchoTheme.colors + Column( + modifier = modifier + .fillMaxWidth() + .shadow(16.dp, RoundedCornerShape(24.dp)) + .clip(RoundedCornerShape(24.dp)) + .background(colors.surfaceCard) + .testTag("triage_card") + .padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.triage_front_label), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + color = colors.foregroundMuted, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = card.front.ifBlank { "—" }, + fontSize = 28.sp, + fontWeight = FontWeight.ExtraBold, + color = colors.foregroundPrimary, + textAlign = TextAlign.Center, + modifier = Modifier.testTag("triage_front"), + ) + Spacer(Modifier.height(16.dp)) + Box(Modifier.width(40.dp).height(2.dp).background(colors.accentPrimary)) + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.triage_back_label), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + color = colors.foregroundMuted, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = card.back.ifBlank { "—" }, + fontSize = 20.sp, + color = colors.foregroundSecondary, + textAlign = TextAlign.Center, + modifier = Modifier.testTag("triage_back"), + ) + } +} + +@Composable +private fun CircleActionButton( + onClick: () -> Unit, + tag: String, + background: Color, + iconTint: Color, + size: Int, + content: @Composable () -> Unit, +) { + IconButton( + onClick = onClick, + modifier = Modifier + .size(size.dp) + .clip(CircleShape) + .background(background) + .testTag(tag), + colors = IconButtonDefaults.iconButtonColors(contentColor = iconTint), + ) { + content() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun TriageScreenPreview() { + EchoTheme { + TriageScreen( + state = TriageUiState( + cards = listOf(TriageCard(0, "por favor", "please")), + currentIndex = 11, + total = 42, + keptCount = 11, + discardedCount = 0, + ), + onBackClick = {}, + onApproveAll = {}, + onDiscard = {}, + onEditClick = {}, + onKeep = {}, + ) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/nav/EchoNavigation.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/nav/EchoNavigation.kt index acf1d1f..6683b6d 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/nav/EchoNavigation.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/nav/EchoNavigation.kt @@ -11,6 +11,8 @@ import com.github.jvsena42.echo.ui.decks.DeckEditorRoute import com.github.jvsena42.echo.ui.decks.EditCardRoute import com.github.jvsena42.echo.ui.importflow.PasteRoute import com.github.jvsena42.echo.ui.importflow.PublishDeckRoute +import com.github.jvsena42.echo.ui.importflow.TriageEditCardRoute +import com.github.jvsena42.echo.ui.importflow.TriageRoute import com.github.jvsena42.echo.ui.onboarding.OnboardingRoute import com.github.jvsena42.echo.ui.profile.FriendProfileRoute import com.github.jvsena42.echo.ui.settings.SettingsRoute @@ -35,7 +37,8 @@ fun EchoNavHost() { navController.navigate(Routes.deckDetail(deckId, author)) }, onNavigateCreateDeck = { - navController.navigate(Routes.DECK_EDITOR_NEW) + // Deck creation always starts at the Paste import flow (design node h9wya). + navController.navigate(Routes.IMPORT_PASTE) }, onNavigateImport = { navController.navigate(Routes.IMPORT_PASTE) @@ -116,9 +119,26 @@ fun EchoNavHost() { composable(Routes.IMPORT_PASTE) { PasteRoute( onCancel = { navController.popBackStack() }, + onNext = { navController.navigate(Routes.IMPORT_TRIAGE) }, + ) + } + composable(Routes.IMPORT_TRIAGE) { + TriageRoute( + onBack = { navController.popBackStack() }, + onEditCard = { rowIndex -> navController.navigate(Routes.triageEditCard(rowIndex)) }, onNext = { navController.navigate(Routes.IMPORT_PUBLISH) }, ) } + composable( + route = Routes.IMPORT_TRIAGE_EDIT, + arguments = listOf(navArgument("rowIndex") { type = NavType.IntType }), + ) { backStackEntry -> + val rowIndex = backStackEntry.arguments?.getInt("rowIndex") ?: return@composable + TriageEditCardRoute( + rowIndex = rowIndex, + onBack = { navController.popBackStack() }, + ) + } composable(Routes.IMPORT_PUBLISH) { PublishDeckRoute( onBack = { navController.popBackStack() }, diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/nav/Routes.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/nav/Routes.kt index 489bde6..0686167 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/nav/Routes.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/nav/Routes.kt @@ -10,6 +10,8 @@ object Routes { const val EDIT_CARD = "deck/{deckId}/card/{cardId}/edit" const val IMPORT_PASTE = "import/paste" + const val IMPORT_TRIAGE = "import/triage" + const val IMPORT_TRIAGE_EDIT = "import/triage/edit/{rowIndex}" const val IMPORT_PUBLISH = "import/publish" /** Study session. `deckId` omitted = study all due cards across owned decks. */ @@ -24,4 +26,5 @@ object Routes { fun deckEditor(deckId: String) = "deck/editor/$deckId" fun editCard(deckId: String, cardId: String) = "deck/$deckId/card/$cardId/edit" fun study(deckId: String?) = if (deckId != null) "study?deckId=$deckId" else "study" + fun triageEditCard(rowIndex: Int) = "import/triage/edit/$rowIndex" } diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml index 54157aa..81e677d 100644 --- a/composeApp/src/androidMain/res/values/strings.xml +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -177,6 +177,46 @@ %1$d cards are now public on your Pubky. Done Undo (%1$ds) + CARD OPTIONS + Listen + Play audio pronunciation on card back + Speak + Test your pronunciation + Cover image + + + Review cards + Approve all + %1$d of %2$d + %1$d kept · %2$d discarded + FRONT + BACK + Discard + Edit + Keep + + + Front image + Choose an image for the front of the card + Done + Search images… + From gallery + Search the web or pick from your gallery + No images found + Remove image + + + Say the word + Tap to cancel + Perfect! + YOU SAID + Continue + Not quite right + You said + Correct + Try again + Microphone permission is needed to practice speaking. + Speech recognition is unavailable on this device. Learn anything,\nremember everything. diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/Repositories.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/Repositories.kt index 852bf3e..27e30f3 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/Repositories.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/Repositories.kt @@ -4,12 +4,14 @@ import com.github.jvsena42.echo.domain.model.Card import com.github.jvsena42.echo.domain.model.Deck import com.github.jvsena42.echo.domain.model.ImportDraft import com.github.jvsena42.echo.domain.model.MediaRef +import com.github.jvsena42.echo.domain.model.ParsedRow import com.github.jvsena42.echo.domain.model.PubkyIdentity import com.github.jvsena42.echo.domain.model.PubkyUri import com.github.jvsena42.echo.domain.model.Session import com.github.jvsena42.echo.domain.model.SrsGrade import com.github.jvsena42.echo.domain.model.SrsState import com.github.jvsena42.echo.domain.model.Tag +import com.github.jvsena42.echo.domain.model.TriageDecision interface IdentityRepository { suspend fun currentSession(): Session? @@ -78,6 +80,17 @@ interface CardRepository { interface ImportRepository { fun currentDraft(): ImportDraft? suspend fun parse(rawText: String): Result + + /** Per-row keep/discard decisions made during triage (default [TriageDecision.Keep]). */ + fun decisions(): Map + fun setDecision(rowIndex: Int, decision: TriageDecision) + + /** Override a draft row's front/back text (triage edit). */ + fun updateRow(rowIndex: Int, front: String, back: String) + + /** The rows kept after triage, with any edits applied. */ + fun keptRows(): List + fun clear() } diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/impl/ImportRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/impl/ImportRepositoryImpl.kt index 240cec8..55168e4 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/impl/ImportRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/impl/ImportRepositoryImpl.kt @@ -7,14 +7,52 @@ import com.github.jvsena42.echo.domain.model.ImportDraft import com.github.jvsena42.echo.domain.model.ParseFlag import com.github.jvsena42.echo.domain.model.ParsedRow import com.github.jvsena42.echo.domain.model.Separator +import com.github.jvsena42.echo.domain.model.TriageDecision +import com.github.jvsena42.echo.domain.model.backIndex +import com.github.jvsena42.echo.domain.model.frontIndex class ImportRepositoryImpl : ImportRepository { private var draft: ImportDraft? = null + private val triageDecisions = mutableMapOf() + private val rowEdits = mutableMapOf>() override fun currentDraft(): ImportDraft? = draft + override fun decisions(): Map = triageDecisions.toMap() + + override fun setDecision(rowIndex: Int, decision: TriageDecision) { + triageDecisions[rowIndex] = decision + } + + override fun updateRow(rowIndex: Int, front: String, back: String) { + rowEdits[rowIndex] = front.trim() to back.trim() + } + + override fun keptRows(): List { + val d = draft ?: return emptyList() + val frontIdx = d.frontIndex() + val backIdx = d.backIndex() + return d.rows + .filter { triageDecisions[it.index] != TriageDecision.Discard } + .map { row -> applyEdit(row, frontIdx, backIdx) } + } + + /** Returns [row] with any triage edit applied to its front/back fields. */ + private fun applyEdit(row: ParsedRow, frontIdx: Int, backIdx: Int): ParsedRow { + val edit = rowEdits[row.index] ?: return row + val (front, back) = edit + val fields = row.fields.toMutableList() + while (fields.size <= maxOf(frontIdx, backIdx)) fields.add("") + fields[frontIdx] = front + fields[backIdx] = back + return row.copy(fields = fields, isValid = front.isNotBlank() || back.isNotBlank()) + } + override suspend fun parse(rawText: String): Result = runCatching { + // A fresh parse invalidates any prior triage decisions/edits. + triageDecisions.clear() + rowEdits.clear() val text = rawText.replace("\r\n", "\n").replace("\r", "\n").trim() require(text.isNotEmpty()) { "Nothing to import." } require(text.length <= MAX_CHARS) { "Text is too long (max $MAX_CHARS characters)." } @@ -64,6 +102,8 @@ class ImportRepositoryImpl : ImportRepository { override fun clear() { draft = null + triageDecisions.clear() + rowEdits.clear() } // --- Separator detection (spec §6 rule order) --- 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 115f4e9..d827334 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 @@ -29,6 +29,7 @@ import com.github.jvsena42.echo.presentation.discover.DiscoverViewModel import com.github.jvsena42.echo.presentation.home.HomeViewModel import com.github.jvsena42.echo.presentation.importflow.PasteImportViewModel import com.github.jvsena42.echo.presentation.importflow.PublishDeckViewModel +import com.github.jvsena42.echo.presentation.importflow.TriageViewModel import com.github.jvsena42.echo.presentation.onboarding.OnboardingViewModel import com.github.jvsena42.echo.presentation.profile.FriendProfileViewModel import com.github.jvsena42.echo.presentation.profile.ProfileViewModel @@ -98,6 +99,7 @@ val sharedModule = module { ) } factory { PasteImportViewModel(importRepository = get()) } + factory { TriageViewModel(importRepository = get()) } factory { PublishDeckViewModel(importRepository = get(), deckRepository = get(), identityRepository = get()) } factory { ProfileViewModel(identityRepository = get(), deckRepository = get()) } factory { params -> SettingsViewModel(identityRepository = get(), appVersion = params.getOrNull() ?: "") } diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/Import.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/Import.kt index ab2c932..147c6e9 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/Import.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/Import.kt @@ -15,6 +15,21 @@ data class ParsedRow( val isValid: Boolean, ) +/** A keep/discard decision made during triage (spec §5.5). Rows default to [Keep]. */ +enum class TriageDecision { Keep, Discard } + +/** Index of the field mapped to the card front (falls back to 0). */ +fun ImportDraft.frontIndex(): Int = + columnMapping.assignments.indexOfFirst { it == ColumnRole.Front }.takeIf { it >= 0 } ?: 0 + +/** Index of the field mapped to the card back (falls back to 1). */ +fun ImportDraft.backIndex(): Int = + columnMapping.assignments.indexOfFirst { it == ColumnRole.Back }.takeIf { it >= 0 } ?: 1 + +/** The (front, back) text pair for [row] using this draft's column mapping. */ +fun ImportDraft.frontBackOf(row: ParsedRow): Pair = + row.fields.getOrElse(frontIndex()) { "" } to row.fields.getOrElse(backIndex()) { "" } + sealed class Separator { data object Auto : Separator() data object Tab : Separator() diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt index 271ac99..a299061 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt @@ -45,9 +45,8 @@ class PublishDeckViewModel( private var undoCountdownJob: Job? = null init { - val draft = importRepository.currentDraft() - if (draft != null) { - _state.update { it.copy(cardCount = draft.rows.size) } + if (importRepository.currentDraft() != null) { + _state.update { it.copy(cardCount = importRepository.keptRows().size) } } } @@ -90,7 +89,7 @@ class PublishDeckViewModel( publishJob = scope.launch { _state.update { it.copy(isPublishing = true, error = null) } - Log.d(TAG, "publish: title=${s.title}, cards=${draft.rows.size}") + Log.d(TAG, "publish: title=${s.title}, cards=${importRepository.keptRows().size}") val session = runCatching { identityRepository.currentSession() }.getOrNull() ?: runCatching { identityRepository.loadPersistedSession() }.getOrNull() @@ -102,8 +101,9 @@ class PublishDeckViewModel( val now = epochMillis() val deckId = generateId() val mapping = draft.columnMapping.assignments + val keptRows = importRepository.keptRows() - val cards = draft.rows.mapIndexed { idx, row -> + val cards = keptRows.map { row -> val frontIdx = mapping.indexOfFirst { it == ColumnRole.Front }.takeIf { it >= 0 } ?: 0 val backIdx = mapping.indexOfFirst { it == ColumnRole.Back }.takeIf { it >= 0 } ?: 1 Card( diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/TriageViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/TriageViewModel.kt new file mode 100644 index 0000000..4787a96 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/TriageViewModel.kt @@ -0,0 +1,139 @@ +package com.github.jvsena42.echo.presentation.importflow + +import com.github.jvsena42.echo.data.repository.ImportRepository +import com.github.jvsena42.echo.domain.model.TriageDecision +import com.github.jvsena42.echo.domain.model.frontBackOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Review-cards / triage step (design node U92Nh, spec §5.5). Walks the parsed draft one card + * at a time letting the user keep, discard, or edit each. "Approve all" keeps everything and + * proceeds straight to publish. + */ +class TriageViewModel( + private val importRepository: ImportRepository, + mainScope: CoroutineScope? = null, +) { + private val scope: CoroutineScope = + mainScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main) + + private val _state = MutableStateFlow(TriageUiState()) + val state: StateFlow = _state.asStateFlow() + + private val _effects = MutableSharedFlow(extraBufferCapacity = 4) + val effects: SharedFlow = _effects.asSharedFlow() + + init { + refresh() + } + + /** Rebuilds card text from the draft (e.g. after returning from the edit screen). */ + fun refresh() { + val draft = importRepository.currentDraft() + if (draft == null) { + _state.value = TriageUiState() + return + } + val decisions = importRepository.decisions() + val cards = draft.rows.map { row -> + val (front, back) = draft.frontBackOf(row) + TriageCard(rowIndex = row.index, front = front, back = back) + } + _state.update { current -> + current.copy( + cards = cards, + total = cards.size, + currentIndex = current.currentIndex.coerceIn(0, maxOf(0, cards.size - 1)), + keptCount = decisions.count { it.value == TriageDecision.Keep }, + discardedCount = decisions.count { it.value == TriageDecision.Discard }, + ) + } + } + + fun onKeep() = decide(TriageDecision.Keep) + + fun onDiscard() = decide(TriageDecision.Discard) + + private fun decide(decision: TriageDecision) { + val s = _state.value + val card = s.cards.getOrNull(s.currentIndex) ?: return + importRepository.setDecision(card.rowIndex, decision) + val decisions = importRepository.decisions() + val kept = decisions.count { it.value == TriageDecision.Keep } + val discarded = decisions.count { it.value == TriageDecision.Discard } + val isLast = s.currentIndex >= s.total - 1 + if (isLast) { + _state.update { it.copy(keptCount = kept, discardedCount = discarded) } + proceed() + } else { + _state.update { + it.copy(currentIndex = it.currentIndex + 1, keptCount = kept, discardedCount = discarded) + } + } + } + + fun onEditClick() { + val card = _state.value.cards.getOrNull(_state.value.currentIndex) ?: return + scope.launch { _effects.emit(TriageEffect.NavigateEditCard(card.rowIndex)) } + } + + /** Keep every card not yet discarded and go to publish. */ + fun onApproveAll() { + _state.value.cards.forEach { card -> + if (importRepository.decisions()[card.rowIndex] != TriageDecision.Discard) { + importRepository.setDecision(card.rowIndex, TriageDecision.Keep) + } + } + proceed() + } + + fun onBackClick() { + scope.launch { _effects.emit(TriageEffect.NavigateBack) } + } + + private fun proceed() { + if (importRepository.keptRows().isEmpty()) { + _state.update { it.copy(error = "Keep at least one card to continue.") } + return + } + scope.launch { _effects.emit(TriageEffect.NavigatePublish) } + } + + fun onDispose() { + scope.cancel() + } +} + +data class TriageUiState( + val cards: List = emptyList(), + val currentIndex: Int = 0, + val total: Int = 0, + val keptCount: Int = 0, + val discardedCount: Int = 0, + val error: String? = null, +) { + val currentCard: TriageCard? get() = cards.getOrNull(currentIndex) +} + +data class TriageCard( + val rowIndex: Int, + val front: String, + val back: String, +) + +sealed interface TriageEffect { + data object NavigateBack : TriageEffect + data class NavigateEditCard(val rowIndex: Int) : TriageEffect + data object NavigatePublish : TriageEffect +} diff --git a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/data/repository/impl/ImportRepositoryImplTest.kt b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/data/repository/impl/ImportRepositoryImplTest.kt index a07333c..f01f87e 100644 --- a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/data/repository/impl/ImportRepositoryImplTest.kt +++ b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/data/repository/impl/ImportRepositoryImplTest.kt @@ -2,6 +2,7 @@ package com.github.jvsena42.echo.data.repository.impl import com.github.jvsena42.echo.domain.model.ColumnRole import com.github.jvsena42.echo.domain.model.Separator +import com.github.jvsena42.echo.domain.model.TriageDecision import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals @@ -294,4 +295,42 @@ class ImportRepositoryImplTest { r.clear() assertEquals(null, r.currentDraft()) } + + @Test + fun keptRowsDefaultsToAll() = runBlocking { + val r = repo() + r.parse("hola — hello\ngracias — thanks\nadios — bye") + assertEquals(3, r.keptRows().size) + } + + @Test + fun discardedRowsAreExcluded() = runBlocking { + val r = repo() + r.parse("hola — hello\ngracias — thanks\nadios — bye") + r.setDecision(1, TriageDecision.Discard) + val kept = r.keptRows() + assertEquals(2, kept.size) + assertEquals("hola", kept[0].fields[0]) + assertEquals("adios", kept[1].fields[0]) + } + + @Test + fun updateRowAppliesEditToKeptRows() = runBlocking { + val r = repo() + r.parse("hola — hello\ngracias — thanks") + r.updateRow(0, "buenos dias", "good morning") + val kept = r.keptRows() + assertEquals("buenos dias", kept[0].fields[0]) + assertEquals("good morning", kept[0].fields[1]) + } + + @Test + fun parseResetsTriageState() = runBlocking { + val r = repo() + r.parse("a — 1\nb — 2") + r.setDecision(0, TriageDecision.Discard) + r.parse("c — 3\nd — 4") + assertEquals(2, r.keptRows().size) + assertTrue(r.decisions().isEmpty()) + } } diff --git a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/FakeRepositories.kt b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/FakeRepositories.kt index 5f0fce4..60d35f0 100644 --- a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/FakeRepositories.kt +++ b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/FakeRepositories.kt @@ -12,6 +12,7 @@ import com.github.jvsena42.echo.domain.model.ColumnMapping import com.github.jvsena42.echo.domain.model.Deck import com.github.jvsena42.echo.domain.model.ImportDraft import com.github.jvsena42.echo.domain.model.ParsedRow +import com.github.jvsena42.echo.domain.model.TriageDecision import com.github.jvsena42.echo.domain.model.PubkyIdentity import com.github.jvsena42.echo.domain.model.PubkyUri import com.github.jvsena42.echo.domain.model.Separator @@ -160,15 +161,32 @@ class RecordingTagRepository(var trendingTags: List = emptyList()) : TagRep class FakeImportRepository(var draft: ImportDraft? = null) : ImportRepository { var clearCount = 0 + private val triageDecisions = mutableMapOf() + private val rowEdits = mutableMapOf>() override fun currentDraft(): ImportDraft? = draft override suspend fun parse(rawText: String): Result = draft?.let { Result.success(it) } ?: Result.failure(IllegalStateException("no draft")) + override fun decisions(): Map = triageDecisions.toMap() + + override fun setDecision(rowIndex: Int, decision: TriageDecision) { + triageDecisions[rowIndex] = decision + } + + override fun updateRow(rowIndex: Int, front: String, back: String) { + rowEdits[rowIndex] = front to back + } + + override fun keptRows(): List = + draft?.rows?.filter { triageDecisions[it.index] != TriageDecision.Discard } ?: emptyList() + override fun clear() { clearCount++ draft = null + triageDecisions.clear() + rowEdits.clear() } } From bfe325ed3c26a13bcc6d4f1a4542062400221a94 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 16 Jun 2026 20:53:29 -0300 Subject: [PATCH 03/20] feat(media): Unsplash web image search, gallery picker + compression, speech recognizer Add platform edges (MediaProcessor + SpeechRecognizer expect/actual on Android), an Unsplash client (HttpFetcher gains header support), a reusable ImagePickerSheet (web grid via Coil + system photo picker with JPEG compression), CardMediaImage, and ImageSheetViewModel. Unsplash key flows from BuildConfig (local.properties); blank key degrades to gallery-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- composeApp/build.gradle.kts | 14 + .../com/github/jvsena42/echo/EchoApp.kt | 2 +- .../echo/ui/components/CardMediaImage.kt | 57 ++++ .../echo/ui/components/ImagePickerSheet.kt | 246 ++++++++++++++++++ gradle/libs.versions.toml | 3 + .../echo/data/nexus/AndroidHttpFetcher.kt | 3 +- .../echo/di/PlatformModule.android.kt | 17 +- .../echo/platform/AndroidMediaProcessor.kt | 61 +++++ .../echo/platform/AndroidSpeechRecognizer.kt | 80 ++++++ .../jvsena42/echo/data/nexus/HttpFetcher.kt | 2 +- .../echo/data/unsplash/UnsplashClient.kt | 111 ++++++++ .../github/jvsena42/echo/di/SharedModule.kt | 2 + .../jvsena42/echo/platform/MediaProcessor.kt | 34 +++ .../echo/platform/SpeechRecognizer.kt | 27 ++ .../presentation/media/ImageSheetViewModel.kt | 81 ++++++ .../jvsena42/echo/testing/TestFixtures.kt | 5 +- .../echo/data/nexus/IosHttpFetcher.kt | 11 +- 17 files changed, 746 insertions(+), 10 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/CardMediaImage.kt create mode 100644 composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImagePickerSheet.kt create mode 100644 shared/src/androidMain/kotlin/com/github/jvsena42/echo/platform/AndroidMediaProcessor.kt create mode 100644 shared/src/androidMain/kotlin/com/github/jvsena42/echo/platform/AndroidSpeechRecognizer.kt create mode 100644 shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/unsplash/UnsplashClient.kt create mode 100644 shared/src/commonMain/kotlin/com/github/jvsena42/echo/platform/MediaProcessor.kt create mode 100644 shared/src/commonMain/kotlin/com/github/jvsena42/echo/platform/SpeechRecognizer.kt create mode 100644 shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/media/ImageSheetViewModel.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 68d5704..03f3d99 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -1,5 +1,6 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import java.util.Properties plugins { alias(libs.plugins.kotlinMultiplatform) @@ -21,6 +22,8 @@ kotlin { implementation(libs.androidx.activity.compose) implementation(libs.koin.android) implementation(libs.play.services.code.scanner) + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) } commonMain.dependencies { implementation(libs.compose.runtime) @@ -53,6 +56,17 @@ android { targetSdk = libs.versions.android.targetSdk.get().toInt() versionCode = 1 versionName = "1.0" + + // Unsplash key for the "from web" image search; blank → gallery-only fallback. + val localProps = Properties().apply { + val file = rootProject.file("local.properties") + if (file.exists()) file.inputStream().use { load(it) } + } + val unsplashKey = (localProps.getProperty("UNSPLASH_ACCESS_KEY") ?: "").trim() + buildConfigField("String", "UNSPLASH_ACCESS_KEY", "\"$unsplashKey\"") + } + buildFeatures { + buildConfig = true } packaging { resources { diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/EchoApp.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/EchoApp.kt index cdd73f1..40fa934 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/EchoApp.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/EchoApp.kt @@ -8,7 +8,7 @@ import org.koin.android.ext.koin.androidLogger class EchoApp : Application() { override fun onCreate() { super.onCreate() - initKoinAndroid { + initKoinAndroid(unsplashAccessKey = BuildConfig.UNSPLASH_ACCESS_KEY) { androidLogger() androidContext(this@EchoApp) } diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/CardMediaImage.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/CardMediaImage.kt new file mode 100644 index 0000000..0d28e53 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/CardMediaImage.kt @@ -0,0 +1,57 @@ +package com.github.jvsena42.echo.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.github.jvsena42.echo.data.repository.MediaRepository +import com.github.jvsena42.echo.domain.model.MediaRef +import org.koin.compose.koinInject + +/** + * Renders a card/cover [MediaRef.Image]. Remote (web) images load directly from their URL via + * Coil; blob images are fetched from the homeserver through [MediaRepository] and rendered from + * their decoded bytes (also via Coil, which decodes the ByteArray). + */ +@Composable +fun CardMediaImage( + image: MediaRef.Image, + deckId: String, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Crop, +) { + if (image.isRemote) { + AsyncImage( + model = image.url, + contentDescription = null, + modifier = modifier, + contentScale = contentScale, + ) + return + } + + val mediaRepository = koinInject() + val bytes by produceState(initialValue = null, image.sha256, deckId) { + value = mediaRepository.get(deckId, image).getOrNull() + } + val data = bytes + if (data == null) { + Box(modifier, contentAlignment = Alignment.Center) { + CircularProgressIndicator(strokeWidth = 2.dp) + } + } else { + AsyncImage( + model = data, + contentDescription = null, + modifier = modifier, + contentScale = contentScale, + ) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImagePickerSheet.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImagePickerSheet.kt new file mode 100644 index 0000000..156300a --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImagePickerSheet.kt @@ -0,0 +1,246 @@ +package com.github.jvsena42.echo.ui.components + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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.platform.MediaProcessor +import com.github.jvsena42.echo.presentation.media.ImageSheetViewModel +import com.github.jvsena42.echo.ui.theme.EchoTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.compose.koinInject + +/** The image a user chose: either a web URL (saved as-is) or compressed gallery bytes. */ +sealed interface ImageSelection { + data class Web(val url: String) : ImageSelection + data class Gallery(val bytes: ByteArray, val mime: String) : ImageSelection +} + +/** + * Reusable bottom sheet for choosing an image — web search (Unsplash) + a 3-column grid, plus a + * "From gallery" button using the system photo picker (no storage permission). Backs both the + * card-image sheet (cEXuT) and the cover sheet (OQ2QL). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ImagePickerSheet( + title: String, + subtitle: String?, + onDismiss: () -> Unit, + onSelected: (ImageSelection) -> Unit, +) { + val colors = EchoTheme.colors + val viewModel = koinInject() + val mediaProcessor = koinInject() + DisposableEffect(viewModel) { onDispose { viewModel.onDispose() } } + + val state by viewModel.state.collectAsStateWithLifecycle() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val context = LocalContext.current + val scope = rememberCoroutineScope() + var selectedUrl by remember { mutableStateOf(null) } + + val galleryLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia(), + ) { uri -> + if (uri != null) { + scope.launch { + val processed = withContext(Dispatchers.IO) { + val raw = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: return@withContext null + mediaProcessor.compressImage(raw) + } + if (processed != null) onSelected(ImageSelection.Gallery(processed.bytes, processed.mime)) + } + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = colors.surfacePrimary, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = title, fontSize = 20.sp, fontWeight = FontWeight.W800, color = colors.foregroundPrimary) + Text( + text = stringResource(R.string.image_sheet_done), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = if (selectedUrl != null) colors.accentPrimary else colors.foregroundMuted, + modifier = Modifier + .testTag("image_sheet_done") + .clip(RoundedCornerShape(50)) + .clickable(enabled = selectedUrl != null) { + selectedUrl?.let { onSelected(ImageSelection.Web(it)) } + } + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + + subtitle?.let { + Text(text = it, fontSize = 13.sp, color = colors.foregroundSecondary) + } + + // Search bar + OutlinedTextField( + value = state.query, + onValueChange = viewModel::onQueryChange, + modifier = Modifier + .fillMaxWidth() + .testTag("image_search_input"), + placeholder = { Text(stringResource(R.string.image_sheet_search_placeholder), color = colors.foregroundMuted) }, + leadingIcon = { Icon(Icons.Default.Search, null, tint = colors.foregroundMuted) }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = colors.accentPrimary, + unfocusedBorderColor = colors.borderSubtle, + cursorColor = colors.accentPrimary, + ), + ) + + // From gallery + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, colors.borderSubtle, RoundedCornerShape(12.dp)) + .background(colors.surfaceCard) + .clickable { + galleryLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + } + .testTag("image_pick_gallery") + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Default.Image, null, tint = colors.foregroundSecondary, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text( + stringResource(R.string.image_sheet_from_gallery), + fontSize = 12.sp, + fontWeight = FontWeight.W600, + color = colors.foregroundSecondary, + ) + } + + // Web image grid + Box(modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp, max = 360.dp)) { + when { + state.isLoading && state.photos.isEmpty() -> + CircularProgressIndicator( + color = colors.accentPrimary, + modifier = Modifier.align(Alignment.Center), + ) + + !state.isUnsplashConfigured -> + CenteredHint(stringResource(R.string.image_sheet_empty)) + + state.photos.isEmpty() -> + CenteredHint(stringResource(R.string.image_sheet_no_results)) + + else -> LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier + .fillMaxWidth() + .testTag("image_grid"), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + itemsIndexed(state.photos, key = { _, p -> p.id }) { index, photo -> + val selected = selectedUrl == photo.fullUrl + AsyncImage( + model = photo.thumbUrl, + contentDescription = photo.authorName, + modifier = Modifier + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)) + .border( + width = if (selected) 2.5.dp else 1.dp, + color = if (selected) colors.accentPrimary else colors.borderSubtle, + shape = RoundedCornerShape(12.dp), + ) + .clickable { selectedUrl = photo.fullUrl } + .testTag(if (index == 0) "image_grid_cell" else "image_grid_cell_$index"), + ) + } + } + } + } + + state.error?.let { Text(it, fontSize = 12.sp, color = colors.danger) } + } + } +} + +@Composable +private fun CenteredHint(text: String) { + val colors = EchoTheme.colors + Box(Modifier.fillMaxWidth().height(120.dp), contentAlignment = Alignment.Center) { + Text(text, fontSize = 13.sp, color = colors.foregroundMuted) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 02fa861..235ad5d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ koin = "4.0.2" navigationCompose = "2.9.0-beta03" kvault = "1.12.0" playServicesCodeScanner = "16.1.0" +coil = "3.0.4" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -55,6 +56,8 @@ koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", ver androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" } kvault = { module = "com.liftric:kvault", version.ref = "kvault" } play-services-code-scanner = { module = "com.google.android.gms:play-services-code-scanner", version.ref = "playServicesCodeScanner" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/shared/src/androidMain/kotlin/com/github/jvsena42/echo/data/nexus/AndroidHttpFetcher.kt b/shared/src/androidMain/kotlin/com/github/jvsena42/echo/data/nexus/AndroidHttpFetcher.kt index ab652d8..8cee945 100644 --- a/shared/src/androidMain/kotlin/com/github/jvsena42/echo/data/nexus/AndroidHttpFetcher.kt +++ b/shared/src/androidMain/kotlin/com/github/jvsena42/echo/data/nexus/AndroidHttpFetcher.kt @@ -11,13 +11,14 @@ import java.net.URL */ class AndroidHttpFetcher : HttpFetcher { - override suspend fun get(url: String): Result = withContext(Dispatchers.IO) { + override suspend fun get(url: String, headers: Map): Result = withContext(Dispatchers.IO) { runCatching { val connection = URL(url).openConnection() as HttpURLConnection try { connection.connectTimeout = TIMEOUT_MS connection.readTimeout = TIMEOUT_MS connection.setRequestProperty("Accept", "application/json") + headers.forEach { (key, value) -> connection.setRequestProperty(key, value) } val code = connection.responseCode if (code !in SUCCESS_RANGE) { throw HttpError(code, "GET $url failed with HTTP $code") diff --git a/shared/src/androidMain/kotlin/com/github/jvsena42/echo/di/PlatformModule.android.kt b/shared/src/androidMain/kotlin/com/github/jvsena42/echo/di/PlatformModule.android.kt index 534b243..b080867 100644 --- a/shared/src/androidMain/kotlin/com/github/jvsena42/echo/di/PlatformModule.android.kt +++ b/shared/src/androidMain/kotlin/com/github/jvsena42/echo/di/PlatformModule.android.kt @@ -6,8 +6,13 @@ import com.github.jvsena42.echo.data.pubky.AndroidPubkyClient import com.github.jvsena42.echo.data.pubky.PubkyClient import com.github.jvsena42.echo.data.storage.AndroidSecureSessionStore import com.github.jvsena42.echo.data.storage.SecureSessionStore +import com.github.jvsena42.echo.data.unsplash.UnsplashClient +import com.github.jvsena42.echo.platform.AndroidMediaProcessor import com.github.jvsena42.echo.platform.AndroidSpeaker +import com.github.jvsena42.echo.platform.AndroidSpeechRecognizer +import com.github.jvsena42.echo.platform.MediaProcessor import com.github.jvsena42.echo.platform.Speaker +import com.github.jvsena42.echo.platform.SpeechRecognizer import com.github.jvsena42.echo.presentation.onboarding.OnboardingViewModel import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin @@ -19,11 +24,14 @@ import org.koin.dsl.module private const val PUBKY_RING_PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=to.pubky.ring" -val androidPlatformModule: Module = module { +fun androidPlatformModule(unsplashAccessKey: String): Module = module { single { AndroidPubkyClient() } single { AndroidHttpFetcher() } single { AndroidSecureSessionStore(androidContext()) } single { AndroidSpeaker(androidContext()) } + single { AndroidMediaProcessor() } + single { AndroidSpeechRecognizer(androidContext()) } + single { UnsplashClient(http = get(), accessKey = unsplashAccessKey) } factory { OnboardingViewModel( identityRepository = get(), @@ -32,9 +40,12 @@ val androidPlatformModule: Module = module { } } -fun initKoinAndroid(appDeclaration: KoinAppDeclaration = {}) { +fun initKoinAndroid( + unsplashAccessKey: String = "", + appDeclaration: KoinAppDeclaration = {}, +) { startKoin { appDeclaration() - modules(sharedModule, androidPlatformModule) + modules(sharedModule, androidPlatformModule(unsplashAccessKey)) } } diff --git a/shared/src/androidMain/kotlin/com/github/jvsena42/echo/platform/AndroidMediaProcessor.kt b/shared/src/androidMain/kotlin/com/github/jvsena42/echo/platform/AndroidMediaProcessor.kt new file mode 100644 index 0000000..9ad9530 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/github/jvsena42/echo/platform/AndroidMediaProcessor.kt @@ -0,0 +1,61 @@ +package com.github.jvsena42.echo.platform + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import kotlin.math.roundToInt + +/** + * [MediaProcessor] backed by Android [Bitmap]. Decodes with subsampling to keep memory bounded, + * downscales so the longest edge fits [maxDimension], then re-encodes as JPEG. Runs off the main + * thread. + */ +class AndroidMediaProcessor : MediaProcessor { + + override suspend fun compressImage( + bytes: ByteArray, + maxDimension: Int, + quality: Int, + ): ProcessedImage = withContext(Dispatchers.Default) { + // First pass: read bounds only so we can subsample large images during decode. + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + + val decodeOpts = BitmapFactory.Options().apply { + inSampleSize = sampleSizeFor(bounds.outWidth, bounds.outHeight, maxDimension) + } + val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOpts) + ?: error("Could not decode image") + + val scaled = downscale(decoded, maxDimension) + val output = ByteArrayOutputStream() + scaled.compress(Bitmap.CompressFormat.JPEG, quality.coerceIn(1, 100), output) + val result = ProcessedImage( + bytes = output.toByteArray(), + mime = "image/jpeg", + width = scaled.width, + height = scaled.height, + ) + if (scaled !== decoded) decoded.recycle() + scaled.recycle() + result + } + + private fun sampleSizeFor(width: Int, height: Int, maxDimension: Int): Int { + var sample = 1 + var longest = maxOf(width, height) + while (longest / sample > maxDimension * 2) sample *= 2 + return sample + } + + private fun downscale(bitmap: Bitmap, maxDimension: Int): Bitmap { + val longest = maxOf(bitmap.width, bitmap.height) + if (longest <= maxDimension) return bitmap + val ratio = maxDimension.toFloat() / longest + val w = (bitmap.width * ratio).roundToInt().coerceAtLeast(1) + val h = (bitmap.height * ratio).roundToInt().coerceAtLeast(1) + return Bitmap.createScaledBitmap(bitmap, w, h, true) + } +} diff --git a/shared/src/androidMain/kotlin/com/github/jvsena42/echo/platform/AndroidSpeechRecognizer.kt b/shared/src/androidMain/kotlin/com/github/jvsena42/echo/platform/AndroidSpeechRecognizer.kt new file mode 100644 index 0000000..4f0d5e7 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/github/jvsena42/echo/platform/AndroidSpeechRecognizer.kt @@ -0,0 +1,80 @@ +package com.github.jvsena42.echo.platform + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer as AndroidSpeech +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * [SpeechRecognizer] backed by Android [android.speech.SpeechRecognizer]. The recognizer must be + * created and used on the main thread, so [listen] hops to the main looper via [callbackFlow]'s + * collector context expectations — callers collect from a main-dispatched coroutine. Microphone + * permission must already be granted. + */ +class AndroidSpeechRecognizer(private val context: Context) : SpeechRecognizer { + + override fun isAvailable(): Boolean = AndroidSpeech.isRecognitionAvailable(context) + + override fun listen(languageTag: String?): Flow = callbackFlow { + if (!AndroidSpeech.isRecognitionAvailable(context)) { + trySend(SpeechEvent.Error(SpeechError.UNAVAILABLE)) + close() + return@callbackFlow + } + + val recognizer = AndroidSpeech.createSpeechRecognizer(context) + val listener = object : RecognitionListener { + override fun onReadyForSpeech(params: Bundle?) { trySend(SpeechEvent.Ready) } + override fun onBeginningOfSpeech() { trySend(SpeechEvent.BeginningOfSpeech) } + override fun onRmsChanged(rmsdB: Float) {} + override fun onBufferReceived(buffer: ByteArray?) {} + override fun onEndOfSpeech() {} + + override fun onPartialResults(partialResults: Bundle?) { + firstTranscript(partialResults)?.let { trySend(SpeechEvent.Partial(it)) } + } + + override fun onResults(results: Bundle?) { + val text = firstTranscript(results) + if (text != null) trySend(SpeechEvent.Result(text)) else trySend(SpeechEvent.Error(SpeechError.NO_MATCH)) + close() + } + + override fun onError(error: Int) { + trySend(SpeechEvent.Error(mapError(error))) + close() + } + + override fun onEvent(eventType: Int, params: Bundle?) {} + } + recognizer.setRecognitionListener(listener) + + val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + languageTag?.let { putExtra(RecognizerIntent.EXTRA_LANGUAGE, it) } + } + recognizer.startListening(intent) + + awaitClose { + recognizer.stopListening() + recognizer.destroy() + } + } + + private fun firstTranscript(bundle: Bundle?): String? = + bundle?.getStringArrayList(AndroidSpeech.RESULTS_RECOGNITION)?.firstOrNull()?.takeIf { it.isNotBlank() } + + private fun mapError(code: Int): SpeechError = when (code) { + AndroidSpeech.ERROR_NO_MATCH, AndroidSpeech.ERROR_SPEECH_TIMEOUT -> SpeechError.NO_MATCH + AndroidSpeech.ERROR_INSUFFICIENT_PERMISSIONS -> SpeechError.PERMISSION + AndroidSpeech.ERROR_NETWORK, AndroidSpeech.ERROR_NETWORK_TIMEOUT -> SpeechError.NETWORK + AndroidSpeech.ERROR_RECOGNIZER_BUSY -> SpeechError.BUSY + else -> SpeechError.UNKNOWN + } +} diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/nexus/HttpFetcher.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/nexus/HttpFetcher.kt index 300ec27..c07760b 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/nexus/HttpFetcher.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/nexus/HttpFetcher.kt @@ -8,7 +8,7 @@ package com.github.jvsena42.echo.data.nexus * Implementations must treat non-2xx responses as failures carrying [HttpError]. */ interface HttpFetcher { - suspend fun get(url: String): Result + suspend fun get(url: String, headers: Map = emptyMap()): Result } class HttpError(val statusCode: Int, message: String) : RuntimeException(message) diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/unsplash/UnsplashClient.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/unsplash/UnsplashClient.kt new file mode 100644 index 0000000..b128bf7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/unsplash/UnsplashClient.kt @@ -0,0 +1,111 @@ +package com.github.jvsena42.echo.data.unsplash + +import com.github.jvsena42.echo.data.nexus.HttpFetcher +import com.github.jvsena42.echo.data.repository.impl.echoJson +import kotlinx.serialization.Serializable + +/** + * Read-only client for the Unsplash REST API. Powers the "from web" image search in the cover + * and card-image sheets. Selected photos are saved by URL (a [com.github.jvsena42.echo.domain.model.MediaRef.Image] + * with `url` set) — Echo never re-hosts the bytes. + * + * The access key comes from `BuildConfig.UNSPLASH_ACCESS_KEY` (see PlatformModule). When the + * key is blank the client returns empty results so the UI degrades to gallery-only. + */ +class UnsplashClient( + private val http: HttpFetcher, + private val accessKey: String, + private val baseUrl: String = DEFAULT_BASE_URL, +) { + + private val authHeaders: Map + get() = mapOf( + "Authorization" to "Client-ID $accessKey", + "Accept-Version" to "v1", + ) + + val isConfigured: Boolean get() = accessKey.isNotBlank() + + /** Search photos matching [query]. Empty/blank query falls back to a random selection. */ + suspend fun search(query: String, perPage: Int = DEFAULT_PER_PAGE): Result> { + if (!isConfigured) return Result.success(emptyList()) + if (query.isBlank()) return random(perPage) + return runCatching { + val url = "$baseUrl/search/photos?per_page=$perPage&query=${query.urlEncode()}" + val body = http.get(url, authHeaders).getOrThrow() + echoJson.decodeFromString(UnsplashSearchResponseDto.serializer(), body) + .results.map { it.toDomain() } + } + } + + /** A random set of photos for the initial (no-query) grid. */ + suspend fun random(count: Int = DEFAULT_PER_PAGE): Result> { + if (!isConfigured) return Result.success(emptyList()) + return runCatching { + val url = "$baseUrl/photos/random?count=$count" + val body = http.get(url, authHeaders).getOrThrow() + echoJson.decodeFromString( + kotlinx.serialization.builtins.ListSerializer(UnsplashPhotoDto.serializer()), + body, + ).map { it.toDomain() } + } + } + + companion object { + const val DEFAULT_BASE_URL = "https://api.unsplash.com" + private const val DEFAULT_PER_PAGE = 30 + } +} + +/** Minimal domain model for a web image — only what the grid + save flow need. */ +data class UnsplashPhoto( + val id: String, + val thumbUrl: String, + val fullUrl: String, + val authorName: String, +) + +@Serializable +internal data class UnsplashSearchResponseDto( + val results: List = emptyList(), +) + +@Serializable +internal data class UnsplashPhotoDto( + val id: String, + val urls: UnsplashUrlsDto, + val user: UnsplashUserDto? = null, +) + +@Serializable +internal data class UnsplashUrlsDto( + val thumb: String = "", + val small: String = "", + val regular: String = "", +) + +@Serializable +internal data class UnsplashUserDto( + val name: String = "", +) + +internal fun UnsplashPhotoDto.toDomain() = UnsplashPhoto( + id = id, + thumbUrl = urls.small.ifBlank { urls.thumb }, + fullUrl = urls.regular.ifBlank { urls.small }, + authorName = user?.name.orEmpty(), +) + +/** Percent-encode a query string (UTF-8) for use in a URL — commonMain has no URLEncoder. */ +private fun String.urlEncode(): String = buildString { + for (byte in this@urlEncode.encodeToByteArray()) { + val code = byte.toInt() and 0xFF + val ch = code.toChar() + if (code < 0x80 && (ch.isLetterOrDigit() || ch in "-_.~")) { + append(ch) + } else { + append('%') + append(code.toString(16).uppercase().padStart(2, '0')) + } + } +} 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 d827334..e511093 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 @@ -30,6 +30,7 @@ import com.github.jvsena42.echo.presentation.home.HomeViewModel import com.github.jvsena42.echo.presentation.importflow.PasteImportViewModel import com.github.jvsena42.echo.presentation.importflow.PublishDeckViewModel import com.github.jvsena42.echo.presentation.importflow.TriageViewModel +import com.github.jvsena42.echo.presentation.media.ImageSheetViewModel import com.github.jvsena42.echo.presentation.onboarding.OnboardingViewModel import com.github.jvsena42.echo.presentation.profile.FriendProfileViewModel import com.github.jvsena42.echo.presentation.profile.ProfileViewModel @@ -100,6 +101,7 @@ val sharedModule = module { } factory { PasteImportViewModel(importRepository = get()) } factory { TriageViewModel(importRepository = get()) } + factory { ImageSheetViewModel(unsplashClient = get()) } factory { PublishDeckViewModel(importRepository = get(), deckRepository = get(), identityRepository = get()) } factory { ProfileViewModel(identityRepository = get(), deckRepository = get()) } factory { params -> SettingsViewModel(identityRepository = get(), appVersion = params.getOrNull() ?: "") } diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/platform/MediaProcessor.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/platform/MediaProcessor.kt new file mode 100644 index 0000000..fdfffd7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/platform/MediaProcessor.kt @@ -0,0 +1,34 @@ +package com.github.jvsena42.echo.platform + +/** Raw bytes + mime of an image picked from the device gallery. */ +data class PickedImage( + val bytes: ByteArray, + val mime: String, +) + +/** A compressed, downscaled image ready to upload to the homeserver. */ +data class ProcessedImage( + val bytes: ByteArray, + val mime: String, + val width: Int, + val height: Int, +) + +/** + * Downscales and re-encodes picked images before they are uploaded as media blobs. Pure (no + * Activity/lifecycle), so it can be a Koin singleton — the actual pick happens in the Compose + * layer via the system photo picker. Implemented per platform (Android Bitmap / iOS UIImage). + */ +interface MediaProcessor { + /** Decode [bytes], downscale to fit [maxDimension] px, re-encode as JPEG at [quality]. */ + suspend fun compressImage( + bytes: ByteArray, + maxDimension: Int = DEFAULT_MAX_DIMENSION, + quality: Int = DEFAULT_QUALITY, + ): ProcessedImage + + companion object { + const val DEFAULT_MAX_DIMENSION = 1024 + const val DEFAULT_QUALITY = 80 + } +} diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/platform/SpeechRecognizer.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/platform/SpeechRecognizer.kt new file mode 100644 index 0000000..2bdfdf9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/platform/SpeechRecognizer.kt @@ -0,0 +1,27 @@ +package com.github.jvsena42.echo.platform + +import kotlinx.coroutines.flow.Flow + +/** Events emitted while listening for speech. */ +sealed interface SpeechEvent { + data object Ready : SpeechEvent + data object BeginningOfSpeech : SpeechEvent + data class Partial(val text: String) : SpeechEvent + data class Result(val text: String) : SpeechEvent + data class Error(val reason: SpeechError) : SpeechEvent +} + +enum class SpeechError { NO_MATCH, PERMISSION, NETWORK, BUSY, UNAVAILABLE, UNKNOWN } + +/** + * On-device speech recognition for the Speak pronunciation practice (study). Implemented per + * platform (Android [android.speech.SpeechRecognizer] / iOS Speech framework). The caller must + * hold microphone permission before collecting [listen]; cancelling the collector stops it. + */ +interface SpeechRecognizer { + /** Whether recognition is available on this device. */ + fun isAvailable(): Boolean + + /** Start listening, emitting [SpeechEvent]s until the flow is cancelled or completes. */ + fun listen(languageTag: String? = null): Flow +} diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/media/ImageSheetViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/media/ImageSheetViewModel.kt new file mode 100644 index 0000000..1727c74 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/media/ImageSheetViewModel.kt @@ -0,0 +1,81 @@ +package com.github.jvsena42.echo.presentation.media + +import com.github.jvsena42.echo.data.unsplash.UnsplashClient +import com.github.jvsena42.echo.data.unsplash.UnsplashPhoto +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Backs the "from web" image grid in the cover and card-image sheets. Debounces the search query + * and queries [UnsplashClient]. When Unsplash is unconfigured (no access key) the grid stays empty + * and the sheet falls back to gallery-only. + */ +class ImageSheetViewModel( + private val unsplashClient: UnsplashClient, + mainScope: CoroutineScope? = null, +) { + private val scope: CoroutineScope = + mainScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main) + + private val _state = MutableStateFlow( + ImageSheetUiState(isUnsplashConfigured = unsplashClient.isConfigured), + ) + val state: StateFlow = _state.asStateFlow() + + private var searchJob: Job? = null + + init { + if (unsplashClient.isConfigured) loadInitial() + } + + private fun loadInitial() { + searchJob?.cancel() + searchJob = scope.launch { + _state.update { it.copy(isLoading = true, error = null) } + unsplashClient.random() + .onSuccess { photos -> _state.update { it.copy(photos = photos, isLoading = false) } } + .onFailure { err -> _state.update { it.copy(isLoading = false, error = err.error()) } } + } + } + + fun onQueryChange(query: String) { + _state.update { it.copy(query = query) } + if (!unsplashClient.isConfigured) return + searchJob?.cancel() + searchJob = scope.launch { + delay(DEBOUNCE_MS) + _state.update { it.copy(isLoading = true, error = null) } + unsplashClient.search(query) + .onSuccess { photos -> _state.update { it.copy(photos = photos, isLoading = false) } } + .onFailure { err -> _state.update { it.copy(isLoading = false, error = err.error()) } } + } + } + + fun onDispose() { + searchJob?.cancel() + scope.cancel() + } + + private fun Throwable.error(): String = message ?: "Could not load images." + + companion object { + private const val DEBOUNCE_MS = 350L + } +} + +data class ImageSheetUiState( + val query: String = "", + val photos: List = emptyList(), + val isLoading: Boolean = false, + val isUnsplashConfigured: Boolean = false, + val error: String? = null, +) diff --git a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/TestFixtures.kt b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/TestFixtures.kt index 06360d0..6cdac2a 100644 --- a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/TestFixtures.kt +++ b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/TestFixtures.kt @@ -55,8 +55,11 @@ class FakeHttpFetcher( responses[url] = Result.failure(error) } - override suspend fun get(url: String): Result { + val requestedHeaders = mutableListOf>() + + override suspend fun get(url: String, headers: Map): Result { requestedUrls.add(url) + requestedHeaders.add(headers) return responses[url] ?: Result.failure(IllegalStateException("No canned response for $url")) } } diff --git a/shared/src/iosMain/kotlin/com/github/jvsena42/echo/data/nexus/IosHttpFetcher.kt b/shared/src/iosMain/kotlin/com/github/jvsena42/echo/data/nexus/IosHttpFetcher.kt index 54189b3..2179b5d 100644 --- a/shared/src/iosMain/kotlin/com/github/jvsena42/echo/data/nexus/IosHttpFetcher.kt +++ b/shared/src/iosMain/kotlin/com/github/jvsena42/echo/data/nexus/IosHttpFetcher.kt @@ -3,12 +3,14 @@ package com.github.jvsena42.echo.data.nexus import kotlinx.coroutines.suspendCancellableCoroutine import platform.Foundation.NSData import platform.Foundation.NSHTTPURLResponse +import platform.Foundation.NSMutableURLRequest import platform.Foundation.NSString import platform.Foundation.NSURL import platform.Foundation.NSURLSession import platform.Foundation.NSUTF8StringEncoding import platform.Foundation.create -import platform.Foundation.dataTaskWithURL +import platform.Foundation.dataTaskWithRequest +import platform.Foundation.setValue import kotlin.coroutines.resume /** @@ -17,12 +19,15 @@ import kotlin.coroutines.resume */ class IosHttpFetcher : HttpFetcher { - override suspend fun get(url: String): Result { + override suspend fun get(url: String, headers: Map): Result { val nsUrl = NSURL.URLWithString(url) ?: return Result.failure(IllegalArgumentException("Invalid URL: $url")) + val request = NSMutableURLRequest.requestWithURL(nsUrl) + headers.forEach { (key, value) -> request.setValue(value, forHTTPHeaderField = key) } + return suspendCancellableCoroutine { continuation -> - val task = NSURLSession.sharedSession.dataTaskWithURL(nsUrl) { data, response, error -> + val task = NSURLSession.sharedSession.dataTaskWithRequest(request) { data, response, error -> val result: Result = when { error != null -> Result.failure(RuntimeException(error.localizedDescription)) From c01844c6796542909c2d59a3d2298ba6b5d0b232 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 16 Jun 2026 21:03:21 -0300 Subject: [PATCH 04/20] feat(deck): wire cover image, Listen/Speak toggles, and card front image PublishDeckViewModel uploads a gallery cover (compressed) or saves a web URL, and persists listen/speak opt-ins; the Publish screen gains a Card Options section and an image-picker cover sheet. EditCardViewModel/Screen let a card front image be picked from gallery or web, with a removable preview chip. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jvsena42/echo/ui/decks/EditCardScreen.kt | 67 ++++++++++- .../echo/ui/importflow/PublishDeckScreen.kt | 112 +++++++++++++++++- .../github/jvsena42/echo/di/SharedModule.kt | 10 +- .../presentation/decks/EditCardViewModel.kt | 37 +++++- .../importflow/PublishDeckViewModel.kt | 45 ++++++- .../importflow/PublishDeckViewModelTest.kt | 3 + .../jvsena42/echo/testing/FakeRepositories.kt | 20 ++++ 7 files changed, 285 insertions(+), 9 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt index 9f29ee0..efaff6e 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt @@ -43,11 +43,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue 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.layout.ContentScale +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -60,6 +65,9 @@ import com.github.jvsena42.echo.platform.Speaker import com.github.jvsena42.echo.presentation.decks.EditCardEffect import com.github.jvsena42.echo.presentation.decks.EditCardUiState import com.github.jvsena42.echo.presentation.decks.EditCardViewModel +import coil3.compose.AsyncImage +import com.github.jvsena42.echo.ui.components.ImagePickerSheet +import com.github.jvsena42.echo.ui.components.ImageSelection import com.github.jvsena42.echo.ui.components.TagChip import com.github.jvsena42.echo.ui.theme.EchoTheme import kotlinx.coroutines.flow.collectLatest @@ -102,6 +110,9 @@ fun EditCardRoute( onSpeakBack = viewModel::onSpeakBack, onRemoveTag = viewModel::onRemoveTag, onAddTag = viewModel::onAddTag, + onFrontImageWebSelected = viewModel::onFrontImageWebSelected, + onFrontImageGallerySelected = viewModel::onFrontImageGallerySelected, + onRemoveFrontImage = viewModel::onRemoveFrontImage, onDeleteCard = viewModel::onDeleteCard, ) } @@ -118,9 +129,13 @@ fun EditCardScreen( onSpeakBack: () -> Unit, onRemoveTag: (String) -> Unit, onAddTag: (String) -> Unit, + onFrontImageWebSelected: (String) -> Unit = {}, + onFrontImageGallerySelected: (ByteArray, String) -> Unit = { _, _ -> }, + onRemoveFrontImage: () -> Unit = {}, onDeleteCard: () -> Unit, ) { val colors = EchoTheme.colors + var showImageSheet by remember { mutableStateOf(false) } Scaffold( containerColor = colors.surfacePrimary, @@ -231,14 +246,47 @@ fun EditCardScreen( focusedBorderColor = colors.accentPrimary, ) + // 4a. Front image preview (when set) + state.frontImageRef?.let { imageRef -> + Row( + modifier = Modifier + .testTag("editcard_front_image_chip") + .clip(RoundedCornerShape(10.dp)) + .background(colors.surfaceSecondary) + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AsyncImage( + model = imageRef.url ?: state.frontPendingBytes, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(8.dp)), + ) + Text(stringResource(R.string.image_sheet_front_title), fontSize = 13.sp, color = colors.foregroundSecondary) + Spacer(Modifier.weight(1f)) + TextButton( + onClick = onRemoveFrontImage, + modifier = Modifier.testTag("editcard_front_image_remove"), + colors = ButtonDefaults.textButtonColors(contentColor = colors.srsAgain), + ) { + Text(stringResource(R.string.image_sheet_remove), fontSize = 12.sp) + } + } + } + // 4. Media buttons Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { OutlinedButton( - onClick = { /* TODO: image picker */ }, - modifier = Modifier.weight(1f), + onClick = { showImageSheet = true }, + modifier = Modifier + .weight(1f) + .testTag("editcard_image"), shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.foregroundMuted), border = BorderStroke(1.dp, colors.borderSubtle), @@ -339,6 +387,21 @@ fun EditCardScreen( } } } + + if (showImageSheet) { + ImagePickerSheet( + title = stringResource(R.string.image_sheet_front_title), + subtitle = stringResource(R.string.image_sheet_front_subtitle), + onDismiss = { showImageSheet = false }, + onSelected = { selection -> + when (selection) { + is ImageSelection.Web -> onFrontImageWebSelected(selection.url) + is ImageSelection.Gallery -> onFrontImageGallerySelected(selection.bytes, selection.mime) + } + showImageSheet = false + }, + ) + } } @OptIn(ExperimentalMaterial3Api::class) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt index a97e3ad..ca36d76 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt @@ -31,6 +31,8 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -45,6 +47,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -57,7 +60,10 @@ import com.github.jvsena42.echo.R import com.github.jvsena42.echo.presentation.importflow.PublishDeckEffect import com.github.jvsena42.echo.presentation.importflow.PublishDeckUiState import com.github.jvsena42.echo.presentation.importflow.PublishDeckViewModel +import coil3.compose.AsyncImage import com.github.jvsena42.echo.ui.components.EchoPrimaryButton +import com.github.jvsena42.echo.ui.components.ImagePickerSheet +import com.github.jvsena42.echo.ui.components.ImageSelection import com.github.jvsena42.echo.ui.components.TagChip import com.github.jvsena42.echo.ui.theme.EchoTheme import kotlinx.coroutines.flow.collectLatest @@ -90,6 +96,10 @@ fun PublishDeckRoute( onDescriptionChanged = viewModel::onDescriptionChanged, onAddTag = viewModel::onAddTag, onRemoveTag = viewModel::onRemoveTag, + onToggleListen = viewModel::onToggleListen, + onToggleSpeak = viewModel::onToggleSpeak, + onCoverWebSelected = viewModel::onCoverWebSelected, + onCoverGallerySelected = viewModel::onCoverGallerySelected, onPublishClick = viewModel::onPublishClick, onUndoPublish = viewModel::onUndoPublish, onDonePublish = viewModel::onDonePublish, @@ -105,6 +115,10 @@ private fun PublishDeckScreen( onDescriptionChanged: (String) -> Unit, onAddTag: (String) -> Unit, onRemoveTag: (String) -> Unit, + onToggleListen: () -> Unit, + onToggleSpeak: () -> Unit, + onCoverWebSelected: (String) -> Unit, + onCoverGallerySelected: (ByteArray, String) -> Unit, onPublishClick: () -> Unit, onUndoPublish: () -> Unit, onDonePublish: () -> Unit, @@ -112,6 +126,7 @@ private fun PublishDeckScreen( ) { val colors = EchoTheme.colors var showTagSheet by remember { mutableStateOf(false) } + var showCoverSheet by remember { mutableStateOf(false) } var tagInput by remember { mutableStateOf("") } if (state.publishedDeckId != null) { @@ -197,10 +212,17 @@ private fun PublishDeckScreen( .background(colors.accentPrimarySoft), contentAlignment = Alignment.Center, ) { - Text( - text = state.coverEmoji.ifBlank { "📚" }, - fontSize = 32.sp, - ) + val coverModel: Any? = state.coverImageUrl ?: state.coverPendingBytes + if (coverModel != null) { + AsyncImage( + model = coverModel, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } else { + Text(text = state.coverEmoji.ifBlank { "📚" }, fontSize = 32.sp) + } } Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( @@ -212,8 +234,10 @@ private fun PublishDeckScreen( ) Row( modifier = Modifier + .testTag("publish_cover_change") .clip(RoundedCornerShape(8.dp)) .border(1.dp, colors.borderSubtle, RoundedCornerShape(8.dp)) + .clickable { showCoverSheet = true } .padding(horizontal = 10.dp, vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, @@ -340,6 +364,31 @@ private fun PublishDeckScreen( } } + // Card options (Listen / Speak) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + stringResource(R.string.publish_card_options_label), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + color = colors.foregroundMuted, + ) + OptionToggleRow( + title = stringResource(R.string.publish_listen_title), + subtitle = stringResource(R.string.publish_listen_subtitle), + checked = state.listenEnabled, + onToggle = onToggleListen, + testTag = "publish_listen_toggle", + ) + OptionToggleRow( + title = stringResource(R.string.publish_speak_title), + subtitle = stringResource(R.string.publish_speak_subtitle), + checked = state.speakEnabled, + onToggle = onToggleSpeak, + testTag = "publish_speak_toggle", + ) + } + // Public on Pubky notice Row( modifier = Modifier @@ -518,6 +567,57 @@ private fun PublishDeckScreen( } } } + + if (showCoverSheet) { + ImagePickerSheet( + title = stringResource(R.string.publish_cover_sheet_title), + subtitle = null, + onDismiss = { showCoverSheet = false }, + onSelected = { selection -> + when (selection) { + is ImageSelection.Web -> onCoverWebSelected(selection.url) + is ImageSelection.Gallery -> onCoverGallerySelected(selection.bytes, selection.mime) + } + showCoverSheet = false + }, + ) + } +} + +@Composable +private fun OptionToggleRow( + title: String, + subtitle: String, + checked: Boolean, + onToggle: () -> Unit, + testTag: String, +) { + val colors = EchoTheme.colors + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .border(1.dp, colors.borderSubtle, RoundedCornerShape(14.dp)) + .background(colors.surfaceCard) + .padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = colors.foregroundPrimary) + Text(subtitle, fontSize = 12.sp, color = colors.foregroundSecondary) + } + Switch( + checked = checked, + onCheckedChange = { onToggle() }, + modifier = Modifier.testTag(testTag), + colors = SwitchDefaults.colors( + checkedThumbColor = colors.foregroundOnAccent, + checkedTrackColor = colors.accentPrimary, + uncheckedTrackColor = colors.borderSubtle, + ), + ) + } } @Composable @@ -619,6 +719,10 @@ private fun PublishDeckScreenPreview() { onDescriptionChanged = {}, onAddTag = {}, onRemoveTag = {}, + onToggleListen = {}, + onToggleSpeak = {}, + onCoverWebSelected = {}, + onCoverGallerySelected = { _, _ -> }, onPublishClick = {}, onUndoPublish = {}, onDonePublish = {}, 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 e511093..9c06c3c 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 @@ -97,12 +97,20 @@ val sharedModule = module { cardId = params.get(1), cardRepository = get(), deckRepository = get(), + mediaRepository = get(), ) } factory { PasteImportViewModel(importRepository = get()) } factory { TriageViewModel(importRepository = get()) } factory { ImageSheetViewModel(unsplashClient = get()) } - factory { PublishDeckViewModel(importRepository = get(), deckRepository = get(), identityRepository = get()) } + factory { + PublishDeckViewModel( + importRepository = get(), + deckRepository = get(), + identityRepository = get(), + mediaRepository = get(), + ) + } factory { ProfileViewModel(identityRepository = get(), deckRepository = get()) } factory { params -> SettingsViewModel(identityRepository = get(), appVersion = params.getOrNull() ?: "") } factory { DiscoverViewModel(discoveryRepository = get(), tagRepository = get()) } diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/EditCardViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/EditCardViewModel.kt index e99d83c..fb219c2 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/EditCardViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/EditCardViewModel.kt @@ -2,8 +2,10 @@ package com.github.jvsena42.echo.presentation.decks import com.github.jvsena42.echo.data.repository.CardRepository import com.github.jvsena42.echo.data.repository.DeckRepository +import com.github.jvsena42.echo.data.repository.MediaRepository import com.github.jvsena42.echo.domain.model.Card import com.github.jvsena42.echo.domain.model.CardSide +import com.github.jvsena42.echo.domain.model.MediaRef import com.github.jvsena42.echo.util.Log import com.github.jvsena42.echo.util.epochMillis import kotlinx.coroutines.CoroutineScope @@ -25,6 +27,7 @@ class EditCardViewModel( private val cardId: String, private val cardRepository: CardRepository, private val deckRepository: DeckRepository, + private val mediaRepository: MediaRepository, mainScope: CoroutineScope? = null, ) { private val scope: CoroutineScope = @@ -60,12 +63,30 @@ class EditCardViewModel( totalCards = totalCards, frontText = card.front.text ?: "", backText = card.back.text ?: "", + frontImageRef = card.front.imageRef, hasImage = card.front.imageRef != null || card.back.imageRef != null, hasAudio = card.front.audioRef != null || card.back.audioRef != null, ) } } + /** A web (Unsplash) image was chosen for the card front — saved by URL. */ + fun onFrontImageWebSelected(url: String) { + val ref = MediaRef.Image(path = "", mime = "image/jpeg", sha256 = "", width = null, height = null, url = url) + _state.update { it.copy(frontImageRef = ref, frontPendingBytes = null, frontPendingMime = null, hasImage = true) } + } + + /** A gallery image was chosen for the card front — already compressed; uploaded on save. */ + fun onFrontImageGallerySelected(bytes: ByteArray, mime: String) { + _state.update { + it.copy(frontImageRef = null, frontPendingBytes = bytes, frontPendingMime = mime, hasImage = true) + } + } + + fun onRemoveFrontImage() { + _state.update { it.copy(frontImageRef = null, frontPendingBytes = null, frontPendingMime = null, hasImage = false) } + } + fun onFrontTextChanged(text: String) { _state.update { it.copy(frontText = text, frontError = cardTextErrorFor(text)) } } @@ -117,13 +138,14 @@ class EditCardViewModel( val existingCard = cardRepository.get(deckId, cardId) val now = epochMillis() + val frontImage = resolveFrontImage(s) val card = Card( id = cardId, deckId = deckId, updatedAt = now, front = CardSide( text = s.frontText.ifBlank { null }, - imageRef = existingCard?.front?.imageRef, + imageRef = frontImage, audioRef = existingCard?.front?.audioRef, ), back = CardSide( @@ -161,6 +183,16 @@ class EditCardViewModel( scope.launch { _effects.emit(EditCardEffect.NavigateBack) } } + /** Upload a pending gallery image, or keep the chosen web/existing ref. */ + private suspend fun resolveFrontImage(s: EditCardUiState): MediaRef.Image? = when { + s.frontPendingBytes != null -> + mediaRepository.putImage(deckId, s.frontPendingBytes, s.frontPendingMime ?: "image/jpeg") + .onFailure { Log.e(TAG, "front image upload failed — ${it.message}", it) } + .getOrNull() + + else -> s.frontImageRef + } + fun onDispose() { loadJob?.cancel() saveJob?.cancel() @@ -183,6 +215,9 @@ data class EditCardUiState( val frontText: String = "", val backText: String = "", val tags: List = emptyList(), + val frontImageRef: MediaRef.Image? = null, + val frontPendingBytes: ByteArray? = null, + val frontPendingMime: String? = null, val hasImage: Boolean = false, val hasAudio: Boolean = false, val isSaving: Boolean = false, diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt index a299061..1ad4084 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt @@ -3,11 +3,13 @@ package com.github.jvsena42.echo.presentation.importflow import com.github.jvsena42.echo.data.repository.DeckRepository import com.github.jvsena42.echo.data.repository.IdentityRepository import com.github.jvsena42.echo.data.repository.ImportRepository +import com.github.jvsena42.echo.data.repository.MediaRepository import com.github.jvsena42.echo.domain.model.Card import com.github.jvsena42.echo.domain.model.CardIndexEntry import com.github.jvsena42.echo.domain.model.CardSide import com.github.jvsena42.echo.domain.model.ColumnRole import com.github.jvsena42.echo.domain.model.Deck +import com.github.jvsena42.echo.domain.model.MediaRef import com.github.jvsena42.echo.domain.model.Tag import com.github.jvsena42.echo.util.Log import com.github.jvsena42.echo.util.epochMillis @@ -30,6 +32,7 @@ class PublishDeckViewModel( private val importRepository: ImportRepository, private val deckRepository: DeckRepository, private val identityRepository: IdentityRepository, + private val mediaRepository: MediaRepository, mainScope: CoroutineScope? = null, ) { private val scope: CoroutineScope = @@ -62,6 +65,24 @@ class PublishDeckViewModel( _state.update { it.copy(coverEmoji = emoji) } } + fun onToggleListen() { + _state.update { it.copy(listenEnabled = !it.listenEnabled) } + } + + fun onToggleSpeak() { + _state.update { it.copy(speakEnabled = !it.speakEnabled) } + } + + /** A web (Unsplash) cover image was chosen — saved by URL, no upload. */ + fun onCoverWebSelected(url: String) { + _state.update { it.copy(coverImageUrl = url, coverPendingBytes = null, coverPendingMime = null) } + } + + /** A gallery cover image was chosen — already compressed; uploaded on publish. */ + fun onCoverGallerySelected(bytes: ByteArray, mime: String) { + _state.update { it.copy(coverImageUrl = null, coverPendingBytes = bytes, coverPendingMime = mime) } + } + fun onAddTag(tag: String) { val trimmed = tag.trim().lowercase() if (trimmed.isBlank()) return @@ -115,17 +136,21 @@ class PublishDeckViewModel( ) } + val coverImageRef = resolveCoverImage(s, deckId) + val deck = Deck( id = deckId, authorPubky = authorPubky, title = s.title, description = s.description.ifBlank { null }, coverEmoji = s.coverEmoji.ifBlank { null }, - coverImageRef = null, + coverImageRef = coverImageRef, tags = s.tags.map { Tag(it) }, createdAt = now, updatedAt = now, cardIndex = cards.map { CardIndexEntry(it.id, it.updatedAt) }, + listenEnabled = s.listenEnabled, + speakEnabled = s.speakEnabled, ) deckRepository.publish(deck, cards) @@ -181,6 +206,19 @@ class PublishDeckViewModel( } } + /** Builds the cover [MediaRef.Image]: upload gallery bytes, or wrap a web URL, else none. */ + private suspend fun resolveCoverImage(s: PublishDeckUiState, deckId: String): MediaRef.Image? = when { + s.coverPendingBytes != null -> + mediaRepository.putImage(deckId, s.coverPendingBytes, s.coverPendingMime ?: "image/jpeg") + .onFailure { Log.e(TAG, "cover upload failed — ${it.message}", it) } + .getOrNull() + + s.coverImageUrl != null -> + MediaRef.Image(path = "", mime = "image/jpeg", sha256 = "", width = null, height = null, url = s.coverImageUrl) + + else -> null + } + private fun validateForPublish(s: PublishDeckUiState): Boolean { if (s.title.isBlank()) { _state.update { it.copy(error = "Title is required.") } @@ -230,6 +268,11 @@ data class PublishDeckUiState( val isPublishing: Boolean = false, val publishedDeckId: String? = null, val undoSecondsRemaining: Int = 0, + val listenEnabled: Boolean = true, + val speakEnabled: Boolean = true, + val coverImageUrl: String? = null, + val coverPendingBytes: ByteArray? = null, + val coverPendingMime: String? = null, val titleError: String? = null, val descriptionError: String? = null, val error: String? = null, diff --git a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModelTest.kt b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModelTest.kt index 4af023f..5e6c49a 100644 --- a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModelTest.kt @@ -3,6 +3,7 @@ package com.github.jvsena42.echo.presentation.importflow import com.github.jvsena42.echo.testing.FakeDeckRepository import com.github.jvsena42.echo.testing.FakeIdentityRepository import com.github.jvsena42.echo.testing.FakeImportRepository +import com.github.jvsena42.echo.testing.FakeMediaRepository import com.github.jvsena42.echo.testing.TEST_PUBKY import com.github.jvsena42.echo.testing.testDraft import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -26,11 +27,13 @@ class PublishDeckViewModelTest { ) private val deckRepo = FakeDeckRepository() private val identityRepo = FakeIdentityRepository() + private val mediaRepo = FakeMediaRepository() private fun TestScope.viewModel() = PublishDeckViewModel( importRepository = importRepo, deckRepository = deckRepo, identityRepository = identityRepo, + mediaRepository = mediaRepo, mainScope = this, ) diff --git a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/FakeRepositories.kt b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/FakeRepositories.kt index 60d35f0..1f19458 100644 --- a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/FakeRepositories.kt +++ b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/FakeRepositories.kt @@ -5,9 +5,11 @@ import com.github.jvsena42.echo.data.repository.DeckRepository import com.github.jvsena42.echo.data.repository.DiscoveryRepository import com.github.jvsena42.echo.data.repository.IdentityRepository import com.github.jvsena42.echo.data.repository.ImportRepository +import com.github.jvsena42.echo.data.repository.MediaRepository import com.github.jvsena42.echo.data.repository.SrsRepository import com.github.jvsena42.echo.data.repository.TagRepository import com.github.jvsena42.echo.domain.model.Card +import com.github.jvsena42.echo.domain.model.MediaRef import com.github.jvsena42.echo.domain.model.ColumnMapping import com.github.jvsena42.echo.domain.model.Deck import com.github.jvsena42.echo.domain.model.ImportDraft @@ -190,6 +192,24 @@ class FakeImportRepository(var draft: ImportDraft? = null) : ImportRepository { } } +class FakeMediaRepository : MediaRepository { + val putImages = mutableListOf>() + + override suspend fun putImage(deckId: String, bytes: ByteArray, mime: String): Result { + putImages.add(Triple(deckId, bytes, mime)) + return Result.success( + MediaRef.Image(path = "media/fake.jpg", mime = mime, sha256 = "fake", width = null, height = null), + ) + } + + override suspend fun putAudio(deckId: String, bytes: ByteArray, mime: String): Result = + Result.success(MediaRef.Audio(path = "media/fake.m4a", mime = mime, sha256 = "fake", durationMs = null)) + + override suspend fun get(deckId: String, ref: MediaRef): Result = Result.success(ByteArray(0)) + + override suspend fun delete(deckId: String, ref: MediaRef): Result = Result.success(Unit) +} + fun testDraft(vararg pairs: Pair): ImportDraft = ImportDraft( rawText = pairs.joinToString("\n") { "${it.first} — ${it.second}" }, separator = Separator.EmDash, From b59b69915ea310702c5972baa2232ac25038acf8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 16 Jun 2026 21:11:50 -0300 Subject: [PATCH 05/20] feat(study): Speak pronunciation practice with on-device speech recognition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Listen/Speak gating to the study session from the deck's opt-ins. The back card gains a Speak (mic) action that requests RECORD_AUDIO, records via the native SpeechRecognizer, and compares the transcript to the answer via SpeakMatcher — surfacing Listening/Correct/Wrong sheets (sIqOr/n3bMb7/BlcXn). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/androidMain/AndroidManifest.xml | 8 + .../jvsena42/echo/ui/study/SpeakSheets.kt | 213 ++++++++++++++++++ .../echo/ui/study/StudySessionScreen.kt | 135 +++++++++-- .../src/androidMain/res/values/strings.xml | 1 + .../study/StudySessionViewModel.kt | 63 ++++++ 5 files changed, 401 insertions(+), 19 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/SpeakSheets.kt diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 0f0da70..af3c318 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -5,6 +5,10 @@ + + + @@ -16,6 +20,10 @@ + + + + Unit, + onContinue: () -> Unit, + onRetry: () -> Unit, +) { + if (phase is SpeakPhase.Idle) return + val colors = EchoTheme.colors + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onCancel, + sheetState = sheetState, + containerColor = colors.surfacePrimary, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + when (phase) { + is SpeakPhase.Listening -> ListeningBody(targetWord) + is SpeakPhase.Correct -> CorrectBody(phase.heard, onContinue) + is SpeakPhase.Wrong -> WrongBody(phase.heard, phase.expected, onRetry) + SpeakPhase.Idle -> Unit + } + } + } +} + +@Composable +private fun ListeningBody(targetWord: String) { + val colors = EchoTheme.colors + Text( + text = stringResource(R.string.speak_listening_prompt), + fontSize = 22.sp, + fontWeight = FontWeight.W800, + color = colors.foregroundPrimary, + textAlign = TextAlign.Center, + ) + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(colors.accentSecondarySoft) + .padding(horizontal = 20.dp, vertical = 10.dp), + ) { + Text(text = targetWord, fontSize = 28.sp, fontWeight = FontWeight.W800, color = colors.accentSecondary) + } + Box( + modifier = Modifier + .size(110.dp) + .clip(CircleShape) + .background(colors.accentSecondary.copy(alpha = 0.10f)) + .testTag("speak_mic"), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(88.dp) + .clip(CircleShape) + .background(colors.accentSecondary.copy(alpha = 0.18f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Default.Mic, null, tint = colors.accentSecondary, modifier = Modifier.size(36.dp)) + } + } + Text( + text = stringResource(R.string.speak_listening_hint), + fontSize = 13.sp, + color = colors.foregroundMuted, + ) +} + +@Composable +private fun CorrectBody(heard: String, onContinue: () -> Unit) { + val colors = EchoTheme.colors + Box( + modifier = Modifier.size(56.dp).clip(CircleShape).background(colors.srsGood.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Default.Check, null, tint = colors.srsGood, modifier = Modifier.size(28.dp)) + } + Text( + text = stringResource(R.string.speak_correct_title), + fontSize = 20.sp, + fontWeight = FontWeight.W800, + color = colors.foregroundPrimary, + ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(stringResource(R.string.speak_you_said_label), fontSize = 10.sp, fontWeight = FontWeight.W700, color = colors.foregroundMuted) + Text( + text = heard, + fontSize = 28.sp, + fontWeight = FontWeight.W800, + color = colors.srsGood, + textAlign = TextAlign.Center, + modifier = Modifier.testTag("speak_correct_word"), + ) + } + SheetButton( + label = stringResource(R.string.speak_continue), + background = colors.srsGood, + contentColor = colors.foregroundOnAccent, + tag = "speak_continue", + onClick = onContinue, + ) +} + +@Composable +private fun WrongBody(heard: String, expected: String, onRetry: () -> Unit) { + val colors = EchoTheme.colors + Box( + modifier = Modifier.size(72.dp).clip(CircleShape).background(colors.dangerSoft), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Default.Close, null, tint = colors.srsAgain, modifier = Modifier.size(32.dp)) + } + Text( + text = stringResource(R.string.speak_wrong_title), + fontSize = 22.sp, + fontWeight = FontWeight.W700, + color = colors.foregroundPrimary, + textAlign = TextAlign.Center, + ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(stringResource(R.string.speak_you_said), fontSize = 11.sp, color = colors.foregroundMuted) + Text( + text = heard.ifBlank { "—" }, + fontSize = 28.sp, + fontWeight = FontWeight.W700, + color = colors.srsAgain, + modifier = Modifier.testTag("speak_heard"), + ) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(stringResource(R.string.speak_correct_label), fontSize = 11.sp, color = colors.foregroundMuted) + Text(text = expected, fontSize = 28.sp, fontWeight = FontWeight.W700, color = colors.srsGood) + } + SheetButton( + label = stringResource(R.string.speak_try_again), + background = colors.srsAgain, + contentColor = colors.foregroundOnAccent, + tag = "speak_retry", + onClick = onRetry, + ) +} + +@Composable +private fun SheetButton( + label: String, + background: androidx.compose.ui.graphics.Color, + contentColor: androidx.compose.ui.graphics.Color, + tag: String, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(background) + .clickable(onClick = onClick) + .testTag(tag) + .padding(vertical = 14.dp), + contentAlignment = Alignment.Center, + ) { + Text(text = label, fontSize = 15.sp, fontWeight = FontWeight.W700, color = contentColor) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/StudySessionScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/StudySessionScreen.kt index 63f363a..e6fdddf 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/StudySessionScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/StudySessionScreen.kt @@ -1,5 +1,9 @@ package com.github.jvsena42.echo.ui.study +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap @@ -29,6 +33,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Autorenew import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Mic import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledIconButton @@ -43,6 +48,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -50,6 +56,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -59,17 +66,23 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.github.jvsena42.echo.R import com.github.jvsena42.echo.domain.model.SrsGrade import com.github.jvsena42.echo.platform.Speaker +import com.github.jvsena42.echo.platform.SpeechEvent +import com.github.jvsena42.echo.platform.SpeechRecognizer +import com.github.jvsena42.echo.presentation.study.SpeakPhase import com.github.jvsena42.echo.presentation.study.StudySessionEffect import com.github.jvsena42.echo.presentation.study.StudySessionUiState import com.github.jvsena42.echo.presentation.study.StudySessionViewModel import com.github.jvsena42.echo.ui.components.EchoLoadingScreen import com.github.jvsena42.echo.ui.components.rememberReduceMotion import com.github.jvsena42.echo.ui.theme.EchoTheme +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.core.parameter.parametersOf @@ -80,28 +93,71 @@ fun StudySessionRoute( ) { val viewModel = koinInject { parametersOf(deckId) } val speaker = koinInject() + val speechRecognizer = koinInject() DisposableEffect(viewModel) { onDispose { viewModel.onDispose() } } val currentClose by rememberUpdatedState(onClose) + val context = LocalContext.current + val scope = rememberCoroutineScope() + val recognitionJob = remember { mutableStateOf(null) } + + val micPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) viewModel.onSpeakTest() else viewModel.onSpeechError() + } + + fun requestSpeak() { + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + if (granted) viewModel.onSpeakTest() else micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } LaunchedEffect(viewModel) { viewModel.effects.collectLatest { effect -> when (effect) { is StudySessionEffect.Speak -> speaker.speak(effect.text) + is StudySessionEffect.StartSpeechRecognition -> { + recognitionJob.value?.cancel() + recognitionJob.value = scope.launch { + if (!speechRecognizer.isAvailable()) { + viewModel.onSpeechError() + return@launch + } + speechRecognizer.listen().collect { event -> + when (event) { + is SpeechEvent.Result -> viewModel.onSpeechResult(event.text) + is SpeechEvent.Error -> viewModel.onSpeechError() + else -> Unit + } + } + } + } StudySessionEffect.Close -> currentClose() } } } val state by viewModel.state.collectAsStateWithLifecycle() + + // Stop the recognizer as soon as the speak sheet leaves the listening phase. + val listening = (state as? StudySessionUiState.Reviewing)?.speakPhase is SpeakPhase.Listening + LaunchedEffect(listening) { + if (!listening) recognitionJob.value?.cancel() + } + StudySessionScreen( state = state, onReveal = viewModel::onReveal, onGrade = viewModel::onGrade, onSpeak = viewModel::onSpeak, + onSpeakTest = ::requestSpeak, + onSpeakContinue = viewModel::onSpeakDismiss, + onSpeakRetry = viewModel::onSpeakRetry, + onSpeakCancel = viewModel::onSpeakDismiss, onClose = viewModel::onClose, onDone = onClose, ) @@ -115,6 +171,10 @@ fun StudySessionScreen( onSpeak: () -> Unit, onClose: () -> Unit, onDone: () -> Unit, + onSpeakTest: () -> Unit = {}, + onSpeakContinue: () -> Unit = {}, + onSpeakRetry: () -> Unit = {}, + onSpeakCancel: () -> Unit = {}, ) { val colors = EchoTheme.colors Box( @@ -161,10 +221,21 @@ fun StudySessionScreen( onReveal = onReveal, onGrade = onGrade, onSpeak = onSpeak, + onSpeakTest = onSpeakTest, onClose = onClose, ) } } + + if (state is StudySessionUiState.Reviewing) { + SpeakSheets( + phase = state.speakPhase, + targetWord = state.backText, + onCancel = onSpeakCancel, + onContinue = onSpeakContinue, + onRetry = onSpeakRetry, + ) + } } @Composable @@ -173,6 +244,7 @@ private fun ReviewingContent( onReveal: () -> Unit, onGrade: (SrsGrade) -> Unit, onSpeak: () -> Unit, + onSpeakTest: () -> Unit, onClose: () -> Unit, ) { val colors = EchoTheme.colors @@ -278,6 +350,8 @@ private fun ReviewingContent( text = state.frontText, textSize = 48.sp, onSpeak = onSpeak, + showListen = state.listenEnabled, + onSpeakTest = null, ) } else { // Counter-rotate so the back content is not mirrored. @@ -286,6 +360,8 @@ private fun ReviewingContent( text = state.backText, textSize = 42.sp, onSpeak = onSpeak, + showListen = state.listenEnabled, + onSpeakTest = if (state.speakEnabled) onSpeakTest else null, modifier = Modifier.graphicsLayer { rotationY = 180f }, ) } @@ -320,6 +396,8 @@ private fun CardFace( text: String, textSize: TextUnit, onSpeak: () -> Unit, + showListen: Boolean, + onSpeakTest: (() -> Unit)?, modifier: Modifier = Modifier, ) { val colors = EchoTheme.colors @@ -344,25 +422,44 @@ private fun CardFace( color = colors.foregroundPrimary, textAlign = TextAlign.Center, ) - FilledTonalButton( - onClick = onSpeak, - shape = RoundedCornerShape(50), - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = colors.accentSecondarySoft, - contentColor = colors.accentSecondary, - ), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.VolumeUp, - contentDescription = stringResource(R.string.study_speak), - modifier = Modifier.size(16.dp), - ) - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = stringResource(R.string.study_speak), - fontSize = 14.sp, - fontWeight = FontWeight.W700, - ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + if (showListen) { + FilledTonalButton( + onClick = onSpeak, + shape = RoundedCornerShape(50), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = colors.accentSecondarySoft, + contentColor = colors.accentSecondary, + ), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.VolumeUp, + contentDescription = stringResource(R.string.study_speak), + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(text = stringResource(R.string.study_speak), fontSize = 14.sp, fontWeight = FontWeight.W700) + } + } + if (onSpeakTest != null) { + FilledTonalButton( + onClick = onSpeakTest, + modifier = Modifier.testTag("study_speak"), + shape = RoundedCornerShape(50), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = colors.accentPrimarySoft, + contentColor = colors.accentPrimary, + ), + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = stringResource(R.string.study_speak_practice), + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(text = stringResource(R.string.study_speak_practice), fontSize = 14.sp, fontWeight = FontWeight.W700) + } + } } } } diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml index 81e677d..c93f060 100644 --- a/composeApp/src/androidMain/res/values/strings.xml +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -206,6 +206,7 @@ Remove image + Speak Say the word Tap to cancel Perfect! diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModel.kt index dd5b53b..a3ac26c 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModel.kt @@ -3,6 +3,7 @@ package com.github.jvsena42.echo.presentation.study import com.github.jvsena42.echo.data.repository.DeckRepository import com.github.jvsena42.echo.data.repository.SrsRepository import com.github.jvsena42.echo.domain.model.Card +import com.github.jvsena42.echo.domain.model.SpeakMatcher import com.github.jvsena42.echo.domain.model.SrsGrade import com.github.jvsena42.echo.domain.model.previewIntervals import com.github.jvsena42.echo.util.Log @@ -47,6 +48,7 @@ class StudySessionViewModel( private var revealed = false private var reviewedCount = 0 private var deckTitle = "" + private var speakPhase: SpeakPhase = SpeakPhase.Idle /** id → title, warmed lazily from [DeckRepository.listOwned] so multi-deck sessions can label each card. */ private var deckTitles: Map = emptyMap() @@ -95,10 +97,55 @@ class StudySessionViewModel( reviewedCount++ index++ revealed = false + speakPhase = SpeakPhase.Idle emitCurrent() } } + /** Start pronunciation practice for the revealed card back (gated on the deck's speak opt-in). */ + fun onSpeakTest() { + val s = _state.value + if (s !is StudySessionUiState.Reviewing || !s.revealed || !s.speakEnabled) return + val expected = queue.getOrNull(index)?.back?.text?.takeIf { it.isNotBlank() } ?: return + setSpeakPhase(SpeakPhase.Listening) + scope.launch { _effects.emit(StudySessionEffect.StartSpeechRecognition(expected)) } + } + + fun onSpeechResult(text: String) { + val expected = queue.getOrNull(index)?.back?.text.orEmpty() + val result = SpeakMatcher.match(text, expected) + setSpeakPhase( + if (result.correct) { + SpeakPhase.Correct(result.heard) + } else { + SpeakPhase.Wrong(heard = result.heard, expected = result.expected) + }, + ) + } + + fun onSpeechError() { + // Treat recognition errors (no match, timeout, etc.) as a dismissal back to the card. + setSpeakPhase(SpeakPhase.Idle) + } + + fun onSpeakRetry() { + val expected = queue.getOrNull(index)?.back?.text?.takeIf { it.isNotBlank() } ?: return + setSpeakPhase(SpeakPhase.Listening) + scope.launch { _effects.emit(StudySessionEffect.StartSpeechRecognition(expected)) } + } + + fun onSpeakDismiss() { + setSpeakPhase(SpeakPhase.Idle) + } + + private fun setSpeakPhase(phase: SpeakPhase) { + speakPhase = phase + val s = _state.value + if (s is StudySessionUiState.Reviewing) { + _state.value = s.copy(speakPhase = phase) + } + } + fun onSpeak() { val card = queue.getOrNull(index) ?: return val text = (if (revealed) card.back.text else card.front.text)?.takeIf { it.isNotBlank() } @@ -130,6 +177,7 @@ class StudySessionViewModel( val srsState = srsRepository.stateFor(card.id) val labels = srsState.previewIntervals(card.id, epochMillis()) val title = deckTitle.ifBlank { resolveDeckTitle(card.deckId) }.ifBlank { card.deckId } + val deck = deckRepository.getLocal(card.deckId) _state.value = StudySessionUiState.Reviewing( deckTitle = title, position = index + 1, @@ -139,6 +187,9 @@ class StudySessionViewModel( backLabel = card.front.text?.uppercase(), revealed = revealed, intervals = labels, + listenEnabled = deck?.listenEnabled ?: true, + speakEnabled = deck?.speakEnabled ?: true, + speakPhase = speakPhase, ) } } @@ -180,6 +231,9 @@ sealed interface StudySessionUiState { val backLabel: String?, val revealed: Boolean, val intervals: Map, + val listenEnabled: Boolean = true, + val speakEnabled: Boolean = true, + val speakPhase: SpeakPhase = SpeakPhase.Idle, ) : StudySessionUiState data class Complete(val reviewed: Int) : StudySessionUiState @@ -187,7 +241,16 @@ sealed interface StudySessionUiState { data class Error(val message: String) : StudySessionUiState } +/** Pronunciation-practice sheet state for the current card back. */ +sealed interface SpeakPhase { + data object Idle : SpeakPhase + data object Listening : SpeakPhase + data class Correct(val heard: String) : SpeakPhase + data class Wrong(val heard: String, val expected: String) : SpeakPhase +} + sealed interface StudySessionEffect { data class Speak(val text: String) : StudySessionEffect + data class StartSpeechRecognition(val expected: String) : StudySessionEffect data object Close : StudySessionEffect } From 046be9d9e608c41d53b76d24db9adf47c41f5ecb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 17 Jun 2026 06:33:15 -0300 Subject: [PATCH 06/20] test(journeys): triage/image/speak journeys + surface sheet test tags Update journey 02 with the triage step and card-options assertions; add journeys 07 (triage edit), 08 (image select), 09 (speak study). Set testTagsAsResourceId on the image and speak bottom-sheet roots so their tags surface to UiAutomator (ModalBottomSheet renders in a separate window). Verified on emulator-5554. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../echo/ui/components/CardMediaImage.kt | 1 - .../echo/ui/components/ImagePickerSheet.kt | 13 +++--- .../echo/ui/components/ImageSelection.kt | 7 ++++ .../jvsena42/echo/ui/decks/EditCardScreen.kt | 2 +- .../echo/ui/importflow/PublishDeckScreen.kt | 3 +- .../echo/ui/importflow/TriageScreen.kt | 1 - .../jvsena42/echo/ui/study/SpeakSheets.kt | 6 ++- journeys/02-paste-import-publish.xml | 6 +++ journeys/07-triage-edit.xml | 27 ++++++++++++ journeys/08-image-select.xml | 22 ++++++++++ journeys/09-speak-study.xml | 22 ++++++++++ journeys/RESULTS.md | 41 +++++++++++++++---- .../echo/platform/AndroidSpeechRecognizer.kt | 4 +- .../repository/impl/ImportRepositoryImpl.kt | 1 + .../echo/data/unsplash/UnsplashClient.kt | 1 + .../presentation/decks/EditCardViewModel.kt | 1 + .../importflow/PublishDeckViewModel.kt | 34 ++++++++------- .../study/StudySessionViewModel.kt | 1 + .../jvsena42/echo/testing/FakeRepositories.kt | 4 +- 19 files changed, 158 insertions(+), 39 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImageSelection.kt create mode 100644 journeys/07-triage-edit.xml create mode 100644 journeys/08-image-select.xml create mode 100644 journeys/09-speak-study.xml diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/CardMediaImage.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/CardMediaImage.kt index 0d28e53..bfcff1f 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/CardMediaImage.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/CardMediaImage.kt @@ -1,7 +1,6 @@ package com.github.jvsena42.echo.ui.components import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImagePickerSheet.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImagePickerSheet.kt index 156300a..c9ce299 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImagePickerSheet.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImagePickerSheet.kt @@ -41,11 +41,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -60,18 +63,13 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.compose.koinInject -/** The image a user chose: either a web URL (saved as-is) or compressed gallery bytes. */ -sealed interface ImageSelection { - data class Web(val url: String) : ImageSelection - data class Gallery(val bytes: ByteArray, val mime: String) : ImageSelection -} - /** * Reusable bottom sheet for choosing an image — web search (Unsplash) + a 3-column grid, plus a * "From gallery" button using the system photo picker (no storage permission). Backs both the * card-image sheet (cEXuT) and the cover sheet (OQ2QL). */ -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@Suppress("CyclomaticComplexMethod", "LongMethod") // Single sheet; grid/gallery/states read top-to-bottom. @Composable fun ImagePickerSheet( title: String, @@ -113,6 +111,7 @@ fun ImagePickerSheet( Column( modifier = Modifier .fillMaxWidth() + .semantics { testTagsAsResourceId = true } .padding(horizontal = 20.dp) .padding(bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(14.dp), diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImageSelection.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImageSelection.kt new file mode 100644 index 0000000..2453662 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImageSelection.kt @@ -0,0 +1,7 @@ +package com.github.jvsena42.echo.ui.components + +/** The image a user chose: either a web URL (saved as-is) or compressed gallery bytes. */ +sealed interface ImageSelection { + data class Web(val url: String) : ImageSelection + data class Gallery(val bytes: ByteArray, val mime: String) : ImageSelection +} diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt index efaff6e..1b6e7cb 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt @@ -60,12 +60,12 @@ import androidx.compose.ui.tooling.preview.Preview 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.platform.Speaker import com.github.jvsena42.echo.presentation.decks.EditCardEffect import com.github.jvsena42.echo.presentation.decks.EditCardUiState import com.github.jvsena42.echo.presentation.decks.EditCardViewModel -import coil3.compose.AsyncImage import com.github.jvsena42.echo.ui.components.ImagePickerSheet import com.github.jvsena42.echo.ui.components.ImageSelection import com.github.jvsena42.echo.ui.components.TagChip diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt index ca36d76..ae74fb1 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt @@ -56,11 +56,11 @@ import androidx.compose.ui.tooling.preview.Preview 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.importflow.PublishDeckEffect import com.github.jvsena42.echo.presentation.importflow.PublishDeckUiState import com.github.jvsena42.echo.presentation.importflow.PublishDeckViewModel -import coil3.compose.AsyncImage import com.github.jvsena42.echo.ui.components.EchoPrimaryButton import com.github.jvsena42.echo.ui.components.ImagePickerSheet import com.github.jvsena42.echo.ui.components.ImageSelection @@ -108,6 +108,7 @@ fun PublishDeckRoute( } @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Suppress("CyclomaticComplexMethod", "LongMethod") // Single-screen form; sections read top-to-bottom. @Composable private fun PublishDeckScreen( state: PublishDeckUiState, diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageScreen.kt index 8ad3d3d..ee6e074 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageScreen.kt @@ -1,7 +1,6 @@ package com.github.jvsena42.echo.ui.importflow import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/SpeakSheets.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/SpeakSheets.kt index d2a74ab..97bf972 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/SpeakSheets.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/SpeakSheets.kt @@ -21,10 +21,13 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -37,7 +40,7 @@ import com.github.jvsena42.echo.ui.theme.EchoTheme * Bottom sheets for Speak pronunciation practice (design sIqOr / n3bMb7 / BlcXn). Rendered based * on the current [SpeakPhase]; [SpeakPhase.Idle] shows nothing. */ -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun SpeakSheets( phase: SpeakPhase, @@ -58,6 +61,7 @@ fun SpeakSheets( Column( modifier = Modifier .fillMaxWidth() + .semantics { testTagsAsResourceId = true } .padding(horizontal = 24.dp) .padding(bottom = 32.dp), horizontalAlignment = Alignment.CenterHorizontally, diff --git a/journeys/02-paste-import-publish.xml b/journeys/02-paste-import-publish.xml index 4184645..e7d1f4e 100644 --- a/journeys/02-paste-import-publish.xml +++ b/journeys/02-paste-import-publish.xml @@ -12,6 +12,12 @@ Type the following three lines into the field with resource-id "paste_input": "dog,cachorro", "cat,gato", "bird,passaro" Verify a live preview appears showing 3 parsed cards with fronts dog, cat, bird Tap the element with resource-id "paste_next" + Verify the "Review cards" triage screen is shown with a card (resource-id: triage_card), a progress label "1 of 3" (resource-id: triage_progress), and Discard/Edit/Keep action buttons + Tap the element with resource-id "triage_keep" to keep the first card + Tap the element with resource-id "triage_keep" to keep the second card + Tap the element with resource-id "triage_keep" to keep the third (last) card and advance to Publish + Verify the "New deck" publish screen is shown with a "Card Options" section + Verify the Listen toggle (resource-id: publish_listen_toggle) and Speak toggle (resource-id: publish_speak_toggle) are both ON by default Enter "Animals PT" into the field with resource-id "publish_title" Tap the element with resource-id "publish_button" Verify a "Deck published!" success state appears with an Undo button showing a live countdown (resource-id: publish_undo) and a Done button (resource-id: publish_done) diff --git a/journeys/07-triage-edit.xml b/journeys/07-triage-edit.xml new file mode 100644 index 0000000..490c5f2 --- /dev/null +++ b/journeys/07-triage-edit.xml @@ -0,0 +1,27 @@ + + + The Review-cards triage step (spec §5.5, design U92Nh): paste a list, edit one card's + front/back text in triage, keep the rest, publish, and confirm the edit persisted in the + deck. Requires a signed-in session (run journey 01 first). + + + Launch the Echo app and verify the Home screen is shown + Tap the element with resource-id "tab_decks" in the bottom tab bar + Tap the element with resource-id "decks_paste_cta" + Type the following two lines into the field with resource-id "paste_input": "hola,hello", "gracias,thanks" + Tap the element with resource-id "paste_next" + Verify the "Review cards" triage screen is shown with the first card front "hola" (resource-id: triage_front) + Tap the element with resource-id "triage_edit" to edit the first card + Verify the edit screen is shown with editable front (resource-id: triage_edit_front) and back (resource-id: triage_edit_back) fields + Clear the field with resource-id "triage_edit_front" and type "buenos dias" + Clear the field with resource-id "triage_edit_back" and type "good morning" + Tap the element with resource-id "triage_edit_save" + Verify the triage screen is shown again and the first card front now reads "buenos dias" (resource-id: triage_front) + Tap the element with resource-id "triage_keep" to keep the first card + Tap the element with resource-id "triage_keep" to keep the second card and advance to Publish + Enter "Greetings ES" into the field with resource-id "publish_title" + Tap the element with resource-id "publish_button" + Tap the element with resource-id "publish_done" + Verify the deck detail screen for "Greetings ES" shows a card with front "buenos dias" and back "good morning" + + diff --git a/journeys/08-image-select.xml b/journeys/08-image-select.xml new file mode 100644 index 0000000..3129793 --- /dev/null +++ b/journeys/08-image-select.xml @@ -0,0 +1,22 @@ + + + Choosing a card/cover image (design cEXuT / OQ2QL): open the image sheet from the Publish + cover, search the web grid, and select a photo. The web grid requires UNSPLASH_ACCESS_KEY in + local.properties — when it is blank the grid shows a gallery-only hint and the web steps + should be skipped. The gallery path opens the system photo picker, which cannot be reliably + automated, so it is left as a manual check. Requires a signed-in session (run journey 01). + + + Launch the Echo app and verify the Home screen is shown + Tap the element with resource-id "tab_decks" in the bottom tab bar + Tap the element with resource-id "decks_paste_cta" + Type the following two lines into the field with resource-id "paste_input": "sol,sun", "luna,moon" + Tap the element with resource-id "paste_next" + Tap the element with resource-id "triage_keep" twice to keep both cards and advance to Publish + Tap the element with resource-id "publish_cover_change" to open the cover image sheet + Verify the image sheet shows a search field (resource-id: image_search_input) and a "From gallery" button (resource-id: image_pick_gallery) + If UNSPLASH_ACCESS_KEY is configured: type "sun" into the field with resource-id "image_search_input", verify the web image grid (resource-id: image_grid) populates, tap the first cell (resource-id: image_grid_cell), then tap "Done" (resource-id: image_sheet_done) and verify the cover thumbnail updates on the Publish screen + If UNSPLASH_ACCESS_KEY is blank: verify the grid area shows the gallery-only hint and skip the web selection steps + Manual check (system picker not automatable): tap "From gallery" (resource-id: image_pick_gallery), choose an image, and verify it is compressed and shown as the cover + + diff --git a/journeys/09-speak-study.xml b/journeys/09-speak-study.xml new file mode 100644 index 0000000..e41381b --- /dev/null +++ b/journeys/09-speak-study.xml @@ -0,0 +1,22 @@ + + + The Speak study feature (design aLoMj / sIqOr / n3bMb7 / BlcXn): on a speak-enabled deck, + reveal a card back and start pronunciation practice. Actual speech cannot be synthesized in + automation, so this journey verifies the permission prompt and the Listening sheet appear and + that cancel/retry work; the Correct/Wrong outcome is a manual check. Requires a signed-in + session with at least one due card in a speak-enabled deck (run journeys 01–02 first). + + + Launch the Echo app and verify the Home screen is shown + Tap the element with resource-id "tab_decks" in the bottom tab bar + Open a deck that was published with the Speak option enabled and start studying it + Verify the study card (resource-id: study_card) is shown front-side + Tap the card to reveal the back + Verify a "Speak" practice button (resource-id: study_speak) is shown on the card back + Tap the element with resource-id "study_speak" + If the microphone permission has not been granted, verify the Android RECORD_AUDIO permission dialog appears and grant it + Verify the Listening sheet appears with a microphone indicator (resource-id: speak_mic) and the target word + Manual check (speech not automatable): say the word and verify either the "Perfect!" sheet with Continue (resource-id: speak_continue) or the "Not quite right" sheet with your transcript (resource-id: speak_heard) and Try again (resource-id: speak_retry) + Dismiss the Listening sheet and verify the study card is shown again + + diff --git a/journeys/RESULTS.md b/journeys/RESULTS.md index 005830a..c46adcc 100644 --- a/journeys/RESULTS.md +++ b/journeys/RESULTS.md @@ -26,19 +26,42 @@ callbacks (→ `echo://login-callback`, `MainActivity` = singleTask) so Ring re- after approval. On the installed Ring build the success screen shows an "OK" button and does not fire `openXSuccess`, so the user taps back to Echo manually — a Ring-side issue, not Echo. -## 02 — Paste-to-Import → publish — ✅ PASS +## 02 — Paste-to-Import → triage → publish — ✅ PASS + +Re-verified on `emulator-5554` 2026-06-17 after adding the triage step + card options. | Step | Result | | --- | --- | | Decks tab → "Paste to import" | PASSED | -| Paste 3 comma-separated lines | PASSED — parser auto-detected "comma", "3 cards", live preview (dog→cachorro, cat→gato, bird→passaro) | -| Next → publish screen, enter title "Animals PT" | PASSED — "3 cards ready" | -| Publish deck | PASSED — 4 `put_with_session` writes to the homeserver (3 cards + manifest) succeeded; `SUCCESS deckId=pv0b3aq0ruz6` | -| Undo window | PASSED — "Deck published! … Undo (6s)" countdown + Done (spec §5.6) | -| Done → deck detail | PASSED — "Animals PT", Total 3 / Due 3, owner edit/delete/share controls | +| Paste comma lines | PASSED — parser auto-detected "comma", live preview | +| Next → **Review cards** triage | PASSED — `triage_card`, `triage_progress` "1 of 3", keep advances "2 of 3 · 1 kept", keeping the last card advances to Publish | +| Publish screen → **Card Options** | PASSED — Listen (`publish_listen_toggle`) + Speak (`publish_speak_toggle`) both ON by default | +| Publish deck | PASSED — "Deck published! … Undo (7s)" + Done; `listen_enabled`/`speak_enabled` serialized into the manifest | +| Done → deck detail | PASSED — deck "Sky", Total 2 / Due 2, In Your Library | ## 03–06 — runnable -With sign-in and homeserver writes working, the remaining journeys (study loop, discover, -deck manage/delete, profile/settings/sign-out) are runnable on the emulator. Drive -`journeys/03…06.xml` with the `android` CLI (`android screen capture` + `adb shell input`). +The study loop, discover, deck manage/delete, and profile/settings/sign-out journeys remain +runnable on the emulator. + +## 07 — Triage edit — ⏳ scripted (not re-run) + +`journeys/07-triage-edit.xml`: edit a draft card's front/back in triage, keep, publish, and +confirm the edit persisted. Scripted; drive with adb. + +## 08 — Image select — ✅ PARTIAL PASS + +`journeys/08-image-select.xml`. On `emulator-5554` 2026-06-17: the cover sheet opens from +`publish_cover_change` with `image_search_input` + `image_pick_gallery`; since +`UNSPLASH_ACCESS_KEY` is blank in `local.properties`, the web grid shows the gallery-only hint +("Search the web or pick from your gallery") as designed. The gallery path uses the system photo +picker and is a manual check. Sheet test-tags surface correctly (the sheet content sets +`testTagsAsResourceId` since `ModalBottomSheet` renders in a separate window). + +## 09 — Speak study — ✅ PARTIAL PASS + +`journeys/09-speak-study.xml`. On `emulator-5554` 2026-06-17: studying the speak-enabled "Sky" +deck shows the back-card `study_speak` button; tapping it raises the Android RECORD_AUDIO +permission dialog at the right time. This emulator image has no on-device speech recognition +service, so `SpeechRecognizer` reports unavailable and the flow returns to the card without +crashing — the Listening/Correct/Wrong outcome is a manual check on a device with Google speech. diff --git a/shared/src/androidMain/kotlin/com/github/jvsena42/echo/platform/AndroidSpeechRecognizer.kt b/shared/src/androidMain/kotlin/com/github/jvsena42/echo/platform/AndroidSpeechRecognizer.kt index 4f0d5e7..ef0f6c0 100644 --- a/shared/src/androidMain/kotlin/com/github/jvsena42/echo/platform/AndroidSpeechRecognizer.kt +++ b/shared/src/androidMain/kotlin/com/github/jvsena42/echo/platform/AndroidSpeechRecognizer.kt @@ -5,10 +5,10 @@ import android.content.Intent import android.os.Bundle import android.speech.RecognitionListener import android.speech.RecognizerIntent -import android.speech.SpeechRecognizer as AndroidSpeech import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +import android.speech.SpeechRecognizer as AndroidSpeech /** * [SpeechRecognizer] backed by Android [android.speech.SpeechRecognizer]. The recognizer must be @@ -28,6 +28,8 @@ class AndroidSpeechRecognizer(private val context: Context) : SpeechRecognizer { } val recognizer = AndroidSpeech.createSpeechRecognizer(context) + + @Suppress("EmptyFunctionBlock") val listener = object : RecognitionListener { override fun onReadyForSpeech(params: Bundle?) { trySend(SpeechEvent.Ready) } override fun onBeginningOfSpeech() { trySend(SpeechEvent.BeginningOfSpeech) } diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/impl/ImportRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/impl/ImportRepositoryImpl.kt index 55168e4..4bed9eb 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/impl/ImportRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/impl/ImportRepositoryImpl.kt @@ -11,6 +11,7 @@ import com.github.jvsena42.echo.domain.model.TriageDecision import com.github.jvsena42.echo.domain.model.backIndex import com.github.jvsena42.echo.domain.model.frontIndex +@Suppress("TooManyFunctions") class ImportRepositoryImpl : ImportRepository { private var draft: ImportDraft? = null diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/unsplash/UnsplashClient.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/unsplash/UnsplashClient.kt index b128bf7..221ef47 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/unsplash/UnsplashClient.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/unsplash/UnsplashClient.kt @@ -97,6 +97,7 @@ internal fun UnsplashPhotoDto.toDomain() = UnsplashPhoto( ) /** Percent-encode a query string (UTF-8) for use in a URL — commonMain has no URLEncoder. */ +@Suppress("MagicNumber") // ASCII boundary (0x80) and hex radix (16) are standard URL-encoding constants. private fun String.urlEncode(): String = buildString { for (byte in this@urlEncode.encodeToByteArray()) { val code = byte.toInt() and 0xFF diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/EditCardViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/EditCardViewModel.kt index fb219c2..b0af071 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/EditCardViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/EditCardViewModel.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +@Suppress("TooManyFunctions") class EditCardViewModel( private val deckId: String, private val cardId: String, diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt index 1ad4084..4910eba 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt @@ -9,6 +9,7 @@ import com.github.jvsena42.echo.domain.model.CardIndexEntry import com.github.jvsena42.echo.domain.model.CardSide import com.github.jvsena42.echo.domain.model.ColumnRole import com.github.jvsena42.echo.domain.model.Deck +import com.github.jvsena42.echo.domain.model.ImportDraft import com.github.jvsena42.echo.domain.model.MediaRef import com.github.jvsena42.echo.domain.model.Tag import com.github.jvsena42.echo.util.Log @@ -28,6 +29,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +@Suppress("TooManyFunctions") class PublishDeckViewModel( private val importRepository: ImportRepository, private val deckRepository: DeckRepository, @@ -121,21 +123,7 @@ class PublishDeckViewModel( val now = epochMillis() val deckId = generateId() - val mapping = draft.columnMapping.assignments - val keptRows = importRepository.keptRows() - - val cards = keptRows.map { row -> - val frontIdx = mapping.indexOfFirst { it == ColumnRole.Front }.takeIf { it >= 0 } ?: 0 - val backIdx = mapping.indexOfFirst { it == ColumnRole.Back }.takeIf { it >= 0 } ?: 1 - Card( - id = generateId(), - deckId = deckId, - updatedAt = now, - front = CardSide(text = row.fields.getOrElse(frontIdx) { "" }.takeIf { it.isNotBlank() }), - back = CardSide(text = row.fields.getOrElse(backIdx) { "" }.takeIf { it.isNotBlank() }), - ) - } - + val cards = buildCards(draft, deckId, now) val coverImageRef = resolveCoverImage(s, deckId) val deck = Deck( @@ -206,6 +194,22 @@ class PublishDeckViewModel( } } + /** Maps the kept triage rows to [Card]s using the draft's column roles. */ + private fun buildCards(draft: ImportDraft, deckId: String, now: Long): List { + val mapping = draft.columnMapping.assignments + val frontIdx = mapping.indexOfFirst { it == ColumnRole.Front }.takeIf { it >= 0 } ?: 0 + val backIdx = mapping.indexOfFirst { it == ColumnRole.Back }.takeIf { it >= 0 } ?: 1 + return importRepository.keptRows().map { row -> + Card( + id = generateId(), + deckId = deckId, + updatedAt = now, + front = CardSide(text = row.fields.getOrElse(frontIdx) { "" }.takeIf { it.isNotBlank() }), + back = CardSide(text = row.fields.getOrElse(backIdx) { "" }.takeIf { it.isNotBlank() }), + ) + } + } + /** Builds the cover [MediaRef.Image]: upload gallery bytes, or wrap a web URL, else none. */ private suspend fun resolveCoverImage(s: PublishDeckUiState, deckId: String): MediaRef.Image? = when { s.coverPendingBytes != null -> diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModel.kt index a3ac26c..cec3cae 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModel.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.launch * value studies one deck (DeckDetail). Grading delegates to [SrsRepository.review], which owns the * SM-2-lite scheduler — the VM only sequences the queue and tracks reveal/progress. */ +@Suppress("TooManyFunctions") class StudySessionViewModel( private val deckId: String?, private val srsRepository: SrsRepository, diff --git a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/FakeRepositories.kt b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/FakeRepositories.kt index 1f19458..752e3b8 100644 --- a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/FakeRepositories.kt +++ b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/FakeRepositories.kt @@ -9,12 +9,11 @@ import com.github.jvsena42.echo.data.repository.MediaRepository import com.github.jvsena42.echo.data.repository.SrsRepository import com.github.jvsena42.echo.data.repository.TagRepository import com.github.jvsena42.echo.domain.model.Card -import com.github.jvsena42.echo.domain.model.MediaRef import com.github.jvsena42.echo.domain.model.ColumnMapping import com.github.jvsena42.echo.domain.model.Deck import com.github.jvsena42.echo.domain.model.ImportDraft +import com.github.jvsena42.echo.domain.model.MediaRef import com.github.jvsena42.echo.domain.model.ParsedRow -import com.github.jvsena42.echo.domain.model.TriageDecision import com.github.jvsena42.echo.domain.model.PubkyIdentity import com.github.jvsena42.echo.domain.model.PubkyUri import com.github.jvsena42.echo.domain.model.Separator @@ -22,6 +21,7 @@ import com.github.jvsena42.echo.domain.model.Session import com.github.jvsena42.echo.domain.model.SrsGrade import com.github.jvsena42.echo.domain.model.SrsState import com.github.jvsena42.echo.domain.model.Tag +import com.github.jvsena42.echo.domain.model.TriageDecision import com.github.jvsena42.echo.domain.model.review class FakeIdentityRepository(var session: Session? = fakeSession()) : IdentityRepository { From e4eda8d61e99c41a75fc9595e10254fb4254b978 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 17 Jun 2026 19:04:37 -0300 Subject: [PATCH 07/20] design: remove speak button from front card --- design/main/phone-echo.pen | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/design/main/phone-echo.pen b/design/main/phone-echo.pen index a4da0bc..7fac373 100644 --- a/design/main/phone-echo.pen +++ b/design/main/phone-echo.pen @@ -1037,41 +1037,6 @@ "fontFamily": "Funnel Sans", "fontSize": 48, "fontWeight": "800" - }, - { - "type": "frame", - "id": "rlxS7", - "name": "speak", - "fill": "$accent-secondary-soft", - "cornerRadius": "$radius-pill", - "gap": 8, - "padding": [ - 10, - 18 - ], - "alignItems": "center", - "children": [ - { - "type": "icon", - "id": "Avarb", - "name": "speakI", - "width": 16, - "height": 16, - "icon": "mic", - "library": "lucide", - "fill": "$accent-secondary" - }, - { - "type": "text", - "id": "Ka7n3", - "name": "speakT", - "fill": "$accent-secondary", - "content": "Speak", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "700" - } - ] } ] }, From f01ffb129560efc3b9436fc0bc62df5caa2e8506 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 17 Jun 2026 19:22:01 -0300 Subject: [PATCH 08/20] fix(study): correct Listen/Speak labels, request mic permission, feedback on unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card-back Listen (TTS) button was labelled "Speak" (both buttons read "Speak"), so users tapped the wrong one and the record button never asked for mic permission. Rename the TTS button to "Listen" (peach) and the practice button to "Speak" (purple) — matching design aLoMj — drop the redundant study_speak_practice string, and stop showing Listen/Speak on the card front (design w1CAm). Show the front-side image as a circular avatar on the back. Surface a Toast when speech recognition is unavailable or the mic permission is denied instead of silently returning to the card, and make the Listening sheet mic a solid purple circle (design sIqOr). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jvsena42/echo/ui/study/SpeakSheets.kt | 9 ++-- .../echo/ui/study/StudySessionScreen.kt | 48 ++++++++++++++----- .../src/androidMain/res/values/strings.xml | 2 +- .../study/StudySessionViewModel.kt | 6 +++ 4 files changed, 49 insertions(+), 16 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/SpeakSheets.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/SpeakSheets.kt index 97bf972..7c8b0ca 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/SpeakSheets.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/SpeakSheets.kt @@ -95,22 +95,23 @@ private fun ListeningBody(targetWord: String) { ) { Text(text = targetWord, fontSize = 28.sp, fontWeight = FontWeight.W800, color = colors.accentSecondary) } + // Solid purple mic in a soft halo ring (design `sIqOr`). Box( modifier = Modifier .size(110.dp) .clip(CircleShape) - .background(colors.accentSecondary.copy(alpha = 0.10f)) + .background(colors.accentSecondary.copy(alpha = 0.15f)) .testTag("speak_mic"), contentAlignment = Alignment.Center, ) { Box( modifier = Modifier - .size(88.dp) + .size(80.dp) .clip(CircleShape) - .background(colors.accentSecondary.copy(alpha = 0.18f)), + .background(colors.accentSecondary), contentAlignment = Alignment.Center, ) { - Icon(Icons.Default.Mic, null, tint = colors.accentSecondary, modifier = Modifier.size(36.dp)) + Icon(Icons.Default.Mic, null, tint = colors.foregroundOnAccent, modifier = Modifier.size(36.dp)) } } Text( diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/StudySessionScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/StudySessionScreen.kt index e6fdddf..9bc90a2 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/StudySessionScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/StudySessionScreen.kt @@ -2,6 +2,7 @@ package com.github.jvsena42.echo.ui.study import android.Manifest import android.content.pm.PackageManager +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.FastOutSlowInEasing @@ -27,6 +28,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material.icons.Icons @@ -69,6 +71,7 @@ import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.github.jvsena42.echo.R +import com.github.jvsena42.echo.domain.model.MediaRef import com.github.jvsena42.echo.domain.model.SrsGrade import com.github.jvsena42.echo.platform.Speaker import com.github.jvsena42.echo.platform.SpeechEvent @@ -77,6 +80,7 @@ import com.github.jvsena42.echo.presentation.study.SpeakPhase import com.github.jvsena42.echo.presentation.study.StudySessionEffect import com.github.jvsena42.echo.presentation.study.StudySessionUiState import com.github.jvsena42.echo.presentation.study.StudySessionViewModel +import com.github.jvsena42.echo.ui.components.CardMediaImage import com.github.jvsena42.echo.ui.components.EchoLoadingScreen import com.github.jvsena42.echo.ui.components.rememberReduceMotion import com.github.jvsena42.echo.ui.theme.EchoTheme @@ -107,7 +111,12 @@ fun StudySessionRoute( val micPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission(), ) { granted -> - if (granted) viewModel.onSpeakTest() else viewModel.onSpeechError() + if (granted) { + viewModel.onSpeakTest() + } else { + Toast.makeText(context, R.string.speak_permission_denied, Toast.LENGTH_LONG).show() + viewModel.onSpeechError() + } } fun requestSpeak() { @@ -124,6 +133,7 @@ fun StudySessionRoute( recognitionJob.value?.cancel() recognitionJob.value = scope.launch { if (!speechRecognizer.isAvailable()) { + Toast.makeText(context, R.string.speak_unavailable, Toast.LENGTH_LONG).show() viewModel.onSpeechError() return@launch } @@ -344,13 +354,14 @@ private fun ReviewingContent( contentAlignment = Alignment.Center, ) { if (rotation < 90f) { - // Front shows only the prompt; the reveal cue lives in the hint row below. + // Front shows only the prompt (design `w1CAm`); the reveal cue lives in the hint + // row below, and Listen/Speak appear only on the back. CardFace( label = null, text = state.frontText, textSize = 48.sp, onSpeak = onSpeak, - showListen = state.listenEnabled, + showListen = false, onSpeakTest = null, ) } else { @@ -362,6 +373,8 @@ private fun ReviewingContent( onSpeak = onSpeak, showListen = state.listenEnabled, onSpeakTest = if (state.speakEnabled) onSpeakTest else null, + imageRef = state.frontImageRef, + deckId = state.deckId, modifier = Modifier.graphicsLayer { rotationY = 180f }, ) } @@ -399,6 +412,8 @@ private fun CardFace( showListen: Boolean, onSpeakTest: (() -> Unit)?, modifier: Modifier = Modifier, + imageRef: MediaRef.Image? = null, + deckId: String = "", ) { val colors = EchoTheme.colors Column( @@ -415,6 +430,17 @@ private fun CardFace( color = colors.accentPrimary, ) } + // Front-side image shown as a circular avatar on the card back (design `aLoMj`). + imageRef?.let { image -> + CardMediaImage( + image = image, + deckId = deckId, + modifier = Modifier + .size(96.dp) + .clip(CircleShape) + .background(colors.accentPrimarySoft), + ) + } Text( text = text, fontSize = textSize, @@ -428,17 +454,17 @@ private fun CardFace( onClick = onSpeak, shape = RoundedCornerShape(50), colors = ButtonDefaults.filledTonalButtonColors( - containerColor = colors.accentSecondarySoft, - contentColor = colors.accentSecondary, + containerColor = colors.accentPrimarySoft, + contentColor = colors.accentPrimary, ), ) { Icon( imageVector = Icons.AutoMirrored.Filled.VolumeUp, - contentDescription = stringResource(R.string.study_speak), + contentDescription = stringResource(R.string.study_listen), modifier = Modifier.size(16.dp), ) Spacer(modifier = Modifier.size(8.dp)) - Text(text = stringResource(R.string.study_speak), fontSize = 14.sp, fontWeight = FontWeight.W700) + Text(text = stringResource(R.string.study_listen), fontSize = 14.sp, fontWeight = FontWeight.W700) } } if (onSpeakTest != null) { @@ -447,17 +473,17 @@ private fun CardFace( modifier = Modifier.testTag("study_speak"), shape = RoundedCornerShape(50), colors = ButtonDefaults.filledTonalButtonColors( - containerColor = colors.accentPrimarySoft, - contentColor = colors.accentPrimary, + containerColor = colors.accentSecondarySoft, + contentColor = colors.accentSecondary, ), ) { Icon( imageVector = Icons.Default.Mic, - contentDescription = stringResource(R.string.study_speak_practice), + contentDescription = stringResource(R.string.study_speak), modifier = Modifier.size(16.dp), ) Spacer(modifier = Modifier.size(8.dp)) - Text(text = stringResource(R.string.study_speak_practice), fontSize = 14.sp, fontWeight = FontWeight.W700) + Text(text = stringResource(R.string.study_speak), fontSize = 14.sp, fontWeight = FontWeight.W700) } } } diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml index c93f060..cd42952 100644 --- a/composeApp/src/androidMain/res/values/strings.xml +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -106,6 +106,7 @@ Back %1$d of %2$d Tap card to reveal answer + Listen Speak @@ -206,7 +207,6 @@ Remove image - Speak Say the word Tap to cancel Perfect! diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModel.kt index cec3cae..6d35ff0 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModel.kt @@ -3,6 +3,7 @@ package com.github.jvsena42.echo.presentation.study import com.github.jvsena42.echo.data.repository.DeckRepository import com.github.jvsena42.echo.data.repository.SrsRepository import com.github.jvsena42.echo.domain.model.Card +import com.github.jvsena42.echo.domain.model.MediaRef import com.github.jvsena42.echo.domain.model.SpeakMatcher import com.github.jvsena42.echo.domain.model.SrsGrade import com.github.jvsena42.echo.domain.model.previewIntervals @@ -191,6 +192,8 @@ class StudySessionViewModel( listenEnabled = deck?.listenEnabled ?: true, speakEnabled = deck?.speakEnabled ?: true, speakPhase = speakPhase, + deckId = card.deckId, + frontImageRef = card.front.imageRef, ) } } @@ -235,6 +238,9 @@ sealed interface StudySessionUiState { val listenEnabled: Boolean = true, val speakEnabled: Boolean = true, val speakPhase: SpeakPhase = SpeakPhase.Idle, + val deckId: String = "", + /** Front-side image, shown as a circular avatar on the card back (design `aLoMj`). */ + val frontImageRef: MediaRef.Image? = null, ) : StudySessionUiState data class Complete(val reviewed: Int) : StudySessionUiState From 91b86a04ac36e5350897b33d5105696fb680f0b5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 17 Jun 2026 19:22:19 -0300 Subject: [PATCH 09/20] =?UTF-8?q?feat(publish):=20match=20New-deck=20desig?= =?UTF-8?q?n=20=E2=80=94=20peach=20badge,=20solid=20fields,=20option=20ico?= =?UTF-8?q?ns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the cards-ready badge to the peach panel with a solid orange check and an "N discarded in review" subtitle (design yFOOS), make the title/description fields solid white cards, and add leading icons to the Listen (peach headphones) and Speak (purple mic) option rows. Track discardedCount in PublishDeckUiState. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../echo/ui/importflow/PublishDeckScreen.kt | 56 ++++++++++++++++--- .../src/androidMain/res/values/strings.xml | 1 + .../importflow/PublishDeckViewModel.kt | 7 ++- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt index ae74fb1..4bba097 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt @@ -26,8 +26,10 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Mic import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.ModalBottomSheet @@ -46,7 +48,9 @@ import androidx.compose.runtime.setValue 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.SolidColor +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 @@ -179,24 +183,44 @@ private fun PublishDeckScreen( Spacer(Modifier.size(40.dp)) } - // Cards ready badge + // Cards ready badge (design `yFOOS`): peach panel, solid orange check, discarded subtitle. Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(14.dp)) - .background(colors.srsGood.copy(alpha = 0.15f)) + .background(colors.accentPrimarySoft) .padding(14.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon(Icons.Default.Check, null, tint = colors.srsGood, modifier = Modifier.size(20.dp)) - Column { + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(colors.accentPrimary), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.Check, + null, + tint = colors.foregroundOnAccent, + modifier = Modifier.size(18.dp), + ) + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( stringResource(R.string.publish_cards_ready, state.cardCount), fontSize = 15.sp, fontWeight = FontWeight.Bold, color = colors.foregroundPrimary, ) + if (state.discardedCount > 0) { + Text( + stringResource(R.string.publish_cards_discarded, state.discardedCount), + fontSize = 12.sp, + color = colors.foregroundSecondary, + ) + } } } @@ -270,7 +294,7 @@ private fun PublishDeckScreen( .testTag("publish_title") .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) - .border(1.dp, colors.borderSubtle, RoundedCornerShape(12.dp)) + .background(colors.surfaceCard) .padding(14.dp), textStyle = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold, color = colors.foregroundPrimary), cursorBrush = SolidColor(colors.accentPrimary), @@ -309,7 +333,7 @@ private fun PublishDeckScreen( .testTag("publish_description") .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) - .border(1.dp, colors.borderSubtle, RoundedCornerShape(12.dp)) + .background(colors.surfaceCard) .padding(14.dp), textStyle = TextStyle(fontSize = 14.sp, color = colors.foregroundSecondary), cursorBrush = SolidColor(colors.accentPrimary), @@ -380,6 +404,9 @@ private fun PublishDeckScreen( checked = state.listenEnabled, onToggle = onToggleListen, testTag = "publish_listen_toggle", + icon = Icons.AutoMirrored.Filled.VolumeUp, + iconColor = colors.accentPrimary, + iconBackground = colors.accentPrimarySoft, ) OptionToggleRow( title = stringResource(R.string.publish_speak_title), @@ -387,6 +414,9 @@ private fun PublishDeckScreen( checked = state.speakEnabled, onToggle = onToggleSpeak, testTag = "publish_speak_toggle", + icon = Icons.Default.Mic, + iconColor = colors.accentSecondary, + iconBackground = colors.accentSecondarySoft, ) } @@ -592,6 +622,9 @@ private fun OptionToggleRow( checked: Boolean, onToggle: () -> Unit, testTag: String, + icon: ImageVector, + iconColor: Color, + iconBackground: Color, ) { val colors = EchoTheme.colors Row( @@ -604,6 +637,15 @@ private fun OptionToggleRow( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(iconBackground), + contentAlignment = Alignment.Center, + ) { + Icon(icon, null, tint = iconColor, modifier = Modifier.size(18.dp)) + } Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text(title, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = colors.foregroundPrimary) Text(subtitle, fontSize = 12.sp, color = colors.foregroundSecondary) diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml index cd42952..b9fa664 100644 --- a/composeApp/src/androidMain/res/values/strings.xml +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -156,6 +156,7 @@ Back New deck %1$d cards ready + %1$d discarded in review COVER Change TITLE diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt index 4910eba..e195fea 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt @@ -50,8 +50,10 @@ class PublishDeckViewModel( private var undoCountdownJob: Job? = null init { - if (importRepository.currentDraft() != null) { - _state.update { it.copy(cardCount = importRepository.keptRows().size) } + val draft = importRepository.currentDraft() + if (draft != null) { + val kept = importRepository.keptRows().size + _state.update { it.copy(cardCount = kept, discardedCount = draft.rows.size - kept) } } } @@ -269,6 +271,7 @@ data class PublishDeckUiState( val coverEmoji: String = "", val tags: List = emptyList(), val cardCount: Int = 0, + val discardedCount: Int = 0, val isPublishing: Boolean = false, val publishedDeckId: String? = null, val undoSecondsRemaining: Int = 0, From a565dc2e799e99fc95167a746e06b5d72036ad72 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 17 Jun 2026 19:22:30 -0300 Subject: [PATCH 10/20] feat(import): add bottom Next button to paste preview (design MJ1SR) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jvsena42/echo/ui/importflow/PasteScreen.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PasteScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PasteScreen.kt index cdb24f0..1f0a457 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PasteScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PasteScreen.kt @@ -57,6 +57,7 @@ import com.github.jvsena42.echo.presentation.importflow.PasteImportEffect import com.github.jvsena42.echo.presentation.importflow.PasteImportUiState import com.github.jvsena42.echo.presentation.importflow.PasteImportViewModel import com.github.jvsena42.echo.presentation.importflow.PreviewCard +import com.github.jvsena42.echo.ui.components.EchoPrimaryButton import com.github.jvsena42.echo.ui.theme.EchoTheme import kotlinx.coroutines.flow.collectLatest import org.koin.compose.koinInject @@ -282,6 +283,18 @@ private fun PasteScreen( state.error?.let { errorText -> Text(errorText, fontSize = 14.sp, color = colors.danger, modifier = Modifier.fillMaxWidth()) } + + // Bottom Next button (design `MJ1SR`) — primary CTA once cards are parsed. + if (state.isParsed) { + EchoPrimaryButton( + label = stringResource(R.string.paste_next), + onClick = onNextClick, + enabled = state.isParsed, + modifier = Modifier + .testTag("paste_next_button") + .fillMaxWidth(), + ) + } } } } From b831f1538d7f4ecee569e0825524ada0c1088735 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 17 Jun 2026 19:22:30 -0300 Subject: [PATCH 11/20] feat(deck): make Edit-card Save an orange pill button (design vU2cv) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../github/jvsena42/echo/ui/decks/EditCardScreen.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt index 1b6e7cb..3aa8e30 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.Layers import androidx.compose.material.icons.filled.Mic import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -166,11 +167,17 @@ fun EditCardScreen( .size(20.dp), ) } else { - TextButton( + Button( onClick = onSaveClick, - colors = ButtonDefaults.textButtonColors(contentColor = colors.accentPrimary), + modifier = Modifier.padding(end = 12.dp), + shape = RoundedCornerShape(50), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = colors.accentPrimary, + contentColor = colors.foregroundOnAccent, + ), ) { - Text(text = stringResource(R.string.edit_card_save), fontSize = 16.sp, fontWeight = FontWeight.W700) + Text(text = stringResource(R.string.edit_card_save), fontSize = 15.sp, fontWeight = FontWeight.W700) } } }, From f635086743dff59571873f58d042cb0379a1217a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 17 Jun 2026 19:22:30 -0300 Subject: [PATCH 12/20] feat(media): make image-picker Done a filled pill, disabled until a selection Co-Authored-By: Claude Opus 4.8 (1M context) --- .../echo/ui/components/ImagePickerSheet.kt | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImagePickerSheet.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImagePickerSheet.kt index c9ce299..42513f7 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImagePickerSheet.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImagePickerSheet.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -25,6 +26,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -123,19 +126,25 @@ fun ImagePickerSheet( verticalAlignment = Alignment.CenterVertically, ) { Text(text = title, fontSize = 20.sp, fontWeight = FontWeight.W800, color = colors.foregroundPrimary) - Text( - text = stringResource(R.string.image_sheet_done), - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = if (selectedUrl != null) colors.accentPrimary else colors.foregroundMuted, - modifier = Modifier - .testTag("image_sheet_done") - .clip(RoundedCornerShape(50)) - .clickable(enabled = selectedUrl != null) { - selectedUrl?.let { onSelected(ImageSelection.Web(it)) } - } - .padding(horizontal = 16.dp, vertical = 8.dp), - ) + Button( + onClick = { selectedUrl?.let { onSelected(ImageSelection.Web(it)) } }, + enabled = selectedUrl != null, + modifier = Modifier.testTag("image_sheet_done"), + shape = RoundedCornerShape(50), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = colors.accentPrimary, + contentColor = colors.foregroundOnAccent, + disabledContainerColor = colors.borderSubtle, + disabledContentColor = colors.foregroundMuted, + ), + ) { + Text( + text = stringResource(R.string.image_sheet_done), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) + } } subtitle?.let { From 4fdde8d7a19a4dd4b63d00bde5515100234f439c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 17 Jun 2026 19:22:30 -0300 Subject: [PATCH 13/20] test(journeys): record Listen/Speak fix + design-polish verification run Co-Authored-By: Claude Opus 4.8 (1M context) --- journeys/RESULTS.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/journeys/RESULTS.md b/journeys/RESULTS.md index c46adcc..e97b7f0 100644 --- a/journeys/RESULTS.md +++ b/journeys/RESULTS.md @@ -65,3 +65,21 @@ deck shows the back-card `study_speak` button; tapping it raises the Android REC permission dialog at the right time. This emulator image has no on-device speech recognition service, so `SpeechRecognizer` reports unavailable and the flow returns to the card without crashing — the Listening/Correct/Wrong outcome is a manual check on a device with Google speech. + +## 10 — Listen/Speak fixes + design polish — ✅ PASS (re-run 2026-06-17, emulator-5554) + +Verified after the Listen/Speak + fidelity fixes (RECORD_AUDIO revoked first to test the fresh +grant path): + +| Step | Result | +| --- | --- | +| Study front card (`w1CAm`) | PASSED — word + "Tap card to reveal answer" only; **no Listen/Speak on the front** | +| Reveal back (`aLoMj`) | PASSED — **"Listen"** pill (peach) + **"Speak"** pill (purple); names + colors now match the design (previously both read "Speak") | +| Tap **Speak** → mic permission | PASSED — "Allow Echo to record audio?" dialog appears (the user-reported "permission not requested" bug is fixed) | +| Grant → recognition unavailable | PASSED — Toast "Speech recognition is unavailable on this device" shows instead of silently doing nothing | +| Paste preview (`MJ1SR`) | PASSED — bottom orange **Next** button shown once parsed | +| Publish (`yFOOS`) | PASSED — peach "N cards ready" badge with solid orange check; white card fields; **Listen/Speak option rows have leading icons** (peach headphones / purple mic) | +| Cover image sheet (`OQ2QL`) | PASSED — **Done** is now a pill (disabled-grey until a selection) | + +Speech recognition itself still needs a device/emulator with Google speech for the +Correct/Wrong outcome. From 187d2759d7af0d1c0aaf57e9ddd9abede41b35d9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 17 Jun 2026 19:29:12 -0300 Subject: [PATCH 14/20] design: fix label --- design/main/phone-echo.pen | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/main/phone-echo.pen b/design/main/phone-echo.pen index 7fac373..bb1dd7f 100644 --- a/design/main/phone-echo.pen +++ b/design/main/phone-echo.pen @@ -11674,7 +11674,7 @@ "id": "hvd7M", "name": "sheetTitle", "fill": "$foreground-primary", - "content": "Cover image", + "content": "Back image", "fontFamily": "Funnel Sans", "fontSize": 20, "fontWeight": "800" From 066f199f1b27d7380452fdf765e787b2701bd2dc Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 17 Jun 2026 19:39:59 -0300 Subject: [PATCH 15/20] fix(publish): replace cover-change emoji with a Material image icon Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jvsena42/echo/ui/importflow/PublishDeckScreen.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt index 4bba097..e332a69 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Mic import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -264,10 +265,15 @@ private fun PublishDeckScreen( .border(1.dp, colors.borderSubtle, RoundedCornerShape(8.dp)) .clickable { showCoverSheet = true } .padding(horizontal = 10.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text("🖼️", fontSize = 14.sp) + Icon( + imageVector = Icons.Default.Image, + contentDescription = null, + tint = colors.accentPrimary, + modifier = Modifier.size(16.dp), + ) Text( stringResource(R.string.publish_cover_change), fontSize = 13.sp, From c81d65b2ec6c260dfd003b2762ea2c0f4b1c9357 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 17 Jun 2026 20:00:31 -0300 Subject: [PATCH 16/20] feat(import): attach front/back card images in the paste/triage creation flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The triage card editor only had text fields, so the primary create flow could not set per-card images (design vU2cv/cEXuT). Add a DraftCardImage model and per-row image storage on ImportRepository, a front+back image picker (Unsplash or gallery) to the triage editor, and resolve/upload those images when building cards at publish — mirroring the existing cover-image path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/importflow/TriageEditCardScreen.kt | 120 +++++++++++++++++- .../src/androidMain/res/values/strings.xml | 3 + .../echo/data/repository/Repositories.kt | 7 + .../repository/impl/ImportRepositoryImpl.kt | 15 +++ .../jvsena42/echo/domain/model/Import.kt | 10 ++ .../importflow/PublishDeckViewModel.kt | 44 +++++-- .../jvsena42/echo/testing/FakeRepositories.kt | 9 ++ 7 files changed, 192 insertions(+), 16 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageEditCardScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageEditCardScreen.kt index 4ab14f1..5308cbc 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageEditCardScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageEditCardScreen.kt @@ -1,15 +1,23 @@ package com.github.jvsena42.echo.ui.importflow +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Image import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -27,16 +35,23 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +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.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight 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.data.repository.ImportRepository +import com.github.jvsena42.echo.domain.model.DraftCardImage import com.github.jvsena42.echo.domain.model.frontBackOf +import com.github.jvsena42.echo.ui.components.ImagePickerSheet +import com.github.jvsena42.echo.ui.components.ImageSelection import com.github.jvsena42.echo.ui.theme.EchoTheme import org.koin.compose.koinInject @@ -55,12 +70,24 @@ fun TriageEditCardRoute( } var front by remember(rowIndex) { mutableStateOf(initial.first) } var back by remember(rowIndex) { mutableStateOf(initial.second) } + var frontImage by remember(rowIndex) { mutableStateOf(importRepository.rowImage(rowIndex, isFront = true)) } + var backImage by remember(rowIndex) { mutableStateOf(importRepository.rowImage(rowIndex, isFront = false)) } TriageEditCardScreen( front = front, back = back, + frontImage = frontImage, + backImage = backImage, onFrontChange = { front = it }, onBackChange = { back = it }, + onFrontImageSelected = { img -> + frontImage = img + importRepository.setRowImage(rowIndex, isFront = true, image = img) + }, + onBackImageSelected = { img -> + backImage = img + importRepository.setRowImage(rowIndex, isFront = false, image = img) + }, onCancel = currentBack, onSave = { importRepository.updateRow(rowIndex, front, back) @@ -74,12 +101,18 @@ fun TriageEditCardRoute( private fun TriageEditCardScreen( front: String, back: String, + frontImage: DraftCardImage?, + backImage: DraftCardImage?, onFrontChange: (String) -> Unit, onBackChange: (String) -> Unit, + onFrontImageSelected: (DraftCardImage?) -> Unit, + onBackImageSelected: (DraftCardImage?) -> Unit, onCancel: () -> Unit, onSave: () -> Unit, ) { val colors = EchoTheme.colors + // Which side's image picker sheet is open (true = front, false = back, null = none). + var pickerSide by remember { mutableStateOf(null) } Scaffold( containerColor = colors.surfacePrimary, @@ -132,6 +165,9 @@ private fun TriageEditCardScreen( placeholder = stringResource(R.string.edit_card_front_placeholder), textStyle = TextStyle(fontSize = 20.sp, fontWeight = FontWeight.Bold), tag = "triage_edit_front", + image = frontImage, + onPickImage = { pickerSide = true }, + onRemoveImage = { onFrontImageSelected(null) }, ) FieldSection( label = stringResource(R.string.triage_back_label), @@ -140,9 +176,28 @@ private fun TriageEditCardScreen( placeholder = stringResource(R.string.edit_card_back_placeholder), textStyle = TextStyle(fontSize = 16.sp), tag = "triage_edit_back", + image = backImage, + onPickImage = { pickerSide = false }, + onRemoveImage = { onBackImageSelected(null) }, ) } } + + pickerSide?.let { isFront -> + ImagePickerSheet( + title = stringResource(if (isFront) R.string.image_sheet_front_title else R.string.image_sheet_back_title), + subtitle = null, + onDismiss = { pickerSide = null }, + onSelected = { selection -> + val img = when (selection) { + is ImageSelection.Web -> DraftCardImage(url = selection.url) + is ImageSelection.Gallery -> DraftCardImage(bytes = selection.bytes, mime = selection.mime) + } + if (isFront) onFrontImageSelected(img) else onBackImageSelected(img) + pickerSide = null + }, + ) + } } @OptIn(ExperimentalMaterial3Api::class) @@ -154,16 +209,39 @@ private fun FieldSection( placeholder: String, textStyle: TextStyle, tag: String, + image: DraftCardImage?, + onPickImage: () -> Unit, + onRemoveImage: () -> Unit, ) { val colors = EchoTheme.colors Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = label, - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 0.8.sp, - color = colors.foregroundMuted, - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + color = colors.foregroundMuted, + ) + TextButton( + onClick = onPickImage, + modifier = Modifier.testTag("${tag}_image"), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), + colors = ButtonDefaults.textButtonColors(contentColor = colors.accentPrimary), + ) { + Icon(Icons.Default.Image, null, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(4.dp)) + Text( + text = stringResource(if (image != null) R.string.image_sheet_change else R.string.edit_card_add_image), + fontSize = 12.sp, + fontWeight = FontWeight.W600, + ) + } + } OutlinedTextField( value = value, onValueChange = onValueChange, @@ -179,5 +257,33 @@ private fun FieldSection( cursorColor = colors.accentPrimary, ), ) + image?.let { img -> + Row( + modifier = Modifier + .testTag("${tag}_image_chip") + .clip(RoundedCornerShape(10.dp)) + .background(colors.surfaceSecondary) + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AsyncImage( + model = img.url ?: img.bytes, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + .border(1.dp, colors.borderSubtle, RoundedCornerShape(8.dp)), + ) + Spacer(Modifier.weight(1f)) + TextButton( + onClick = onRemoveImage, + colors = ButtonDefaults.textButtonColors(contentColor = colors.srsAgain), + ) { + Text(stringResource(R.string.image_sheet_remove), fontSize = 12.sp) + } + } + } } } diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml index b9fa664..7cab507 100644 --- a/composeApp/src/androidMain/res/values/strings.xml +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -200,6 +200,9 @@ Front image Choose an image for the front of the card + Back image + Choose an image for the back of the card + Change image Done Search images… From gallery diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/Repositories.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/Repositories.kt index 27e30f3..a7ad3c3 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/Repositories.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/Repositories.kt @@ -2,6 +2,7 @@ package com.github.jvsena42.echo.data.repository import com.github.jvsena42.echo.domain.model.Card import com.github.jvsena42.echo.domain.model.Deck +import com.github.jvsena42.echo.domain.model.DraftCardImage import com.github.jvsena42.echo.domain.model.ImportDraft import com.github.jvsena42.echo.domain.model.MediaRef import com.github.jvsena42.echo.domain.model.ParsedRow @@ -88,6 +89,12 @@ interface ImportRepository { /** Override a draft row's front/back text (triage edit). */ fun updateRow(rowIndex: Int, front: String, back: String) + /** Attach/replace an image on a draft card side (triage); `null` clears it. */ + fun setRowImage(rowIndex: Int, isFront: Boolean, image: DraftCardImage?) + + /** The image attached to a draft card side during triage, if any. */ + fun rowImage(rowIndex: Int, isFront: Boolean): DraftCardImage? + /** The rows kept after triage, with any edits applied. */ fun keptRows(): List diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/impl/ImportRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/impl/ImportRepositoryImpl.kt index 4bed9eb..cc0d849 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/impl/ImportRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/impl/ImportRepositoryImpl.kt @@ -3,6 +3,7 @@ package com.github.jvsena42.echo.data.repository.impl import com.github.jvsena42.echo.data.repository.ImportRepository import com.github.jvsena42.echo.domain.model.ColumnMapping import com.github.jvsena42.echo.domain.model.ColumnRole +import com.github.jvsena42.echo.domain.model.DraftCardImage import com.github.jvsena42.echo.domain.model.ImportDraft import com.github.jvsena42.echo.domain.model.ParseFlag import com.github.jvsena42.echo.domain.model.ParsedRow @@ -17,6 +18,8 @@ class ImportRepositoryImpl : ImportRepository { private var draft: ImportDraft? = null private val triageDecisions = mutableMapOf() private val rowEdits = mutableMapOf>() + private val rowFrontImages = mutableMapOf() + private val rowBackImages = mutableMapOf() override fun currentDraft(): ImportDraft? = draft @@ -30,6 +33,14 @@ class ImportRepositoryImpl : ImportRepository { rowEdits[rowIndex] = front.trim() to back.trim() } + override fun setRowImage(rowIndex: Int, isFront: Boolean, image: DraftCardImage?) { + val target = if (isFront) rowFrontImages else rowBackImages + if (image == null) target.remove(rowIndex) else target[rowIndex] = image + } + + override fun rowImage(rowIndex: Int, isFront: Boolean): DraftCardImage? = + if (isFront) rowFrontImages[rowIndex] else rowBackImages[rowIndex] + override fun keptRows(): List { val d = draft ?: return emptyList() val frontIdx = d.frontIndex() @@ -54,6 +65,8 @@ class ImportRepositoryImpl : ImportRepository { // A fresh parse invalidates any prior triage decisions/edits. triageDecisions.clear() rowEdits.clear() + rowFrontImages.clear() + rowBackImages.clear() val text = rawText.replace("\r\n", "\n").replace("\r", "\n").trim() require(text.isNotEmpty()) { "Nothing to import." } require(text.length <= MAX_CHARS) { "Text is too long (max $MAX_CHARS characters)." } @@ -105,6 +118,8 @@ class ImportRepositoryImpl : ImportRepository { draft = null triageDecisions.clear() rowEdits.clear() + rowFrontImages.clear() + rowBackImages.clear() } // --- Separator detection (spec §6 rule order) --- diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/Import.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/Import.kt index 147c6e9..a6952ce 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/Import.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/Import.kt @@ -18,6 +18,16 @@ data class ParsedRow( /** A keep/discard decision made during triage (spec §5.5). Rows default to [Keep]. */ enum class TriageDecision { Keep, Discard } +/** + * An image attached to a draft card side during triage, resolved at publish time: + * a web [url] is stored as a remote ref, while gallery [bytes] are uploaded as a blob. + */ +data class DraftCardImage( + val url: String? = null, + val bytes: ByteArray? = null, + val mime: String? = null, +) + /** Index of the field mapped to the card front (falls back to 0). */ fun ImportDraft.frontIndex(): Int = columnMapping.assignments.indexOfFirst { it == ColumnRole.Front }.takeIf { it >= 0 } ?: 0 diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt index e195fea..e48902c 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt @@ -9,6 +9,7 @@ import com.github.jvsena42.echo.domain.model.CardIndexEntry import com.github.jvsena42.echo.domain.model.CardSide import com.github.jvsena42.echo.domain.model.ColumnRole import com.github.jvsena42.echo.domain.model.Deck +import com.github.jvsena42.echo.domain.model.DraftCardImage import com.github.jvsena42.echo.domain.model.ImportDraft import com.github.jvsena42.echo.domain.model.MediaRef import com.github.jvsena42.echo.domain.model.Tag @@ -196,20 +197,45 @@ class PublishDeckViewModel( } } - /** Maps the kept triage rows to [Card]s using the draft's column roles. */ - private fun buildCards(draft: ImportDraft, deckId: String, now: Long): List { + /** Maps the kept triage rows to [Card]s using the draft's column roles, uploading any + * per-row images attached during triage. */ + private suspend fun buildCards(draft: ImportDraft, deckId: String, now: Long): List { val mapping = draft.columnMapping.assignments val frontIdx = mapping.indexOfFirst { it == ColumnRole.Front }.takeIf { it >= 0 } ?: 0 val backIdx = mapping.indexOfFirst { it == ColumnRole.Back }.takeIf { it >= 0 } ?: 1 - return importRepository.keptRows().map { row -> - Card( - id = generateId(), - deckId = deckId, - updatedAt = now, - front = CardSide(text = row.fields.getOrElse(frontIdx) { "" }.takeIf { it.isNotBlank() }), - back = CardSide(text = row.fields.getOrElse(backIdx) { "" }.takeIf { it.isNotBlank() }), + val cards = mutableListOf() + for (row in importRepository.keptRows()) { + cards.add( + Card( + id = generateId(), + deckId = deckId, + updatedAt = now, + front = CardSide( + text = row.fields.getOrElse(frontIdx) { "" }.takeIf { it.isNotBlank() }, + imageRef = resolveDraftImage(importRepository.rowImage(row.index, isFront = true), deckId), + ), + back = CardSide( + text = row.fields.getOrElse(backIdx) { "" }.takeIf { it.isNotBlank() }, + imageRef = resolveDraftImage(importRepository.rowImage(row.index, isFront = false), deckId), + ), + ), ) } + return cards + } + + /** Resolves a triage [DraftCardImage]: upload gallery bytes, wrap a web URL, else none. */ + private suspend fun resolveDraftImage(image: DraftCardImage?, deckId: String): MediaRef.Image? = when { + image == null -> null + image.bytes != null -> + mediaRepository.putImage(deckId, image.bytes, image.mime ?: "image/jpeg") + .onFailure { Log.e(TAG, "card image upload failed — ${it.message}", it) } + .getOrNull() + + image.url != null -> + MediaRef.Image(path = "", mime = "image/jpeg", sha256 = "", width = null, height = null, url = image.url) + + else -> null } /** Builds the cover [MediaRef.Image]: upload gallery bytes, or wrap a web URL, else none. */ diff --git a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/FakeRepositories.kt b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/FakeRepositories.kt index 752e3b8..6c8d320 100644 --- a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/FakeRepositories.kt +++ b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/testing/FakeRepositories.kt @@ -11,6 +11,7 @@ import com.github.jvsena42.echo.data.repository.TagRepository import com.github.jvsena42.echo.domain.model.Card import com.github.jvsena42.echo.domain.model.ColumnMapping import com.github.jvsena42.echo.domain.model.Deck +import com.github.jvsena42.echo.domain.model.DraftCardImage import com.github.jvsena42.echo.domain.model.ImportDraft import com.github.jvsena42.echo.domain.model.MediaRef import com.github.jvsena42.echo.domain.model.ParsedRow @@ -181,6 +182,14 @@ class FakeImportRepository(var draft: ImportDraft? = null) : ImportRepository { rowEdits[rowIndex] = front to back } + private val rowImages = mutableMapOf, DraftCardImage>() + + override fun setRowImage(rowIndex: Int, isFront: Boolean, image: DraftCardImage?) { + if (image == null) rowImages.remove(rowIndex to isFront) else rowImages[rowIndex to isFront] = image + } + + override fun rowImage(rowIndex: Int, isFront: Boolean): DraftCardImage? = rowImages[rowIndex to isFront] + override fun keptRows(): List = draft?.rows?.filter { triageDecisions[it.index] != TriageDecision.Discard } ?: emptyList() From 0e2f603c38623a7fed3f066781169987560c4a7f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 17 Jun 2026 20:00:41 -0300 Subject: [PATCH 17/20] feat(deck): support front and back card images in Edit Card Edit Card only handled a front image; add back-side image selection/upload and move the image affordance into each card side (front + back), matching the design's per-side image action (vU2cv). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jvsena42/echo/ui/decks/EditCardScreen.kt | 190 ++++++++++-------- .../presentation/decks/EditCardViewModel.kt | 34 +++- 2 files changed, 135 insertions(+), 89 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt index 3aa8e30..9406bc0 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt @@ -114,6 +114,9 @@ fun EditCardRoute( onFrontImageWebSelected = viewModel::onFrontImageWebSelected, onFrontImageGallerySelected = viewModel::onFrontImageGallerySelected, onRemoveFrontImage = viewModel::onRemoveFrontImage, + onBackImageWebSelected = viewModel::onBackImageWebSelected, + onBackImageGallerySelected = viewModel::onBackImageGallerySelected, + onRemoveBackImage = viewModel::onRemoveBackImage, onDeleteCard = viewModel::onDeleteCard, ) } @@ -133,10 +136,14 @@ fun EditCardScreen( onFrontImageWebSelected: (String) -> Unit = {}, onFrontImageGallerySelected: (ByteArray, String) -> Unit = { _, _ -> }, onRemoveFrontImage: () -> Unit = {}, + onBackImageWebSelected: (String) -> Unit = {}, + onBackImageGallerySelected: (ByteArray, String) -> Unit = { _, _ -> }, + onRemoveBackImage: () -> Unit = {}, onDeleteCard: () -> Unit, ) { val colors = EchoTheme.colors - var showImageSheet by remember { mutableStateOf(false) } + // Which side's image picker is open (true = front, false = back, null = none). + var imagePickerSide by remember { mutableStateOf(null) } Scaffold( containerColor = colors.surfacePrimary, @@ -238,6 +245,10 @@ fun EditCardScreen( textStyle = TextStyle(fontSize = 20.sp, fontWeight = FontWeight.W700), error = state.frontError, focusedBorderColor = colors.accentPrimary, + imageModel = state.frontImageRef?.url ?: state.frontPendingBytes, + onPickImage = { imagePickerSide = true }, + onRemoveImage = onRemoveFrontImage, + imageTag = "editcard_front_image", ) // 3. Back section @@ -251,77 +262,27 @@ fun EditCardScreen( textStyle = TextStyle(fontSize = 16.sp), error = state.backError, focusedBorderColor = colors.accentPrimary, + imageModel = state.backImageRef?.url ?: state.backPendingBytes, + onPickImage = { imagePickerSide = false }, + onRemoveImage = onRemoveBackImage, + imageTag = "editcard_back_image", ) - // 4a. Front image preview (when set) - state.frontImageRef?.let { imageRef -> - Row( - modifier = Modifier - .testTag("editcard_front_image_chip") - .clip(RoundedCornerShape(10.dp)) - .background(colors.surfaceSecondary) - .padding(4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - AsyncImage( - model = imageRef.url ?: state.frontPendingBytes, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(8.dp)), - ) - Text(stringResource(R.string.image_sheet_front_title), fontSize = 13.sp, color = colors.foregroundSecondary) - Spacer(Modifier.weight(1f)) - TextButton( - onClick = onRemoveFrontImage, - modifier = Modifier.testTag("editcard_front_image_remove"), - colors = ButtonDefaults.textButtonColors(contentColor = colors.srsAgain), - ) { - Text(stringResource(R.string.image_sheet_remove), fontSize = 12.sp) - } - } - } - - // 4. Media buttons - Row( + // 4. Audio (recording is a future enhancement) + OutlinedButton( + onClick = { /* TODO: audio recorder */ }, modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.foregroundMuted), + border = BorderStroke(1.dp, colors.borderSubtle), ) { - OutlinedButton( - onClick = { showImageSheet = true }, - modifier = Modifier - .weight(1f) - .testTag("editcard_image"), - shape = RoundedCornerShape(14.dp), - colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.foregroundMuted), - border = BorderStroke(1.dp, colors.borderSubtle), - ) { - Icon( - imageVector = Icons.Default.Image, - contentDescription = stringResource(R.string.edit_card_add_image), - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = stringResource(R.string.edit_card_image), fontSize = 14.sp) - } - - OutlinedButton( - onClick = { /* TODO: audio recorder */ }, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(14.dp), - colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.foregroundMuted), - border = BorderStroke(1.dp, colors.borderSubtle), - ) { - Icon( - imageVector = Icons.Default.Mic, - contentDescription = stringResource(R.string.edit_card_add_audio), - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = stringResource(R.string.edit_card_audio), fontSize = 14.sp) - } + Icon( + imageVector = Icons.Default.Mic, + contentDescription = stringResource(R.string.edit_card_add_audio), + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = stringResource(R.string.edit_card_audio), fontSize = 14.sp) } // 5. Tags section @@ -395,17 +356,23 @@ fun EditCardScreen( } } - if (showImageSheet) { + imagePickerSide?.let { isFront -> ImagePickerSheet( - title = stringResource(R.string.image_sheet_front_title), - subtitle = stringResource(R.string.image_sheet_front_subtitle), - onDismiss = { showImageSheet = false }, + title = stringResource(if (isFront) R.string.image_sheet_front_title else R.string.image_sheet_back_title), + subtitle = stringResource(if (isFront) R.string.image_sheet_front_subtitle else R.string.image_sheet_back_subtitle), + onDismiss = { imagePickerSide = null }, onSelected = { selection -> when (selection) { - is ImageSelection.Web -> onFrontImageWebSelected(selection.url) - is ImageSelection.Gallery -> onFrontImageGallerySelected(selection.bytes, selection.mime) + is ImageSelection.Web -> + if (isFront) onFrontImageWebSelected(selection.url) else onBackImageWebSelected(selection.url) + is ImageSelection.Gallery -> + if (isFront) { + onFrontImageGallerySelected(selection.bytes, selection.mime) + } else { + onBackImageGallerySelected(selection.bytes, selection.mime) + } } - showImageSheet = false + imagePickerSide = null }, ) } @@ -423,6 +390,10 @@ private fun CardTextSection( textStyle: TextStyle, error: String?, focusedBorderColor: Color, + imageModel: Any?, + onPickImage: () -> Unit, + onRemoveImage: () -> Unit, + imageTag: String, ) { val colors = EchoTheme.colors Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -438,18 +409,34 @@ private fun CardTextSection( letterSpacing = 0.8.sp, color = colors.foregroundMuted, ) - TextButton( - onClick = onSpeak, - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), - colors = ButtonDefaults.textButtonColors(contentColor = colors.accentPrimary), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.VolumeUp, - contentDescription = speakDescription, - modifier = Modifier.size(14.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(text = stringResource(R.string.edit_card_speak), fontSize = 12.sp, fontWeight = FontWeight.W600) + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton( + onClick = onPickImage, + modifier = Modifier.testTag("${imageTag}_add"), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), + colors = ButtonDefaults.textButtonColors(contentColor = colors.accentPrimary), + ) { + Icon(imageVector = Icons.Default.Image, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(if (imageModel != null) R.string.image_sheet_change else R.string.edit_card_add_image), + fontSize = 12.sp, + fontWeight = FontWeight.W600, + ) + } + TextButton( + onClick = onSpeak, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), + colors = ButtonDefaults.textButtonColors(contentColor = colors.accentPrimary), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.VolumeUp, + contentDescription = speakDescription, + modifier = Modifier.size(14.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = stringResource(R.string.edit_card_speak), fontSize = 12.sp, fontWeight = FontWeight.W600) + } } } @@ -469,6 +456,35 @@ private fun CardTextSection( ), ) + imageModel?.let { model -> + Row( + modifier = Modifier + .testTag("${imageTag}_chip") + .clip(RoundedCornerShape(10.dp)) + .background(colors.surfaceSecondary) + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AsyncImage( + model = model, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(8.dp)), + ) + Spacer(Modifier.weight(1f)) + TextButton( + onClick = onRemoveImage, + modifier = Modifier.testTag("${imageTag}_remove"), + colors = ButtonDefaults.textButtonColors(contentColor = colors.srsAgain), + ) { + Text(stringResource(R.string.image_sheet_remove), fontSize = 12.sp) + } + } + } + error?.let { errorText -> Text(text = errorText, fontSize = 12.sp, color = colors.danger) } diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/EditCardViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/EditCardViewModel.kt index b0af071..693f9c2 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/EditCardViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/EditCardViewModel.kt @@ -65,6 +65,7 @@ class EditCardViewModel( frontText = card.front.text ?: "", backText = card.back.text ?: "", frontImageRef = card.front.imageRef, + backImageRef = card.back.imageRef, hasImage = card.front.imageRef != null || card.back.imageRef != null, hasAudio = card.front.audioRef != null || card.back.audioRef != null, ) @@ -85,7 +86,22 @@ class EditCardViewModel( } fun onRemoveFrontImage() { - _state.update { it.copy(frontImageRef = null, frontPendingBytes = null, frontPendingMime = null, hasImage = false) } + _state.update { it.copy(frontImageRef = null, frontPendingBytes = null, frontPendingMime = null) } + } + + /** A web (Unsplash) image was chosen for the card back — saved by URL. */ + fun onBackImageWebSelected(url: String) { + val ref = MediaRef.Image(path = "", mime = "image/jpeg", sha256 = "", width = null, height = null, url = url) + _state.update { it.copy(backImageRef = ref, backPendingBytes = null, backPendingMime = null) } + } + + /** A gallery image was chosen for the card back — already compressed; uploaded on save. */ + fun onBackImageGallerySelected(bytes: ByteArray, mime: String) { + _state.update { it.copy(backImageRef = null, backPendingBytes = bytes, backPendingMime = mime) } + } + + fun onRemoveBackImage() { + _state.update { it.copy(backImageRef = null, backPendingBytes = null, backPendingMime = null) } } fun onFrontTextChanged(text: String) { @@ -140,6 +156,7 @@ class EditCardViewModel( val existingCard = cardRepository.get(deckId, cardId) val now = epochMillis() val frontImage = resolveFrontImage(s) + val backImage = resolveBackImage(s) val card = Card( id = cardId, deckId = deckId, @@ -151,7 +168,7 @@ class EditCardViewModel( ), back = CardSide( text = s.backText.ifBlank { null }, - imageRef = existingCard?.back?.imageRef, + imageRef = backImage, audioRef = existingCard?.back?.audioRef, ), ) @@ -194,6 +211,16 @@ class EditCardViewModel( else -> s.frontImageRef } + /** Upload a pending gallery image, or keep the chosen web/existing ref (card back). */ + private suspend fun resolveBackImage(s: EditCardUiState): MediaRef.Image? = when { + s.backPendingBytes != null -> + mediaRepository.putImage(deckId, s.backPendingBytes, s.backPendingMime ?: "image/jpeg") + .onFailure { Log.e(TAG, "back image upload failed — ${it.message}", it) } + .getOrNull() + + else -> s.backImageRef + } + fun onDispose() { loadJob?.cancel() saveJob?.cancel() @@ -219,6 +246,9 @@ data class EditCardUiState( val frontImageRef: MediaRef.Image? = null, val frontPendingBytes: ByteArray? = null, val frontPendingMime: String? = null, + val backImageRef: MediaRef.Image? = null, + val backPendingBytes: ByteArray? = null, + val backPendingMime: String? = null, val hasImage: Boolean = false, val hasAudio: Boolean = false, val isSaving: Boolean = false, From 3d9c0964f7e2736ead842b0aac0c97c4312402cf Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 17 Jun 2026 20:14:20 -0300 Subject: [PATCH 18/20] refactor(deck): match Edit Card image control to design (card + Add-image pill) Replace the bordered text field + plain image button + full-width remove row with a shared CardSideEditor: a white rounded card (design vU2cv) holding the input, with the side label and a peach 'Add image' pill in the header. When an image is set the pill becomes a thumbnail preview (tap to change) with a circular remove button. Used by both the post-publish Edit Card and the paste/triage editor so they stay consistent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../echo/ui/components/CardSideEditor.kt | 196 ++++++++++++++++++ .../jvsena42/echo/ui/decks/EditCardScreen.kt | 144 +------------ .../ui/importflow/TriageEditCardScreen.kt | 119 +---------- .../src/androidMain/res/values/strings.xml | 1 - 4 files changed, 216 insertions(+), 244 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/CardSideEditor.kt diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/CardSideEditor.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/CardSideEditor.kt new file mode 100644 index 0000000..d2caea9 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/CardSideEditor.kt @@ -0,0 +1,196 @@ +package com.github.jvsena42.echo.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Image +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +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.draw.shadow +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +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 + +/** + * One side of a card in the Edit-card screens (design `vU2cv`): a white rounded card holding the + * text input, with the side label and an "Add image" pill in the header. When an image is set the + * pill is replaced by a thumbnail preview (tap to change) plus a circular remove button. + * + * Shared by the post-publish editor ([com.github.jvsena42.echo.ui.decks.EditCardScreen]) and the + * paste/triage editor so both match the design. [onSpeak] adds a TTS icon when non-null. + */ +@Composable +fun CardSideEditor( + label: String, + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + textStyle: TextStyle, + imageModel: Any?, + onPickImage: () -> Unit, + onRemoveImage: () -> Unit, + imageTag: String, + fieldTag: String, + modifier: Modifier = Modifier, + error: String? = null, + onSpeak: (() -> Unit)? = null, + speakDescription: String? = null, +) { + val colors = EchoTheme.colors + Column( + modifier = modifier + .fillMaxWidth() + .shadow(6.dp, RoundedCornerShape(20.dp)) + .clip(RoundedCornerShape(20.dp)) + .background(colors.surfaceCard) + .border(1.5.dp, colors.borderSubtle, RoundedCornerShape(20.dp)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + fontSize = 10.sp, + fontWeight = FontWeight.W700, + letterSpacing = 1.sp, + color = colors.foregroundMuted, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (onSpeak != null) { + Icon( + imageVector = Icons.AutoMirrored.Filled.VolumeUp, + contentDescription = speakDescription, + tint = colors.accentPrimary, + modifier = Modifier + .clip(CircleShape) + .clickable(onClick = onSpeak) + .padding(4.dp) + .size(18.dp), + ) + } + if (imageModel == null) { + AddImagePill(onClick = onPickImage, tag = "${imageTag}_add") + } else { + ImagePreview( + model = imageModel, + onChange = onPickImage, + onRemove = onRemoveImage, + tag = imageTag, + ) + } + } + } + + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .testTag(fieldTag), + textStyle = textStyle.copy(color = colors.foregroundPrimary), + cursorBrush = SolidColor(colors.accentPrimary), + decorationBox = { inner -> + Box { + if (value.isEmpty()) { + Text(text = placeholder, style = textStyle, color = colors.foregroundMuted) + } + inner() + } + }, + ) + + error?.let { Text(text = it, fontSize = 12.sp, color = colors.danger) } + } +} + +@Composable +private fun AddImagePill(onClick: () -> Unit, tag: String) { + val colors = EchoTheme.colors + Row( + modifier = Modifier + .testTag(tag) + .clip(RoundedCornerShape(50)) + .background(colors.accentPrimarySoft) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Default.Image, null, tint = colors.accentPrimary, modifier = Modifier.size(12.dp)) + Text( + text = stringResource(R.string.edit_card_add_image), + fontSize = 11.sp, + fontWeight = FontWeight.W700, + color = colors.accentPrimary, + ) + } +} + +@Composable +private fun ImagePreview(model: Any, onChange: () -> Unit, onRemove: () -> Unit, tag: String) { + val colors = EchoTheme.colors + Row( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(colors.surfaceSecondary) + .padding(PaddingValues(start = 4.dp, top = 4.dp, bottom = 4.dp, end = 8.dp)), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = model, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .testTag("${tag}_chip") + .size(36.dp) + .clip(RoundedCornerShape(8.dp)) + .border(1.5.dp, colors.accentPrimary, RoundedCornerShape(8.dp)) + .clickable(onClick = onChange), + ) + Box( + modifier = Modifier + .testTag("${tag}_remove") + .size(24.dp) + .clip(CircleShape) + .background(colors.surfaceCard) + .clickable(onClick = onRemove), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Default.Close, null, tint = colors.foregroundMuted, modifier = Modifier.size(14.dp)) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt index 9406bc0..438340a 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -19,9 +18,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Layers import androidx.compose.material.icons.filled.Mic import androidx.compose.material3.AssistChip @@ -33,8 +30,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -48,12 +43,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue -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.layout.ContentScale -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -61,12 +52,12 @@ import androidx.compose.ui.tooling.preview.Preview 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.platform.Speaker import com.github.jvsena42.echo.presentation.decks.EditCardEffect import com.github.jvsena42.echo.presentation.decks.EditCardUiState import com.github.jvsena42.echo.presentation.decks.EditCardViewModel +import com.github.jvsena42.echo.ui.components.CardSideEditor import com.github.jvsena42.echo.ui.components.ImagePickerSheet import com.github.jvsena42.echo.ui.components.ImageSelection import com.github.jvsena42.echo.ui.components.TagChip @@ -235,37 +226,37 @@ fun EditCardScreen( ) // 2. Front section - CardTextSection( + CardSideEditor( label = stringResource(R.string.edit_card_label_front), - speakDescription = stringResource(R.string.edit_card_speak_front), - onSpeak = onSpeakFront, value = state.frontText, onValueChange = onFrontTextChanged, placeholder = stringResource(R.string.edit_card_front_placeholder), textStyle = TextStyle(fontSize = 20.sp, fontWeight = FontWeight.W700), - error = state.frontError, - focusedBorderColor = colors.accentPrimary, imageModel = state.frontImageRef?.url ?: state.frontPendingBytes, onPickImage = { imagePickerSide = true }, onRemoveImage = onRemoveFrontImage, imageTag = "editcard_front_image", + fieldTag = "editcard_front", + error = state.frontError, + onSpeak = onSpeakFront, + speakDescription = stringResource(R.string.edit_card_speak_front), ) // 3. Back section - CardTextSection( + CardSideEditor( label = stringResource(R.string.edit_card_label_back), - speakDescription = stringResource(R.string.edit_card_speak_back), - onSpeak = onSpeakBack, value = state.backText, onValueChange = onBackTextChanged, placeholder = stringResource(R.string.edit_card_back_placeholder), textStyle = TextStyle(fontSize = 16.sp), - error = state.backError, - focusedBorderColor = colors.accentPrimary, imageModel = state.backImageRef?.url ?: state.backPendingBytes, onPickImage = { imagePickerSide = false }, onRemoveImage = onRemoveBackImage, imageTag = "editcard_back_image", + fieldTag = "editcard_back", + error = state.backError, + onSpeak = onSpeakBack, + speakDescription = stringResource(R.string.edit_card_speak_back), ) // 4. Audio (recording is a future enhancement) @@ -378,119 +369,6 @@ fun EditCardScreen( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun CardTextSection( - label: String, - speakDescription: String, - onSpeak: () -> Unit, - value: String, - onValueChange: (String) -> Unit, - placeholder: String, - textStyle: TextStyle, - error: String?, - focusedBorderColor: Color, - imageModel: Any?, - onPickImage: () -> Unit, - onRemoveImage: () -> Unit, - imageTag: String, -) { - val colors = EchoTheme.colors - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - fontSize = 10.sp, - fontWeight = FontWeight.W700, - letterSpacing = 0.8.sp, - color = colors.foregroundMuted, - ) - Row(verticalAlignment = Alignment.CenterVertically) { - TextButton( - onClick = onPickImage, - modifier = Modifier.testTag("${imageTag}_add"), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), - colors = ButtonDefaults.textButtonColors(contentColor = colors.accentPrimary), - ) { - Icon(imageVector = Icons.Default.Image, contentDescription = null, modifier = Modifier.size(14.dp)) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = stringResource(if (imageModel != null) R.string.image_sheet_change else R.string.edit_card_add_image), - fontSize = 12.sp, - fontWeight = FontWeight.W600, - ) - } - TextButton( - onClick = onSpeak, - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), - colors = ButtonDefaults.textButtonColors(contentColor = colors.accentPrimary), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.VolumeUp, - contentDescription = speakDescription, - modifier = Modifier.size(14.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(text = stringResource(R.string.edit_card_speak), fontSize = 12.sp, fontWeight = FontWeight.W600) - } - } - } - - OutlinedTextField( - value = value, - onValueChange = onValueChange, - modifier = Modifier.fillMaxWidth(), - textStyle = textStyle.copy(color = colors.foregroundPrimary), - placeholder = { Text(text = placeholder, style = textStyle, color = colors.foregroundMuted) }, - isError = error != null, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = focusedBorderColor, - unfocusedBorderColor = colors.borderSubtle, - cursorColor = colors.accentPrimary, - errorBorderColor = colors.danger, - ), - ) - - imageModel?.let { model -> - Row( - modifier = Modifier - .testTag("${imageTag}_chip") - .clip(RoundedCornerShape(10.dp)) - .background(colors.surfaceSecondary) - .padding(4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - AsyncImage( - model = model, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(8.dp)), - ) - Spacer(Modifier.weight(1f)) - TextButton( - onClick = onRemoveImage, - modifier = Modifier.testTag("${imageTag}_remove"), - colors = ButtonDefaults.textButtonColors(contentColor = colors.srsAgain), - ) { - Text(stringResource(R.string.image_sheet_remove), fontSize = 12.sp) - } - } - } - - error?.let { errorText -> - Text(text = errorText, fontSize = 12.sp, color = colors.danger) - } - } -} - @Preview @Composable private fun EditCardScreenPreview() { diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageEditCardScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageEditCardScreen.kt index 5308cbc..257e645 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageEditCardScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageEditCardScreen.kt @@ -1,29 +1,17 @@ package com.github.jvsena42.echo.ui.importflow -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Image import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -35,21 +23,18 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue -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.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight 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.data.repository.ImportRepository import com.github.jvsena42.echo.domain.model.DraftCardImage import com.github.jvsena42.echo.domain.model.frontBackOf +import com.github.jvsena42.echo.ui.components.CardSideEditor import com.github.jvsena42.echo.ui.components.ImagePickerSheet import com.github.jvsena42.echo.ui.components.ImageSelection import com.github.jvsena42.echo.ui.theme.EchoTheme @@ -158,27 +143,29 @@ private fun TriageEditCardScreen( .padding(horizontal = 20.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(18.dp), ) { - FieldSection( + CardSideEditor( label = stringResource(R.string.triage_front_label), value = front, onValueChange = onFrontChange, placeholder = stringResource(R.string.edit_card_front_placeholder), textStyle = TextStyle(fontSize = 20.sp, fontWeight = FontWeight.Bold), - tag = "triage_edit_front", - image = frontImage, + imageModel = frontImage?.url ?: frontImage?.bytes, onPickImage = { pickerSide = true }, onRemoveImage = { onFrontImageSelected(null) }, + imageTag = "triage_edit_front_image", + fieldTag = "triage_edit_front", ) - FieldSection( + CardSideEditor( label = stringResource(R.string.triage_back_label), value = back, onValueChange = onBackChange, placeholder = stringResource(R.string.edit_card_back_placeholder), textStyle = TextStyle(fontSize = 16.sp), - tag = "triage_edit_back", - image = backImage, + imageModel = backImage?.url ?: backImage?.bytes, onPickImage = { pickerSide = false }, onRemoveImage = { onBackImageSelected(null) }, + imageTag = "triage_edit_back_image", + fieldTag = "triage_edit_back", ) } } @@ -199,91 +186,3 @@ private fun TriageEditCardScreen( ) } } - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun FieldSection( - label: String, - value: String, - onValueChange: (String) -> Unit, - placeholder: String, - textStyle: TextStyle, - tag: String, - image: DraftCardImage?, - onPickImage: () -> Unit, - onRemoveImage: () -> Unit, -) { - val colors = EchoTheme.colors - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 0.8.sp, - color = colors.foregroundMuted, - ) - TextButton( - onClick = onPickImage, - modifier = Modifier.testTag("${tag}_image"), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), - colors = ButtonDefaults.textButtonColors(contentColor = colors.accentPrimary), - ) { - Icon(Icons.Default.Image, null, modifier = Modifier.size(14.dp)) - Spacer(Modifier.width(4.dp)) - Text( - text = stringResource(if (image != null) R.string.image_sheet_change else R.string.edit_card_add_image), - fontSize = 12.sp, - fontWeight = FontWeight.W600, - ) - } - } - OutlinedTextField( - value = value, - onValueChange = onValueChange, - modifier = Modifier - .fillMaxWidth() - .testTag(tag), - textStyle = textStyle.copy(color = colors.foregroundPrimary), - placeholder = { Text(text = placeholder, style = textStyle, color = colors.foregroundMuted) }, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = colors.accentPrimary, - unfocusedBorderColor = colors.borderSubtle, - cursorColor = colors.accentPrimary, - ), - ) - image?.let { img -> - Row( - modifier = Modifier - .testTag("${tag}_image_chip") - .clip(RoundedCornerShape(10.dp)) - .background(colors.surfaceSecondary) - .padding(4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - AsyncImage( - model = img.url ?: img.bytes, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(40.dp) - .clip(RoundedCornerShape(8.dp)) - .border(1.dp, colors.borderSubtle, RoundedCornerShape(8.dp)), - ) - Spacer(Modifier.weight(1f)) - TextButton( - onClick = onRemoveImage, - colors = ButtonDefaults.textButtonColors(contentColor = colors.srsAgain), - ) { - Text(stringResource(R.string.image_sheet_remove), fontSize = 12.sp) - } - } - } - } -} diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml index 7cab507..925ca1e 100644 --- a/composeApp/src/androidMain/res/values/strings.xml +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -202,7 +202,6 @@ Choose an image for the front of the card Back image Choose an image for the back of the card - Change image Done Search images… From gallery From c8eac7890b6b4e8d09f65cebd86399b0e07cda9f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 17 Jun 2026 20:48:26 -0300 Subject: [PATCH 19/20] style(deck): reduce Edit Card side-card elevation (6dp -> 2dp) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/github/jvsena42/echo/ui/components/CardSideEditor.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/CardSideEditor.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/CardSideEditor.kt index d2caea9..c047bff 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/CardSideEditor.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/CardSideEditor.kt @@ -66,7 +66,7 @@ fun CardSideEditor( Column( modifier = modifier .fillMaxWidth() - .shadow(6.dp, RoundedCornerShape(20.dp)) + .shadow(2.dp, RoundedCornerShape(20.dp)) .clip(RoundedCornerShape(20.dp)) .background(colors.surfaceCard) .border(1.5.dp, colors.borderSubtle, RoundedCornerShape(20.dp)) From ab213198793ad849770b38cb2769cfde55881a81 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 17 Jun 2026 20:48:26 -0300 Subject: [PATCH 20/20] style(deck): center top-bar titles across the deck-creation flow Use CenterAlignedTopAppBar for Paste, Review cards, Edit card, and the deck editor so titles are centered (Publish already centers via its custom header). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/github/jvsena42/echo/ui/decks/DeckEditorScreen.kt | 4 ++-- .../com/github/jvsena42/echo/ui/decks/EditCardScreen.kt | 4 ++-- .../com/github/jvsena42/echo/ui/importflow/PasteScreen.kt | 4 ++-- .../jvsena42/echo/ui/importflow/TriageEditCardScreen.kt | 4 ++-- .../com/github/jvsena42/echo/ui/importflow/TriageScreen.kt | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckEditorScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckEditorScreen.kt index e419bd2..ec7c74d 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckEditorScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckEditorScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.material.icons.filled.Mic import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledIconButton @@ -39,7 +40,6 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -126,7 +126,7 @@ fun DeckEditorScreen( Scaffold( containerColor = colors.surfacePrimary, topBar = { - TopAppBar( + CenterAlignedTopAppBar( title = { Text( text = if (state.isNew) { diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt index 438340a..357e543 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton @@ -33,7 +34,6 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -139,7 +139,7 @@ fun EditCardScreen( Scaffold( containerColor = colors.surfacePrimary, topBar = { - TopAppBar( + CenterAlignedTopAppBar( title = { Text( text = stringResource(R.string.edit_card_title), diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PasteScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PasteScreen.kt index 1f0a457..b5b7e45 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PasteScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PasteScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField @@ -32,7 +33,6 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -104,7 +104,7 @@ private fun PasteScreen( Scaffold( containerColor = colors.surfacePrimary, topBar = { - TopAppBar( + CenterAlignedTopAppBar( title = { Text( text = stringResource(R.string.paste_title), diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageEditCardScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageEditCardScreen.kt index 257e645..d1eaab1 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageEditCardScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageEditCardScreen.kt @@ -9,13 +9,13 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -102,7 +102,7 @@ private fun TriageEditCardScreen( Scaffold( containerColor = colors.surfacePrimary, topBar = { - TopAppBar( + CenterAlignedTopAppBar( title = { Text( text = stringResource(R.string.edit_card_title), diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageScreen.kt index ee6e074..af47399 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -27,7 +28,6 @@ import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -108,7 +108,7 @@ private fun TriageScreen( Scaffold( containerColor = colors.surfaceSecondary, topBar = { - TopAppBar( + CenterAlignedTopAppBar( title = { Text( text = stringResource(R.string.triage_title),