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>() + + override suspend fun get(url: String, headers: Map): Result { requestedUrls.add(url) + requestedHeaders.add(headers) return responses[url] ?: Result.failure(IllegalStateException("No canned response for $url")) } } diff --git a/shared/src/iosMain/kotlin/com/github/jvsena42/echo/data/nexus/IosHttpFetcher.kt b/shared/src/iosMain/kotlin/com/github/jvsena42/echo/data/nexus/IosHttpFetcher.kt index 54189b3..2179b5d 100644 --- a/shared/src/iosMain/kotlin/com/github/jvsena42/echo/data/nexus/IosHttpFetcher.kt +++ b/shared/src/iosMain/kotlin/com/github/jvsena42/echo/data/nexus/IosHttpFetcher.kt @@ -3,12 +3,14 @@ package com.github.jvsena42.echo.data.nexus import kotlinx.coroutines.suspendCancellableCoroutine import platform.Foundation.NSData import platform.Foundation.NSHTTPURLResponse +import platform.Foundation.NSMutableURLRequest import platform.Foundation.NSString import platform.Foundation.NSURL import platform.Foundation.NSURLSession import platform.Foundation.NSUTF8StringEncoding import platform.Foundation.create -import platform.Foundation.dataTaskWithURL +import platform.Foundation.dataTaskWithRequest +import platform.Foundation.setValue import kotlin.coroutines.resume /** @@ -17,12 +19,15 @@ import kotlin.coroutines.resume */ class IosHttpFetcher : HttpFetcher { - override suspend fun get(url: String): Result { + override suspend fun get(url: String, headers: Map): Result { val nsUrl = NSURL.URLWithString(url) ?: return Result.failure(IllegalArgumentException("Invalid URL: $url")) + val request = NSMutableURLRequest.requestWithURL(nsUrl) + headers.forEach { (key, value) -> request.setValue(value, forHTTPHeaderField = key) } + return suspendCancellableCoroutine { continuation -> - val task = NSURLSession.sharedSession.dataTaskWithURL(nsUrl) { data, response, error -> + val task = NSURLSession.sharedSession.dataTaskWithRequest(request) { data, response, error -> val result: Result = when { error != null -> Result.failure(RuntimeException(error.localizedDescription))