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/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 @@
+
+
+
+
()
+ 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/CardSideEditor.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/CardSideEditor.kt
new file mode 100644
index 0000000..c047bff
--- /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(2.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/components/ImagePickerSheet.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImagePickerSheet.kt
new file mode 100644
index 0000000..42513f7
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImagePickerSheet.kt
@@ -0,0 +1,254 @@
+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.PaddingValues
+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.Button
+import androidx.compose.material3.ButtonDefaults
+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.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
+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
+
+/**
+ * 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, ExperimentalComposeUiApi::class)
+@Suppress("CyclomaticComplexMethod", "LongMethod") // Single sheet; grid/gallery/states read top-to-bottom.
+@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()
+ .semantics { testTagsAsResourceId = true }
+ .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)
+ 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 {
+ 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/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/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 9f29ee0..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
@@ -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,35 +18,33 @@ 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
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
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
-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.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
-import androidx.compose.ui.Alignment
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
@@ -60,6 +57,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 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
import com.github.jvsena42.echo.ui.theme.EchoTheme
import kotlinx.coroutines.flow.collectLatest
@@ -102,6 +102,12 @@ fun EditCardRoute(
onSpeakBack = viewModel::onSpeakBack,
onRemoveTag = viewModel::onRemoveTag,
onAddTag = viewModel::onAddTag,
+ onFrontImageWebSelected = viewModel::onFrontImageWebSelected,
+ onFrontImageGallerySelected = viewModel::onFrontImageGallerySelected,
+ onRemoveFrontImage = viewModel::onRemoveFrontImage,
+ onBackImageWebSelected = viewModel::onBackImageWebSelected,
+ onBackImageGallerySelected = viewModel::onBackImageGallerySelected,
+ onRemoveBackImage = viewModel::onRemoveBackImage,
onDeleteCard = viewModel::onDeleteCard,
)
}
@@ -118,14 +124,22 @@ fun EditCardScreen(
onSpeakBack: () -> Unit,
onRemoveTag: (String) -> Unit,
onAddTag: (String) -> Unit,
+ onFrontImageWebSelected: (String) -> Unit = {},
+ onFrontImageGallerySelected: (ByteArray, String) -> Unit = { _, _ -> },
+ onRemoveFrontImage: () -> Unit = {},
+ onBackImageWebSelected: (String) -> Unit = {},
+ onBackImageGallerySelected: (ByteArray, String) -> Unit = { _, _ -> },
+ onRemoveBackImage: () -> Unit = {},
onDeleteCard: () -> Unit,
) {
val colors = EchoTheme.colors
+ // Which side's image picker is open (true = front, false = back, null = none).
+ var imagePickerSide by remember { mutableStateOf(null) }
Scaffold(
containerColor = colors.surfacePrimary,
topBar = {
- TopAppBar(
+ CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(R.string.edit_card_title),
@@ -151,11 +165,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)
}
}
},
@@ -206,67 +226,54 @@ 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),
+ imageModel = state.frontImageRef?.url ?: state.frontPendingBytes,
+ onPickImage = { imagePickerSide = true },
+ onRemoveImage = onRemoveFrontImage,
+ imageTag = "editcard_front_image",
+ fieldTag = "editcard_front",
error = state.frontError,
- focusedBorderColor = colors.accentPrimary,
+ 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),
+ imageModel = state.backImageRef?.url ?: state.backPendingBytes,
+ onPickImage = { imagePickerSide = false },
+ onRemoveImage = onRemoveBackImage,
+ imageTag = "editcard_back_image",
+ fieldTag = "editcard_back",
error = state.backError,
- focusedBorderColor = colors.accentPrimary,
+ onSpeak = onSpeakBack,
+ speakDescription = stringResource(R.string.edit_card_speak_back),
)
- // 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 = { /* TODO: image picker */ },
- modifier = Modifier.weight(1f),
- 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
@@ -339,69 +346,26 @@ 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,
-) {
- 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,
- )
- 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,
- ),
+ imagePickerSide?.let { isFront ->
+ ImagePickerSheet(
+ 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 ->
+ 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)
+ }
+ }
+ imagePickerSide = null
+ },
)
-
- error?.let { errorText ->
- Text(text = errorText, fontSize = 12.sp, color = colors.danger)
- }
}
}
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..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
@@ -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
@@ -103,7 +104,7 @@ private fun PasteScreen(
Scaffold(
containerColor = colors.surfacePrimary,
topBar = {
- TopAppBar(
+ CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(R.string.paste_title),
@@ -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(),
+ )
+ }
}
}
}
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..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
@@ -26,11 +26,16 @@ 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.Image
+import androidx.compose.material.icons.filled.Mic
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
@@ -44,7 +49,10 @@ 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
import androidx.compose.ui.text.TextStyle
@@ -53,11 +61,14 @@ 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 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 +101,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,
@@ -98,6 +113,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,
@@ -105,6 +121,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 +132,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) {
@@ -163,24 +184,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,
+ )
+ }
}
}
@@ -197,10 +238,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,13 +260,20 @@ 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),
+ 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,
@@ -245,7 +300,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),
@@ -284,7 +339,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),
@@ -340,6 +395,37 @@ 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",
+ icon = Icons.AutoMirrored.Filled.VolumeUp,
+ iconColor = colors.accentPrimary,
+ iconBackground = colors.accentPrimarySoft,
+ )
+ OptionToggleRow(
+ title = stringResource(R.string.publish_speak_title),
+ subtitle = stringResource(R.string.publish_speak_subtitle),
+ checked = state.speakEnabled,
+ onToggle = onToggleSpeak,
+ testTag = "publish_speak_toggle",
+ icon = Icons.Default.Mic,
+ iconColor = colors.accentSecondary,
+ iconBackground = colors.accentSecondarySoft,
+ )
+ }
+
// Public on Pubky notice
Row(
modifier = Modifier
@@ -518,6 +604,69 @@ 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,
+ icon: ImageVector,
+ iconColor: Color,
+ iconBackground: Color,
+) {
+ 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,
+ ) {
+ 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)
+ }
+ Switch(
+ checked = checked,
+ onCheckedChange = { onToggle() },
+ modifier = Modifier.testTag(testTag),
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = colors.foregroundOnAccent,
+ checkedTrackColor = colors.accentPrimary,
+ uncheckedTrackColor = colors.borderSubtle,
+ ),
+ )
+ }
}
@Composable
@@ -619,6 +768,10 @@ private fun PublishDeckScreenPreview() {
onDescriptionChanged = {},
onAddTag = {},
onRemoveTag = {},
+ onToggleListen = {},
+ onToggleSpeak = {},
+ onCoverWebSelected = {},
+ onCoverGallerySelected = { _, _ -> },
onPublishClick = {},
onUndoPublish = {},
onDonePublish = {},
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..d1eaab1
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageEditCardScreen.kt
@@ -0,0 +1,188 @@
+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.padding
+import androidx.compose.foundation.rememberScrollState
+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.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.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
+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) }
+ 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)
+ currentBack()
+ },
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+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,
+ topBar = {
+ CenterAlignedTopAppBar(
+ 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),
+ ) {
+ 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),
+ imageModel = frontImage?.url ?: frontImage?.bytes,
+ onPickImage = { pickerSide = true },
+ onRemoveImage = { onFrontImageSelected(null) },
+ imageTag = "triage_edit_front_image",
+ fieldTag = "triage_edit_front",
+ )
+ 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),
+ imageModel = backImage?.url ?: backImage?.bytes,
+ onPickImage = { pickerSide = false },
+ onRemoveImage = { onBackImageSelected(null) },
+ imageTag = "triage_edit_back_image",
+ fieldTag = "triage_edit_back",
+ )
+ }
+ }
+
+ 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
+ },
+ )
+ }
+}
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..af47399
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageScreen.kt
@@ -0,0 +1,314 @@
+package com.github.jvsena42.echo.ui.importflow
+
+import androidx.compose.foundation.background
+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.CenterAlignedTopAppBar
+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.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 = {
+ CenterAlignedTopAppBar(
+ 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/kotlin/com/github/jvsena42/echo/ui/study/SpeakSheets.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/SpeakSheets.kt
new file mode 100644
index 0000000..7c8b0ca
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/SpeakSheets.kt
@@ -0,0 +1,218 @@
+package com.github.jvsena42.echo.ui.study
+
+import androidx.compose.foundation.background
+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.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.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Mic
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ModalBottomSheet
+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
+import androidx.compose.ui.unit.sp
+import com.github.jvsena42.echo.R
+import com.github.jvsena42.echo.presentation.study.SpeakPhase
+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, ExperimentalComposeUiApi::class)
+@Composable
+fun SpeakSheets(
+ phase: SpeakPhase,
+ targetWord: String,
+ onCancel: () -> 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()
+ .semantics { testTagsAsResourceId = true }
+ .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)
+ }
+ // Solid purple mic in a soft halo ring (design `sIqOr`).
+ Box(
+ modifier = Modifier
+ .size(110.dp)
+ .clip(CircleShape)
+ .background(colors.accentSecondary.copy(alpha = 0.15f))
+ .testTag("speak_mic"),
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(80.dp)
+ .clip(CircleShape)
+ .background(colors.accentSecondary),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Default.Mic, null, tint = colors.foregroundOnAccent, 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..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
@@ -1,5 +1,10 @@
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
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
@@ -23,12 +28,14 @@ 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
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 +50,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 +58,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 +68,25 @@ 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.MediaRef
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.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
+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 +97,77 @@ 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 {
+ Toast.makeText(context, R.string.speak_permission_denied, Toast.LENGTH_LONG).show()
+ 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()) {
+ Toast.makeText(context, R.string.speak_unavailable, Toast.LENGTH_LONG).show()
+ 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 +181,10 @@ fun StudySessionScreen(
onSpeak: () -> Unit,
onClose: () -> Unit,
onDone: () -> Unit,
+ onSpeakTest: () -> Unit = {},
+ onSpeakContinue: () -> Unit = {},
+ onSpeakRetry: () -> Unit = {},
+ onSpeakCancel: () -> Unit = {},
) {
val colors = EchoTheme.colors
Box(
@@ -161,10 +231,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 +254,7 @@ private fun ReviewingContent(
onReveal: () -> Unit,
onGrade: (SrsGrade) -> Unit,
onSpeak: () -> Unit,
+ onSpeakTest: () -> Unit,
onClose: () -> Unit,
) {
val colors = EchoTheme.colors
@@ -272,12 +354,15 @@ 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 = false,
+ onSpeakTest = null,
)
} else {
// Counter-rotate so the back content is not mirrored.
@@ -286,6 +371,10 @@ private fun ReviewingContent(
text = state.backText,
textSize = 42.sp,
onSpeak = onSpeak,
+ showListen = state.listenEnabled,
+ onSpeakTest = if (state.speakEnabled) onSpeakTest else null,
+ imageRef = state.frontImageRef,
+ deckId = state.deckId,
modifier = Modifier.graphicsLayer { rotationY = 180f },
)
}
@@ -320,7 +409,11 @@ private fun CardFace(
text: String,
textSize: TextUnit,
onSpeak: () -> Unit,
+ showListen: Boolean,
+ onSpeakTest: (() -> Unit)?,
modifier: Modifier = Modifier,
+ imageRef: MediaRef.Image? = null,
+ deckId: String = "",
) {
val colors = EchoTheme.colors
Column(
@@ -337,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,
@@ -344,25 +448,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.accentPrimarySoft,
+ contentColor = colors.accentPrimary,
+ ),
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.VolumeUp,
+ contentDescription = stringResource(R.string.study_listen),
+ modifier = Modifier.size(16.dp),
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(text = stringResource(R.string.study_listen), 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.accentSecondarySoft,
+ contentColor = colors.accentSecondary,
+ ),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Mic,
+ 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)
+ }
+ }
}
}
}
diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml
index 54157aa..925ca1e 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
@@ -155,6 +156,7 @@
Back
New deck
%1$d cards ready
+ %1$d discarded in review
COVER
Change
TITLE
@@ -177,6 +179,48 @@
%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
+ Back image
+ Choose an image for the back 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/design/main/phone-echo.pen b/design/main/phone-echo.pen
index a4da0bc..bb1dd7f 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"
- }
- ]
}
]
},
@@ -11709,7 +11674,7 @@
"id": "hvd7M",
"name": "sheetTitle",
"fill": "$foreground-primary",
- "content": "Cover image",
+ "content": "Back image",
"fontFamily": "Funnel Sans",
"fontSize": 20,
"fontWeight": "800"
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/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/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..e97b7f0 100644
--- a/journeys/RESULTS.md
+++ b/journeys/RESULTS.md
@@ -26,19 +26,60 @@ 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.
+
+## 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.
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..ef0f6c0
--- /dev/null
+++ b/shared/src/androidMain/kotlin/com/github/jvsena42/echo/platform/AndroidSpeechRecognizer.kt
@@ -0,0 +1,82 @@
+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 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
+ * 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)
+
+ @Suppress("EmptyFunctionBlock")
+ 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/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/data/repository/Repositories.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/repository/Repositories.kt
index 852bf3e..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,14 +2,17 @@ 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
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 +81,23 @@ 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)
+
+ /** 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
+
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..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,18 +3,70 @@ 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
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
+@Suppress("TooManyFunctions")
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
+ 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 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()
+ 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()
+ 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)." }
@@ -64,6 +116,10 @@ class ImportRepositoryImpl : ImportRepository {
override fun clear() {
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/data/unsplash/UnsplashClient.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/unsplash/UnsplashClient.kt
new file mode 100644
index 0000000..221ef47
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/data/unsplash/UnsplashClient.kt
@@ -0,0 +1,112 @@
+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. */
+@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
+ 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 115f4e9..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
@@ -29,6 +29,8 @@ 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.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
@@ -95,10 +97,20 @@ val sharedModule = module {
cardId = params.get(1),
cardRepository = get(),
deckRepository = get(),
+ mediaRepository = get(),
)
}
factory { PasteImportViewModel(importRepository = get()) }
- factory { PublishDeckViewModel(importRepository = get(), deckRepository = get(), identityRepository = get()) }
+ factory { TriageViewModel(importRepository = get()) }
+ factory { ImageSheetViewModel(unsplashClient = 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/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/Import.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/domain/model/Import.kt
index ab2c932..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
@@ -15,6 +15,31 @@ 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 }
+
+/**
+ * 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
+
+/** 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/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/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/decks/EditCardViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/EditCardViewModel.kt
index e99d83c..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
@@ -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
@@ -20,11 +22,13 @@ 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,
private val cardRepository: CardRepository,
private val deckRepository: DeckRepository,
+ private val mediaRepository: MediaRepository,
mainScope: CoroutineScope? = null,
) {
private val scope: CoroutineScope =
@@ -60,12 +64,46 @@ class EditCardViewModel(
totalCards = totalCards,
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,
)
}
}
+ /** 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) }
+ }
+
+ /** 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) {
_state.update { it.copy(frontText = text, frontError = cardTextErrorFor(text)) }
}
@@ -117,18 +155,20 @@ 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,
updatedAt = now,
front = CardSide(
text = s.frontText.ifBlank { null },
- imageRef = existingCard?.front?.imageRef,
+ imageRef = frontImage,
audioRef = existingCard?.front?.audioRef,
),
back = CardSide(
text = s.backText.ifBlank { null },
- imageRef = existingCard?.back?.imageRef,
+ imageRef = backImage,
audioRef = existingCard?.back?.audioRef,
),
)
@@ -161,6 +201,26 @@ 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
+ }
+
+ /** 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()
@@ -183,6 +243,12 @@ 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 backImageRef: MediaRef.Image? = null,
+ val backPendingBytes: ByteArray? = null,
+ val backPendingMime: 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 271ac99..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
@@ -3,11 +3,15 @@ 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.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
import com.github.jvsena42.echo.util.Log
import com.github.jvsena42.echo.util.epochMillis
@@ -26,10 +30,12 @@ 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,
private val identityRepository: IdentityRepository,
+ private val mediaRepository: MediaRepository,
mainScope: CoroutineScope? = null,
) {
private val scope: CoroutineScope =
@@ -47,7 +53,8 @@ class PublishDeckViewModel(
init {
val draft = importRepository.currentDraft()
if (draft != null) {
- _state.update { it.copy(cardCount = draft.rows.size) }
+ val kept = importRepository.keptRows().size
+ _state.update { it.copy(cardCount = kept, discardedCount = draft.rows.size - kept) }
}
}
@@ -63,6 +70,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
@@ -90,7 +115,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()
@@ -101,19 +126,8 @@ class PublishDeckViewModel(
val now = epochMillis()
val deckId = generateId()
- val mapping = draft.columnMapping.assignments
-
- val cards = draft.rows.mapIndexed { idx, 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(
id = deckId,
@@ -121,11 +135,13 @@ class PublishDeckViewModel(
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 +197,60 @@ class PublishDeckViewModel(
}
}
+ /** 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
+ 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. */
+ 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.") }
@@ -227,9 +297,15 @@ 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,
+ 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/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/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/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..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,8 @@ 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
import com.github.jvsena42.echo.util.Log
@@ -27,6 +29,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,
@@ -47,6 +50,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 +99,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 +179,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 +189,11 @@ class StudySessionViewModel(
backLabel = card.front.text?.uppercase(),
revealed = revealed,
intervals = labels,
+ listenEnabled = deck?.listenEnabled ?: true,
+ speakEnabled = deck?.speakEnabled ?: true,
+ speakPhase = speakPhase,
+ deckId = card.deckId,
+ frontImageRef = card.front.imageRef,
)
}
}
@@ -180,6 +235,12 @@ 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,
+ 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
@@ -187,7 +248,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
}
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/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/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)
+ }
+}
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 5f0fce4..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
@@ -5,12 +5,15 @@ 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.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
import com.github.jvsena42.echo.domain.model.PubkyIdentity
import com.github.jvsena42.echo.domain.model.PubkyUri
@@ -19,6 +22,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 {
@@ -160,16 +164,59 @@ 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
+ }
+
+ 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()
+
override fun clear() {
clearCount++
draft = null
+ triageDecisions.clear()
+ rowEdits.clear()
+ }
+}
+
+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(
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