diff --git a/app/src/androidTest/java/nart/simpleanki/feature/study/StudyContentTest.kt b/app/src/androidTest/java/nart/simpleanki/feature/study/StudyContentTest.kt index 00d26d5..bb69a88 100644 --- a/app/src/androidTest/java/nart/simpleanki/feature/study/StudyContentTest.kt +++ b/app/src/androidTest/java/nart/simpleanki/feature/study/StudyContentTest.kt @@ -23,7 +23,7 @@ class StudyContentTest { ) @Test - fun front_showsQuestion_andRevealButton() { + fun front_showsQuestion_andFlipsOnTap() { var revealed = false composeRule.setContent { StudyContent( @@ -33,8 +33,8 @@ class StudyContentTest { onDone = {}, ) } - composeRule.onNodeWithText("hola").assertIsDisplayed() - composeRule.onNodeWithText("Show answer").assertIsDisplayed().performClick() + // The card flips on tap (no "Show answer" button) — tapping the question reveals the answer. + composeRule.onNodeWithText("hola").assertIsDisplayed().performClick() assertTrue(revealed) } diff --git a/app/src/main/java/nart/simpleanki/feature/study/StudyScreen.kt b/app/src/main/java/nart/simpleanki/feature/study/StudyScreen.kt index 4fb8689..84f9573 100644 --- a/app/src/main/java/nart/simpleanki/feature/study/StudyScreen.kt +++ b/app/src/main/java/nart/simpleanki/feature/study/StudyScreen.kt @@ -1,5 +1,9 @@ package nart.simpleanki.feature.study +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -106,36 +110,51 @@ private fun StudyCard(state: StudyUiState, onReveal: () -> Unit, onRate: (Rating ) } Spacer(Modifier.height(16.dp)) - if (!state.isRevealed) { - if (state.showFlipHint) { - Row( - Modifier.fillMaxWidth().height(60.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, + // Fixed-height slot so the FlipCard above never shifts. Two full-size Column layers inside + // a Box overlap (z-stack) so the rating row slides up OVER the cross-fading hint. (Each + // AnimatedVisibility needs a ColumnScope; this Compose version has no top-level overload.) + Box(Modifier.fillMaxWidth().height(60.dp)) { + Column(Modifier.fillMaxSize()) { + AnimatedVisibility( + visible = !state.isRevealed, + enter = fadeIn(), + exit = fadeOut(), ) { - Icon( - Icons.Outlined.TouchApp, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(8.dp)) - Text( - "Tap to flip", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (state.showFlipHint) { + Row( + Modifier.fillMaxWidth().height(60.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.TouchApp, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Tap to flip", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } - } else { - // Keep the layout stable once the hint is gone. - Spacer(Modifier.height(60.dp)) } - } else { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - // iOS rating colors (SwiftUI system): again=pink, hard=orange, good=indigo, easy=mint. - RatingButton("Again", state.ratingIntervals[Rating.Again], Color(0xFFFF2D55), Modifier.weight(1f)) { onRate(Rating.Again) } - RatingButton("Hard", state.ratingIntervals[Rating.Hard], Color(0xFFFF9500), Modifier.weight(1f)) { onRate(Rating.Hard) } - RatingButton("Good", state.ratingIntervals[Rating.Good], Color(0xFF5856D6), Modifier.weight(1f)) { onRate(Rating.Good) } - RatingButton("Easy", state.ratingIntervals[Rating.Easy], Color(0xFF00C7BE), Modifier.weight(1f)) { onRate(Rating.Easy) } + Column(Modifier.fillMaxSize()) { + AnimatedVisibility( + visible = state.isRevealed, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = fadeOut(), + ) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // iOS rating colors (SwiftUI system): again=pink, hard=orange, good=indigo, easy=mint. + RatingButton("Again", state.ratingIntervals[Rating.Again], Color(0xFFFF2D55), Modifier.weight(1f)) { onRate(Rating.Again) } + RatingButton("Hard", state.ratingIntervals[Rating.Hard], Color(0xFFFF9500), Modifier.weight(1f)) { onRate(Rating.Hard) } + RatingButton("Good", state.ratingIntervals[Rating.Good], Color(0xFF5856D6), Modifier.weight(1f)) { onRate(Rating.Good) } + RatingButton("Easy", state.ratingIntervals[Rating.Easy], Color(0xFF00C7BE), Modifier.weight(1f)) { onRate(Rating.Easy) } + } + } } } } diff --git a/app/src/main/java/nart/simpleanki/ui/components/FlipCard.kt b/app/src/main/java/nart/simpleanki/ui/components/FlipCard.kt index f0e9a89..d355f24 100644 --- a/app/src/main/java/nart/simpleanki/ui/components/FlipCard.kt +++ b/app/src/main/java/nart/simpleanki/ui/components/FlipCard.kt @@ -4,12 +4,16 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column 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.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -99,26 +103,36 @@ private fun CardFace( audioName: String? = null, audioPath: String? = null, ) { - Column( - modifier.fillMaxSize().padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text( - label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - letterSpacing = 1.sp, - ) - Spacer(Modifier.height(12.dp)) - imageName?.let { name -> - MediaImage(name, imagePath, Modifier.fillMaxWidth().height(160.dp)) - Spacer(Modifier.height(16.dp)) - } - Text(text, style = textStyle, color = textColor, textAlign = TextAlign.Center) - audioName?.let { name -> + // Center the content when it fits the card; when it overflows, the column grows past the + // viewport and verticalScroll lets the user scroll (centering then naturally yields no extra + // space). heightIn(min = maxHeight) is what makes the "center when short" case work. Mirrors + // iOS's ScrollView { ... }.frame(minHeight: proxy.size.height). + BoxWithConstraints(modifier.fillMaxSize().padding(horizontal = 24.dp)) { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight) + .fillMaxWidth() + .padding(vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + letterSpacing = 1.sp, + ) Spacer(Modifier.height(12.dp)) - AudioPlayButton(name, audioPath) + imageName?.let { name -> + MediaImage(name, imagePath, Modifier.fillMaxWidth().height(160.dp)) + Spacer(Modifier.height(16.dp)) + } + Text(text, style = textStyle, color = textColor, textAlign = TextAlign.Center) + audioName?.let { name -> + Spacer(Modifier.height(12.dp)) + AudioPlayButton(name, audioPath) + } } } } @@ -149,3 +163,25 @@ private fun FlipCardBackPreview() { ) } } + +private val previewLongCard = Card( + id = "c2", + front = "Explain the difference between the present perfect and the simple past tense, " + + "with at least three examples of each, and describe when a learner should prefer one " + + "over the other in everyday conversation. Then summarize the key rule in one sentence.", + back = "The present perfect links a past action to the present; the simple past describes a " + + "finished action at a definite time.", + deckId = "d1", + dateCreated = 0, lastModified = 0, fsrsDue = 0, fsrsState = CardState.New.value, +) + +@Preview(name = "FlipCard · long text", showBackground = true) +@Composable +private fun FlipCardLongTextPreview() { + AzriTheme { + FlipCard( + previewLongCard, revealed = false, onFlip = {}, + modifier = Modifier.fillMaxWidth().height(300.dp).padding(20.dp), + ) + } +} diff --git a/docs/superpowers/plans/2026-06-04-study-card-scroll-slide.md b/docs/superpowers/plans/2026-06-04-study-card-scroll-slide.md new file mode 100644 index 0000000..0e0f37b --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-study-card-scroll-slide.md @@ -0,0 +1,270 @@ +# Study Card: Scroll + Slide-Up Buttons Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the study card's content scrollable for long text, and animate the rating buttons sliding up from the bottom on answer reveal. + +**Architecture:** Two independent presentation changes — `FlipCard.kt`'s `CardFace` gets a "center-when-short, scroll-when-tall" wrapper (`BoxWithConstraints` + `verticalScroll` + `heightIn(min = maxHeight)`); `StudyScreen.kt`'s `StudyCard` replaces its `if/else` bottom slot with stacked `AnimatedVisibility`s so the rating row slides up + fades in. No state, ViewModel, or data changes. + +**Tech Stack:** Kotlin, Jetpack Compose (`androidx.compose.animation`, `foundation.verticalScroll`, `BoxWithConstraints`). Build-verified (no unit tests for animation/layout). + +**Build/test prefix:** all Gradle commands MUST be prefixed with `export JAVA_HOME=/opt/homebrew/opt/openjdk &&`. Run from `/Users/astemirboziev/Developer/SimpleAnkiProject/azri_android`. + +**Note:** the emulator is currently unavailable, so instrumented (`androidTest`) sources are COMPILE-verified only, not run. + +--- + +### Task 1: Scrollable card content (`FlipCard.kt` → `CardFace`) + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/ui/components/FlipCard.kt` + +Build-verified + a long-text preview. + +- [ ] **Step 1: Add four imports** + +In `FlipCard.kt`, add these imports (keep all existing ones): + +```kotlin +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +``` + +- [ ] **Step 2: Make `CardFace` scrollable** + +Replace the entire `private fun CardFace(...)` composable with this version (the only change is wrapping the `Column` in `BoxWithConstraints` and moving the layout/scroll modifiers onto the `Column`; the inner content is unchanged): + +```kotlin +@Composable +private fun CardFace( + label: String, + text: String, + textStyle: TextStyle, + textColor: Color, + modifier: Modifier = Modifier, + imageName: String? = null, + imagePath: String? = null, + audioName: String? = null, + audioPath: String? = null, +) { + // Center the content when it fits; scroll when it overflows (heightIn(min = maxHeight) + // forces the column to at least fill the card so Arrangement.Center works, but lets it + // grow past the viewport for long text). Mirrors iOS's ScrollView { ... }.minHeight. + BoxWithConstraints(modifier.fillMaxSize()) { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight) + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + letterSpacing = 1.sp, + ) + Spacer(Modifier.height(12.dp)) + imageName?.let { name -> + MediaImage(name, imagePath, Modifier.fillMaxWidth().height(160.dp)) + Spacer(Modifier.height(16.dp)) + } + Text(text, style = textStyle, color = textColor, textAlign = TextAlign.Center) + audioName?.let { name -> + Spacer(Modifier.height(12.dp)) + AudioPlayButton(name, audioPath) + } + } + } +} +``` + +Note: the `modifier` parameter (the back face passes `Modifier.graphicsLayer { rotationY = 180f }`) now wraps the whole `BoxWithConstraints`, so the counter-rotation still applies to the entire face. `maxHeight` is the `BoxWithConstraintsScope` viewport height. + +- [ ] **Step 3: Add a long-text preview** + +In `FlipCard.kt`, add this preview data + preview after the existing `FlipCardBackPreview`: + +```kotlin +private val previewLongCard = Card( + id = "c2", + front = "Explain the difference between the present perfect and the simple past tense, " + + "with at least three examples of each, and describe when a learner should prefer one " + + "over the other in everyday conversation. Then summarize the key rule in one sentence.", + back = "The present perfect links a past action to the present; the simple past describes a " + + "finished action at a definite time.", + deckId = "d1", + dateCreated = 0, lastModified = 0, fsrsDue = 0, fsrsState = CardState.New.value, +) + +@Preview(name = "FlipCard · long text", showBackground = true) +@Composable +private fun FlipCardLongTextPreview() { + AzriTheme { + FlipCard( + previewLongCard, revealed = false, onFlip = {}, + modifier = Modifier.fillMaxWidth().height(300.dp).padding(20.dp), + ) + } +} +``` + +(The 300.dp height forces the long front text to overflow, so the preview demonstrates the scroll wrapper rather than clipping.) + +- [ ] **Step 4: Verify it compiles** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/ui/components/FlipCard.kt +git commit -m "Make flip card content scrollable for long text" +``` + +--- + +### Task 2: Slide-up rating buttons (`StudyScreen.kt` → `StudyCard`) + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/feature/study/StudyScreen.kt` + +Build-verified. + +- [ ] **Step 1: Add four animation imports** + +In `StudyScreen.kt`, add these imports (keep all existing ones; `Box` is already imported): + +```kotlin +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +``` + +- [ ] **Step 2: Replace the bottom-slot `if/else` with stacked `AnimatedVisibility`s** + +In `StudyCard`, the block currently reads (from the `Spacer(Modifier.height(16.dp))` after the `key(card.id) { ... }` block to the end of the function's `Column`): + +```kotlin + Spacer(Modifier.height(16.dp)) + if (!state.isRevealed) { + if (state.showFlipHint) { + Row( + Modifier.fillMaxWidth().height(60.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.TouchApp, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Tap to flip", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + // Keep the layout stable once the hint is gone. + Spacer(Modifier.height(60.dp)) + } + } else { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // iOS rating colors (SwiftUI system): again=pink, hard=orange, good=indigo, easy=mint. + RatingButton("Again", state.ratingIntervals[Rating.Again], Color(0xFFFF2D55), Modifier.weight(1f)) { onRate(Rating.Again) } + RatingButton("Hard", state.ratingIntervals[Rating.Hard], Color(0xFFFF9500), Modifier.weight(1f)) { onRate(Rating.Hard) } + RatingButton("Good", state.ratingIntervals[Rating.Good], Color(0xFF5856D6), Modifier.weight(1f)) { onRate(Rating.Good) } + RatingButton("Easy", state.ratingIntervals[Rating.Easy], Color(0xFF00C7BE), Modifier.weight(1f)) { onRate(Rating.Easy) } + } + } +``` + +Replace that entire block with: + +```kotlin + Spacer(Modifier.height(16.dp)) + // Fixed-height slot so the FlipCard above never shifts; the hint cross-fades and the + // rating row slides up from the bottom edge on reveal. + Box(Modifier.fillMaxWidth().height(60.dp)) { + AnimatedVisibility( + visible = !state.isRevealed, + enter = fadeIn(), + exit = fadeOut(), + ) { + if (state.showFlipHint) { + Row( + Modifier.fillMaxWidth().height(60.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.TouchApp, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Tap to flip", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + AnimatedVisibility( + visible = state.isRevealed, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = fadeOut(), + ) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // iOS rating colors (SwiftUI system): again=pink, hard=orange, good=indigo, easy=mint. + RatingButton("Again", state.ratingIntervals[Rating.Again], Color(0xFFFF2D55), Modifier.weight(1f)) { onRate(Rating.Again) } + RatingButton("Hard", state.ratingIntervals[Rating.Hard], Color(0xFFFF9500), Modifier.weight(1f)) { onRate(Rating.Hard) } + RatingButton("Good", state.ratingIntervals[Rating.Good], Color(0xFF5856D6), Modifier.weight(1f)) { onRate(Rating.Good) } + RatingButton("Easy", state.ratingIntervals[Rating.Easy], Color(0xFF00C7BE), Modifier.weight(1f)) { onRate(Rating.Easy) } + } + } + } +``` + +Behavior: the outer `Box` is always `60.dp` tall (so the card above is stable). When `!isRevealed`, the first `AnimatedVisibility` shows the hint (or nothing, when `showFlipHint` is false). On reveal, the hint fades out and the rating row slides up from the bottom edge (`slideInVertically(initialOffsetY = { it })`, clipped to the box) while fading in. On advancing to the next card (`isRevealed → false`), the rating row fades out and the hint fades back in. + +- [ ] **Step 3: Verify it compiles (main + instrumented test sources)** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin :app:compileDebugAndroidTestKotlin` +Expected: BUILD SUCCESSFUL. (The instrumented `StudyContentTest` still composes the rating buttons when revealed — `AnimatedVisibility(visible = true)` renders its content — so it remains valid; it is compile-verified only, no emulator.) + +- [ ] **Step 4: Run the full app unit test suite (no regressions)** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/feature/study/StudyScreen.kt +git commit -m "Slide rating buttons up on answer reveal" +``` + +--- + +## Final verification + +- [ ] **Build the debug APK end-to-end** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:assembleDebug` +Expected: BUILD SUCCESSFUL. + +- [ ] **Manual smoke (when an emulator is available)** + +- Study a card with long front/back text: the card content scrolls (drag) instead of clipping; a short card stays vertically centered. Tapping (not dragging) still flips. +- On tapping to reveal: the four rating buttons slide up from the bottom and fade in; the "Tap to flip" hint cross-fades out. +- Rating a card and advancing: the next card shows on its front with the hint/spacer; no leftover buttons. diff --git a/docs/superpowers/specs/2026-06-04-study-card-scroll-slide-design.md b/docs/superpowers/specs/2026-06-04-study-card-scroll-slide-design.md new file mode 100644 index 0000000..54d5219 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-study-card-scroll-slide-design.md @@ -0,0 +1,126 @@ +# Study Card: Slide-Up Buttons + Scrollable Content — Design + +**Date:** 2026-06-04 +**Status:** Approved (design); pending implementation plan +**Branch:** `feature/study-card-scroll-slide` (off the current `main`, which has the merged +flip-card screen, PR #8). + +## Goal + +Two study-screen polish improvements on top of the flip card: + +1. **Slide-up rating buttons** — when the user taps the card to reveal the answer, the four + rating buttons should animate **up from the bottom** (slide + fade) instead of just appearing. +2. **Scrollable card content** — long question/answer text currently overflows and cannot be + scrolled. The card face should scroll when content exceeds the available height, while staying + centered when it fits. + +## Background: current behavior + +- `StudyScreen.kt` → `StudyCard`: a `Column` with `FlipCard` (`weight(1f)`) on top, then a fixed + bottom slot chosen by a plain `if (!state.isRevealed) { hint/spacer } else { rating Row }`. The + swap is instant — no transition. +- `FlipCard.kt` → `CardFace`: `Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = + Arrangement.Center)`. With no scroll modifier, long text clips at the card bounds. +- iOS `FlipCardView` already solves the scroll case with a `ScrollView` whose content has + `minHeight: proxy.size.height` (centers short content, scrolls long content). + +## Change 1: Slide-up rating buttons (`StudyScreen.kt` → `StudyCard`) + +Replace the `if/else` bottom slot with a `Box` (fixed height `60.dp`, matching the rating-button +height so the card above never shifts) stacking two `AnimatedVisibility`s: + +```kotlin +Box(Modifier.fillMaxWidth().height(60.dp)) { + androidx.compose.animation.AnimatedVisibility( + visible = !state.isRevealed, + enter = fadeIn(), + exit = fadeOut(), + ) { + if (state.showFlipHint) { /* existing hint Row */ } else { /* empty */ } + } + androidx.compose.animation.AnimatedVisibility( + visible = state.isRevealed, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = fadeOut(), + ) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + /* existing four RatingButton(...) calls, unchanged */ + } + } +} +``` + +- `slideInVertically(initialOffsetY = { it })` starts the row offset by its full height + (below the slot) and animates it to its resting position → "slides up from the bottom". + Combined with `fadeIn()`. +- On reveal: hint fades out; rating row slides up + fades in. +- On advancing to the next card (`isRevealed → false`): the rating row fades out (no downward + slide — cleaner on advance) and the hint fades back in. +- The slot stays `60.dp` tall in all states (hint Row, empty spacer, and rating row are all + `60.dp`), so the `weight(1f)` `FlipCard` above keeps a stable size. +- Default animation durations (Compose's ~250 ms) are used; no custom spec needed. The hint inner + content (the `if (showFlipHint)` Row vs. empty) is preserved exactly as today. + +## Change 2: Scrollable card content (`FlipCard.kt` → `CardFace`) + +Wrap the face's `Column` so it scrolls when its content overflows the card, and stays centered +when it fits — the Compose analog of iOS's `minHeight: proxy.size.height`: + +```kotlin +@Composable +private fun CardFace(...) { + BoxWithConstraints(Modifier.fillMaxSize()) { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight) + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + // label, optional image, text, optional audio — unchanged content + } + } +} +``` + +- `heightIn(min = maxHeight)` (where `maxHeight` is the `BoxWithConstraints` viewport height) + forces the column to be at least viewport-tall, so `verticalArrangement = Arrangement.Center` + centers short content. When content exceeds the viewport, the column grows beyond it and + `verticalScroll` lets the user scroll. +- Applies to both faces (front question and back answer), so either a long question or a long + answer scrolls. +- **Gesture coexistence:** `AzriCard(onClick = onFlip)` (front, when not revealed) handles taps; + `verticalScroll` consumes vertical drags. A tap flips; a drag scrolls. No extra wiring. +- **Flip transform:** the card's `graphicsLayer { rotationY }` rotates around the Y axis, which + mirrors horizontally only — vertical scrolling inside the (counter-rotated, upright) back face + is unaffected. + +## Data flow + +No state or ViewModel changes. `state.isRevealed` / `state.showFlipHint` already drive the bottom +slot; they now drive `AnimatedVisibility` instead of an `if/else`. `CardFace` gains a local +`rememberScrollState()` — purely view-local. + +## Error handling + +Pure presentation. Short content centers (no scrollbar, nothing to scroll); long content scrolls. +No new I/O or failure modes. A card with no image/audio omits those as before. + +## Testing + +- Pure visual/animation + layout change with no new logic → **build-verified + `@Preview`**. +- Add a **long-text `FlipCard` preview** (a `front` with several paragraphs, `revealed = false`) + to visually confirm the content scrolls rather than clips. +- Existing `StudyViewModelTest` is unaffected (no state changes). +- The instrumented `StudyContentTest` remains valid: the rating buttons are still composed when + `revealed = true` (`AnimatedVisibility(visible = true)` renders its content), so any + reveal/rating assertions still hold. + +## Out of scope + +Changing the flip animation, the tap-to-flip hint, the rating logic, or the session summary; +horizontal scroll, pinch-zoom, scroll indicators/fade edges, or HTML/rich-text rendering of card +fields.