From 43207eda83d5eddcc02804b35cbfdd17c07601cd Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 15 Jun 2026 19:32:56 -0300 Subject: [PATCH 1/5] design: card design improvements --- design/main/phone-echo.pen | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/design/main/phone-echo.pen b/design/main/phone-echo.pen index 073450a..eed0701 100644 --- a/design/main/phone-echo.pen +++ b/design/main/phone-echo.pen @@ -1091,7 +1091,41 @@ "type": "ref", "ref": "m6NJx", "name": "srs3", - "width": "fill_container" + "width": "fill_container", + "enabled": true, + "opacity": 0 + }, + { + "type": "frame", + "id": "E1fs1", + "name": "Flip Hint", + "width": "fill_container", + "fill": "#00000000", + "gap": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "N8fOB", + "name": "Flip Icon", + "width": 16, + "height": 16, + "icon": "repeat", + "library": "lucide", + "fill": "$foreground-muted" + }, + { + "type": "text", + "id": "ULVZX", + "name": "Flip Text", + "fill": "$foreground-muted", + "content": "Tap card to reveal answer", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] } ] } From 20c1af9207bde442d4c03750b5cd31153b8a3132 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 15 Jun 2026 19:54:10 -0300 Subject: [PATCH 2/5] fix: add card animation --- .../echo/ui/study/StudySessionScreen.kt | 170 +++++++++++++----- .../src/androidMain/res/values/strings.xml | 2 +- design/main/phone-echo.pen | 11 -- 3 files changed, 131 insertions(+), 52 deletions(-) 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 35eb188..3096525 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,6 +1,9 @@ package com.github.jvsena42.echo.ui.study -import androidx.compose.animation.Crossfade +import android.provider.Settings +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -21,6 +24,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape 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.material3.Button import androidx.compose.material3.ButtonDefaults @@ -35,11 +39,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.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 @@ -223,44 +230,85 @@ private fun ReviewingContent( Spacer(modifier = Modifier.height(20.dp)) - // Card (tap front to reveal). Crossfade doubles as the Reduce-Motion-friendly flip. + // Card (tap front to reveal) with a 3D flip. Same Box for both faces, so front + // and back are always the identical size. + val reduceMotion = rememberReduceMotion() + val rotation by animateFloatAsState( + targetValue = if (state.revealed) 180f else 0f, + animationSpec = if (reduceMotion) snap() else tween(durationMillis = 450), + label = "cardFlip", + ) Box( modifier = Modifier .testTag("study_card") .fillMaxWidth() .weight(1f) + .graphicsLayer { + rotationY = rotation + cameraDistance = 12f * density + } .clip(RoundedCornerShape(28.dp)) .background(colors.surfaceCard) .clickable(enabled = !state.revealed, onClick = onReveal) .padding(24.dp), contentAlignment = Alignment.Center, ) { - Crossfade(targetState = state.revealed, label = "cardFace") { revealed -> - if (!revealed) { - CardFace( - label = stringResource(R.string.study_tap_to_reveal), - text = state.frontText, - textSize = 44.sp, - onSpeak = onSpeak, - ) - } else { - CardFace( - label = state.backLabel, - text = state.backText, - textSize = 38.sp, - onSpeak = onSpeak, - ) - } + if (rotation < 90f) { + // Front shows only the prompt; the reveal cue lives in the hint row below. + CardFace( + label = null, + text = state.frontText, + textSize = 48.sp, + onSpeak = onSpeak, + ) + } else { + // Counter-rotate so the back content is not mirrored. + CardFace( + label = state.backLabel, + text = state.backText, + textSize = 42.sp, + onSpeak = onSpeak, + modifier = Modifier.graphicsLayer { rotationY = 180f }, + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + // SRS grade row — reserve its space always so the card above stays the same + // size; only show the buttons (only on the back) once revealed. + Box(modifier = Modifier.height(72.dp)) { + if (state.revealed) { + SrsRow(intervals = state.intervals, onGrade = onGrade) } } Spacer(modifier = Modifier.height(20.dp)) - // SRS grade row — only after reveal - if (state.revealed) { - SrsRow(intervals = state.intervals, onGrade = onGrade) - Spacer(modifier = Modifier.height(8.dp)) + // Flip hint — shown on the front only; space is reserved on the back too so the + // card above keeps the same size across the flip. + Box(modifier = Modifier.fillMaxWidth().height(20.dp)) { + if (!state.revealed) { + FlipHint(modifier = Modifier.align(Alignment.Center)) + } } + Spacer(modifier = Modifier.height(8.dp)) + } +} + +/** + * Reads the OS animation scale; returns true when animations are disabled + * ("Remove animations" / Reduce Motion), so the flip swaps faces instantly. + */ +@Composable +private fun rememberReduceMotion(): Boolean { + val context = LocalContext.current + return remember(context) { + Settings.Global.getFloat( + context.contentResolver, + Settings.Global.ANIMATOR_DURATION_SCALE, + 1f, + ) == 0f } } @@ -270,18 +318,20 @@ private fun CardFace( text: String, textSize: TextUnit, onSpeak: () -> Unit, + modifier: Modifier = Modifier, ) { val colors = EchoTheme.colors Column( + modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { label?.takeIf { it.isNotBlank() }?.let { Text( text = it, - fontSize = 11.sp, + fontSize = 14.sp, fontWeight = FontWeight.W700, - letterSpacing = 1.2.sp, + letterSpacing = 1.5.sp, color = colors.accentPrimary, ) } @@ -315,6 +365,29 @@ private fun CardFace( } } +@Composable +private fun FlipHint(modifier: Modifier = Modifier) { + val colors = EchoTheme.colors + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Autorenew, + contentDescription = null, + tint = colors.foregroundMuted, + modifier = Modifier.size(16.dp), + ) + Text( + text = stringResource(R.string.study_flip_hint), + fontSize = 13.sp, + fontWeight = FontWeight.W500, + color = colors.foregroundMuted, + ) + } +} + @Composable private fun SrsRow( intervals: Map, @@ -411,26 +484,43 @@ private fun BoxScope.CenteredMessage( } } +private val previewReviewing = StudySessionUiState.Reviewing( + deckTitle = "Spanish basics", + position = 3, + total = 12, + frontText = "hola", + backText = "hello", + backLabel = "MEANING", + revealed = true, + intervals = mapOf( + SrsGrade.Again to "1m", + SrsGrade.Hard to "10m", + SrsGrade.Good to "1d", + SrsGrade.Easy to "4d", + ), +) + @Preview @Composable -private fun StudySessionScreenPreview() { +private fun StudySessionScreenRevealedPreview() { EchoTheme { StudySessionScreen( - state = StudySessionUiState.Reviewing( - deckTitle = "Spanish basics", - position = 3, - total = 12, - frontText = "hola", - backText = "hello", - backLabel = "MEANING", - revealed = true, - intervals = mapOf( - SrsGrade.Again to "1m", - SrsGrade.Hard to "10m", - SrsGrade.Good to "1d", - SrsGrade.Easy to "4d", - ), - ), + state = previewReviewing, + onReveal = {}, + onGrade = {}, + onSpeak = {}, + onClose = {}, + onDone = {}, + ) + } +} + +@Preview +@Composable +private fun StudySessionScreenFrontPreview() { + EchoTheme { + StudySessionScreen( + state = previewReviewing.copy(revealed = false), onReveal = {}, onGrade = {}, onSpeak = {}, diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml index 1c76621..9c835e6 100644 --- a/composeApp/src/androidMain/res/values/strings.xml +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -101,7 +101,7 @@ You reviewed %1$d cards. Back %1$d of %2$d - TAP TO REVEAL + Tap card to reveal answer Speak diff --git a/design/main/phone-echo.pen b/design/main/phone-echo.pen index eed0701..cf18e36 100644 --- a/design/main/phone-echo.pen +++ b/design/main/phone-echo.pen @@ -1025,17 +1025,6 @@ "justifyContent": "center", "alignItems": "center", "children": [ - { - "type": "text", - "id": "A74Ju", - "name": "cardLbl", - "fill": "$accent-primary", - "content": "TRANSLATE TO ENGLISH", - "fontFamily": "Inter", - "fontSize": 11, - "fontWeight": "700", - "letterSpacing": 1.2 - }, { "type": "text", "id": "gPbPk", From 00c26f393bceb94a2de0358420aa1ea8e53fae1b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 15 Jun 2026 20:14:41 -0300 Subject: [PATCH 3/5] fix: improve animations --- .../echo/ui/study/StudySessionScreen.kt | 125 +++++++++++++----- 1 file changed, 94 insertions(+), 31 deletions(-) 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 3096525..d0b4d90 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,11 +1,14 @@ package com.github.jvsena42.echo.ui.study import android.provider.Settings +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -39,8 +42,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -235,7 +240,11 @@ private fun ReviewingContent( val reduceMotion = rememberReduceMotion() val rotation by animateFloatAsState( targetValue = if (state.revealed) 180f else 0f, - animationSpec = if (reduceMotion) snap() else tween(durationMillis = 450), + animationSpec = if (reduceMotion) { + snap() + } else { + tween(durationMillis = 700, easing = FastOutSlowInEasing) + }, label = "cardFlip", ) Box( @@ -279,7 +288,7 @@ private fun ReviewingContent( // size; only show the buttons (only on the back) once revealed. Box(modifier = Modifier.height(72.dp)) { if (state.revealed) { - SrsRow(intervals = state.intervals, onGrade = onGrade) + SrsRow(intervals = state.intervals, onGrade = onGrade, reduceMotion = reduceMotion) } } @@ -392,6 +401,7 @@ private fun FlipHint(modifier: Modifier = Modifier) { private fun SrsRow( intervals: Map, onGrade: (SrsGrade) -> Unit, + reduceMotion: Boolean, ) { val colors = EchoTheme.colors val buttons = listOf( @@ -404,35 +414,88 @@ private fun SrsRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - buttons.forEach { (grade, color) -> - Button( - onClick = { onGrade(grade) }, - modifier = Modifier - .testTag("study_${grade.name.lowercase()}") - .weight(1f) - .height(72.dp), - shape = RoundedCornerShape(20.dp), - colors = ButtonDefaults.buttonColors( - containerColor = color, - contentColor = Color.White, - ), - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text( - text = grade.name, - fontSize = 15.sp, - fontWeight = FontWeight.W700, - ) - Text( - text = intervals[grade] ?: "", - fontSize = 11.sp, - fontWeight = FontWeight.W500, - ) - } - } + buttons.forEachIndexed { index, (grade, color) -> + SrsButton( + grade = grade, + color = color, + interval = intervals[grade] ?: "", + index = index, + reduceMotion = reduceMotion, + onGrade = onGrade, + modifier = Modifier.weight(1f), + ) + } + } +} + +/** + * One grade button. On reveal the buttons fade + rise + scale in with a per-index + * stagger; pressing dips the scale for tactile feedback. Both effects are skipped when + * the OS has animations disabled. + */ +@Composable +private fun SrsButton( + grade: SrsGrade, + color: Color, + interval: String, + index: Int, + reduceMotion: Boolean, + onGrade: (SrsGrade) -> Unit, + modifier: Modifier = Modifier, +) { + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { visible = true } + val enter by animateFloatAsState( + targetValue = if (visible) 1f else 0f, + animationSpec = if (reduceMotion) { + snap() + } else { + tween(durationMillis = 280, delayMillis = index * 60, easing = FastOutSlowInEasing) + }, + label = "srsEnter", + ) + + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + val pressScale by animateFloatAsState( + targetValue = if (pressed && !reduceMotion) 0.94f else 1f, + animationSpec = tween(durationMillis = 120), + label = "srsPress", + ) + + Button( + onClick = { onGrade(grade) }, + interactionSource = interactionSource, + modifier = modifier + .testTag("study_${grade.name.lowercase()}") + .height(72.dp) + .graphicsLayer { + alpha = enter + val scale = (0.85f + 0.15f * enter) * pressScale + scaleX = scale + scaleY = scale + translationY = (1f - enter) * 20.dp.toPx() + }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults.buttonColors( + containerColor = color, + contentColor = Color.White, + ), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = grade.name, + fontSize = 15.sp, + fontWeight = FontWeight.W700, + ) + Text( + text = interval, + fontSize = 11.sp, + fontWeight = FontWeight.W500, + ) } } } From 412eb681a8916585d8cd653b6e6c89069574806a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 15 Jun 2026 20:20:32 -0300 Subject: [PATCH 4/5] fix: text size adapt --- .../echo/ui/study/StudySessionScreen.kt | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) 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 d0b4d90..051e650 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 @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding 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 @@ -56,6 +57,7 @@ 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.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp @@ -200,13 +202,22 @@ private fun ReviewingContent( modifier = Modifier.size(20.dp), ) } - Column(horizontalAlignment = Alignment.CenterHorizontally) { + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { Text( text = state.deckTitle.uppercase(), fontSize = 11.sp, fontWeight = FontWeight.W700, letterSpacing = 1.sp, color = colors.foregroundMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), ) Text( text = stringResource(R.string.study_position_of_total, state.position, state.total), @@ -488,13 +499,25 @@ private fun SrsButton( ) { Text( text = grade.name, - fontSize = 15.sp, + autoSize = TextAutoSize.StepBased( + minFontSize = 11.sp, + maxFontSize = 15.sp, + stepSize = 0.5.sp, + ), fontWeight = FontWeight.W700, + maxLines = 1, + softWrap = false, ) Text( text = interval, - fontSize = 11.sp, + autoSize = TextAutoSize.StepBased( + minFontSize = 8.sp, + maxFontSize = 11.sp, + stepSize = 0.5.sp, + ), fontWeight = FontWeight.W500, + maxLines = 1, + softWrap = false, ) } } From 63814ce0e1a11194d0afb81b28a2c742af1edd28 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 15 Jun 2026 20:29:12 -0300 Subject: [PATCH 5/5] fix: display deck title --- .../study/StudySessionViewModel.kt | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) 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 9db995f..dd5b53b 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 @@ -47,6 +47,9 @@ class StudySessionViewModel( private var revealed = false private var reviewedCount = 0 private var deckTitle = "" + + /** id → title, warmed lazily from [DeckRepository.listOwned] so multi-deck sessions can label each card. */ + private var deckTitles: Map = emptyMap() private var gradeJob: Job? = null init { @@ -58,7 +61,7 @@ class StudySessionViewModel( private fun load() { scope.launch { _state.value = StudySessionUiState.Loading - deckTitle = deckId?.let { deckRepository.getLocal(it)?.title }.orEmpty() + deckTitle = deckId?.let { resolveDeckTitle(it) }.orEmpty() runCatching { if (deckId == null) srsRepository.dueToday() else srsRepository.dueForDeck(deckId) } @@ -126,8 +129,9 @@ class StudySessionViewModel( // Cache is warmed by the queue build; a null state means a new (never-reviewed) card. val srsState = srsRepository.stateFor(card.id) val labels = srsState.previewIntervals(card.id, epochMillis()) + val title = deckTitle.ifBlank { resolveDeckTitle(card.deckId) }.ifBlank { card.deckId } _state.value = StudySessionUiState.Reviewing( - deckTitle = deckTitle.ifBlank { card.deckId }, + deckTitle = title, position = index + 1, total = queue.size, frontText = card.front.text.orEmpty(), @@ -139,6 +143,22 @@ class StudySessionViewModel( } } + /** + * Resolves a deck title for the header. Tries the in-memory cache first ([DeckRepository.getLocal]); + * on a cold cache it falls back to [DeckRepository.listOwned] once, which fetches + caches owned + * decks, so a session opened without the deck pre-loaded still shows the name. + */ + private suspend fun resolveDeckTitle(id: String): String { + deckRepository.getLocal(id)?.title?.takeIf { it.isNotBlank() }?.let { return it } + if (deckTitles.isEmpty()) { + deckTitles = runCatching { deckRepository.listOwned() } + .onFailure { Log.e(TAG, "resolveDeckTitle: listOwned FAILED — ${it.message}", it) } + .getOrDefault(emptyList()) + .associate { it.id to it.title } + } + return deckTitles[id].orEmpty() + } + companion object { private const val TAG = "Echo/StudyVM" }