diff --git a/app/src/androidTest/java/nart/simpleanki/feature/cardform/CardFormContentTest.kt b/app/src/androidTest/java/nart/simpleanki/feature/cardform/CardFormContentTest.kt index 9bc9d33..61737ba 100644 --- a/app/src/androidTest/java/nart/simpleanki/feature/cardform/CardFormContentTest.kt +++ b/app/src/androidTest/java/nart/simpleanki/feature/cardform/CardFormContentTest.kt @@ -2,6 +2,7 @@ package nart.simpleanki.feature.cardform import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performTextInput import org.junit.Assert.assertEquals @@ -14,7 +15,7 @@ class CardFormContentTest { val composeRule = createComposeRule() @Test - fun newCard_showsReverseChip_andEditsInvokeCallbacks() { + fun newCard_showsReverseToggle_andEditsInvokeCallbacks() { var front = "" composeRule.setContent { CardFormContent( @@ -33,8 +34,8 @@ class CardFormContentTest { ) } composeRule.onNodeWithText("New card").assertIsDisplayed() - composeRule.onNodeWithText("Also create reverse card").assertExists() // FilterChip, new cards only - composeRule.onNodeWithText("Add image").assertExists() + composeRule.onNodeWithContentDescription("Also create reverse card").assertExists() // toggle, new cards only + composeRule.onNodeWithContentDescription("Add image").assertExists() composeRule.onNodeWithText("Front").performTextInput("hola") assertEquals("hola", front) } @@ -58,6 +59,50 @@ class CardFormContentTest { ) } composeRule.onNodeWithText("Edit card").assertIsDisplayed() - composeRule.onNodeWithText("Also create reverse card").assertDoesNotExist() + composeRule.onNodeWithContentDescription("Also create reverse card").assertDoesNotExist() + } + + @Test + fun newCard_reverseOn_showsHint() { + composeRule.setContent { + CardFormContent( + state = CardFormUiState(isEdit = false, createReverse = true), + onFrontChange = {}, + onBackChange = {}, + onSelectDeck = {}, + isRecording = false, + onToggleReverse = {}, + onAddImage = {}, + onRemoveImage = {}, + onToggleRecording = {}, + onRemoveAudio = {}, + onSave = {}, + onBack = {}, + ) + } + composeRule.onNodeWithText("A reverse card (Back → Front) will also be created.") + .assertExists() + } + + @Test + fun newCard_reverseOff_hidesHint() { + composeRule.setContent { + CardFormContent( + state = CardFormUiState(isEdit = false, createReverse = false), + onFrontChange = {}, + onBackChange = {}, + onSelectDeck = {}, + isRecording = false, + onToggleReverse = {}, + onAddImage = {}, + onRemoveImage = {}, + onToggleRecording = {}, + onRemoveAudio = {}, + onSave = {}, + onBack = {}, + ) + } + composeRule.onNodeWithText("A reverse card (Back → Front) will also be created.") + .assertDoesNotExist() } } diff --git a/app/src/main/java/nart/simpleanki/feature/cardform/CardFormScreen.kt b/app/src/main/java/nart/simpleanki/feature/cardform/CardFormScreen.kt index 50c53a9..7a098bb 100644 --- a/app/src/main/java/nart/simpleanki/feature/cardform/CardFormScreen.kt +++ b/app/src/main/java/nart/simpleanki/feature/cardform/CardFormScreen.kt @@ -8,12 +8,11 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -28,17 +27,18 @@ import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.SwapHoriz -import androidx.compose.material3.AssistChip +import androidx.compose.material3.BottomAppBar import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.IconToggleButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField @@ -47,7 +47,6 @@ import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState 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 @@ -65,6 +64,8 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat @@ -149,7 +150,7 @@ fun CardFormScreen( } /** Stateless card-form UI, decoupled from the ViewModel for testing. */ -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun CardFormContent( state: CardFormUiState, @@ -194,8 +195,66 @@ fun CardFormContent( Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, + ) + }, + bottomBar = { + BottomAppBar( + // imePadding() is on the bar (not the Scaffold/content) so it rises above the keyboard; + // edge-to-edge is enabled in MainActivity. Scaffold then pads content by the bar's height. + modifier = Modifier.imePadding(), actions = { - TextButton(onClick = onSave, enabled = state.canSave) { Text("Save") } + if (state.imageName == null) { + IconButton(onClick = onAddImage, enabled = !state.uploadingImage) { + if (state.uploadingImage) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Default.Image, contentDescription = "Add image") + } + } + } + if (state.audioName == null) { + IconButton(onClick = onToggleRecording, enabled = !state.uploadingAudio) { + when { + state.uploadingAudio -> + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + isRecording -> + Icon(Icons.Default.Stop, contentDescription = "Stop recording", tint = MaterialTheme.colorScheme.error) + else -> + Icon(Icons.Default.Mic, contentDescription = "Record audio") + } + } + } + if (!state.isEdit) { + IconToggleButton( + checked = state.createReverse, + onCheckedChange = onToggleReverse, + ) { + Icon( + if (state.createReverse) Icons.Default.Check else Icons.Default.SwapHoriz, + contentDescription = "Also create reverse card", + ) + } + } + }, + floatingActionButton = { + // M3 FABs have no `enabled` flag: show disabled via muted colors + a gated click. + val saveEnabled = state.canSave + FloatingActionButton( + onClick = { if (saveEnabled) onSave() }, + modifier = if (saveEnabled) Modifier else Modifier.semantics { disabled() }, + containerColor = if (saveEnabled) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + contentColor = if (saveEnabled) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + }, + ) { + Icon(Icons.Default.Check, contentDescription = "Save") + } }, ) }, @@ -297,59 +356,26 @@ fun CardFormContent( } } } - - // Attachment actions (Material chips) - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - if (state.imageName == null) { - AssistChip( - onClick = onAddImage, - enabled = !state.uploadingImage, - leadingIcon = { - if (state.uploadingImage) { - CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) - } else { - Icon(Icons.Default.Image, contentDescription = null, Modifier.height(18.dp)) - } - }, - label = { Text(if (state.uploadingImage) "Saving…" else "Add image") }, + // Reverse-card hint: shown while the bottom-bar reverse toggle is on (new cards only). + if (state.createReverse && !state.isEdit) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.SwapHoriz, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, ) - } - if (state.audioName == null) { - AssistChip( - onClick = onToggleRecording, - enabled = !state.uploadingAudio, - leadingIcon = { - when { - state.uploadingAudio -> CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) - isRecording -> Icon(Icons.Default.Stop, contentDescription = null, Modifier.height(18.dp), tint = MaterialTheme.colorScheme.error) - else -> Icon(Icons.Default.Mic, contentDescription = null, Modifier.height(18.dp)) - } - }, - label = { - Text( - when { - state.uploadingAudio -> "Saving…" - isRecording -> "Stop recording" - else -> "Record audio" - }, - ) - }, + Text( + "A reverse card (Back → Front) will also be created.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - if (!state.isEdit) { - FilterChip( - selected = state.createReverse, - onClick = { onToggleReverse(!state.createReverse) }, - label = { Text("Also create reverse card") }, - leadingIcon = if (state.createReverse) { - { Icon(Icons.Default.Check, contentDescription = null, Modifier.height(18.dp)) } - } else { - { Icon(Icons.Default.SwapHoriz, contentDescription = null, Modifier.height(18.dp)) } - }, - ) - } } } } @@ -416,3 +442,20 @@ private fun CardFormPickDeckSelectedPreview() { ) } } + +@Preview(name = "Card form · with attachments", showBackground = true) +@Composable +private fun CardFormWithAttachmentsPreview() { + AzriTheme { + CardFormContent( + state = CardFormUiState( + front = "dog", back = "perro", + imageName = "img.jpg", audioName = "audio.m4a", + ), + isRecording = false, + onFrontChange = {}, onBackChange = {}, onSelectDeck = {}, onToggleReverse = {}, + onAddImage = {}, onRemoveImage = {}, onToggleRecording = {}, onRemoveAudio = {}, + onSave = {}, onBack = {}, + ) + } +} diff --git a/app/src/main/java/nart/simpleanki/ui/navigation/AzriNavHost.kt b/app/src/main/java/nart/simpleanki/ui/navigation/AzriNavHost.kt index e89caff..968dc88 100644 --- a/app/src/main/java/nart/simpleanki/ui/navigation/AzriNavHost.kt +++ b/app/src/main/java/nart/simpleanki/ui/navigation/AzriNavHost.kt @@ -13,7 +13,10 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.height @@ -40,6 +43,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.navigation.NavBackStackEntry @@ -149,14 +153,30 @@ fun AzriNavHost() { val fadeThroughIn = fadeIn(tween(durationMillis = 195, delayMillis = 105, easing = FastOutSlowInEasing)) val fadeThroughOut = fadeOut(tween(durationMillis = 105, easing = FastOutSlowInEasing)) + // Apply the outer Scaffold's insets AND consume them, so each screen's own Scaffold + // (its TopAppBar's status-bar inset, its content's nav-bar inset) doesn't reserve + // them a second time. Without consuming: the status bar gets reserved twice (titles + // pushed down) and a nav-bar-height gap appears above the bottom bar. + // Exception: the card editor hosts its own BottomAppBar that must paint into the + // navigation-bar inset (else a blank strip shows below it). For that route we release + // the bottom inset — apply only top + horizontal — and let the editor's bar own the bottom. + val isCardEditor = currentRoute?.startsWith("cardForm") == true + val contentModifier = if (isCardEditor) { + val ld = LocalLayoutDirection.current + val topAndSides = PaddingValues( + start = padding.calculateStartPadding(ld), + end = padding.calculateEndPadding(ld), + top = padding.calculateTopPadding(), + bottom = 0.dp, + ) + Modifier.padding(topAndSides).consumeWindowInsets(topAndSides) + } else { + Modifier.padding(padding).consumeWindowInsets(padding) + } NavHost( navController = nav, startDestination = QUEUE, - // Apply the outer Scaffold's insets AND consume them, so each screen's own Scaffold - // (its TopAppBar's status-bar inset, its content's nav-bar inset) doesn't reserve - // them a second time. Without consuming: the status bar gets reserved twice (titles - // pushed down) and a nav-bar-height gap appears above the bottom bar. - modifier = Modifier.padding(padding).consumeWindowInsets(padding), + modifier = contentModifier, // Forward: incoming enters from the right (+30dp), outgoing exits left (−30dp). enterTransition = { slideInHorizontally(slideSpec) { slide } + fadeThroughIn }, exitTransition = { slideOutHorizontally(slideSpec) { -slide } + fadeThroughOut }, diff --git a/docs/superpowers/plans/2026-06-05-card-editor-bottom-toolbar.md b/docs/superpowers/plans/2026-06-05-card-editor-bottom-toolbar.md new file mode 100644 index 0000000..7e9cef9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-card-editor-bottom-toolbar.md @@ -0,0 +1,235 @@ +# Card Editor: Bottom App Bar — 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:** Move the card editor's Add image / Record audio / reverse-card actions into a stable Material `BottomAppBar`, with Save as the bar's primary FAB, floating above the keyboard. + +**Architecture:** Pure presentational change in the stateless `CardFormContent` (in `CardFormScreen.kt`): remove the top-bar Save button, remove the inline attachment chips + reverse `FilterChip`, and add a `Scaffold(bottomBar = { BottomAppBar(...) })`. No ViewModel/state changes; `CardFormContent`'s parameter list is unchanged. The androidTest assertions switch from chip text to icon `contentDescription`. + +**Tech Stack:** Kotlin, Jetpack Compose Material3 1.4.0 stable (`BottomAppBar`, `FloatingActionButton`, `IconToggleButton`, `Modifier.imePadding()`); Compose UI test (compile-verified — emulator unavailable). + +**Build/test prefix:** ALL Gradle commands MUST be prefixed with `export JAVA_HOME=/opt/homebrew/opt/openjdk &&` and run from `/Users/astemirboziev/Developer/SimpleAnkiProject/azri_android`. + +--- + +## File Structure + +- `app/src/main/java/nart/simpleanki/feature/cardform/CardFormScreen.kt` (modify) — remove top-bar Save; remove the `FlowRow` of `AssistChip`s and the reverse `FilterChip`; add `bottomBar` with the three actions + Save FAB; fix imports; add a preview. +- `app/src/androidTest/java/nart/simpleanki/feature/cardform/CardFormContentTest.kt` (modify) — chip-text assertions → `onNodeWithContentDescription`. + +Verification is build-only (no JVM logic to unit-test, emulator unavailable): both `:app:compileDebugKotlin` and `:app:compileDebugAndroidTestKotlin` must pass. + +--- + +## Task 1: Move editor actions into a BottomAppBar + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/feature/cardform/CardFormScreen.kt` +- Modify: `app/src/androidTest/java/nart/simpleanki/feature/cardform/CardFormContentTest.kt` + +- [ ] **Step 1: Update the androidTest assertions (chip text → contentDescription)** + +In `CardFormContentTest.kt`, add this import alongside the existing `androidx.compose.ui.test.*` imports: +```kotlin +import androidx.compose.ui.test.onNodeWithContentDescription +``` + +In `newCard_showsReverseChip_andEditsInvokeCallbacks`, replace the two chip-text assertions: +```kotlin + composeRule.onNodeWithText("Also create reverse card").assertExists() // FilterChip, new cards only + composeRule.onNodeWithText("Add image").assertExists() +``` +with contentDescription lookups (the buttons are now icons in the bottom bar): +```kotlin + composeRule.onNodeWithContentDescription("Also create reverse card").assertExists() // toggle, new cards only + composeRule.onNodeWithContentDescription("Add image").assertExists() +``` +(Leave the `onNodeWithText("New card")` title assertion and the `onNodeWithText("Front").performTextInput("hola")` line unchanged — the top-bar title and the Front field label are still text.) + +In `editCard_hidesReverseChip`, replace: +```kotlin + composeRule.onNodeWithText("Also create reverse card").assertDoesNotExist() +``` +with: +```kotlin + composeRule.onNodeWithContentDescription("Also create reverse card").assertDoesNotExist() +``` +(Leave `onNodeWithText("Edit card").assertIsDisplayed()` unchanged.) + +- [ ] **Step 2: Confirm the androidTest still compiles** + +This is a UI relocation verified by compilation (emulator unavailable), not runtime TDD. Confirm the updated test compiles: + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugAndroidTestKotlin` +Expected: BUILD SUCCESSFUL. (`onNodeWithContentDescription` is a valid API regardless of which nodes exist at runtime; the matching `contentDescription`s are added to the UI in Step 6.) + +- [ ] **Step 3: Fix imports in `CardFormScreen.kt`** + +Add these imports: +```kotlin +import androidx.compose.foundation.layout.imePadding +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.IconToggleButton +``` + +Remove these imports **after** Steps 4–6 confirm they are no longer referenced anywhere in the file (they are used only by the code you delete in Step 5): +```kotlin +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.material3.AssistChip +import androidx.compose.material3.FilterChip +import androidx.compose.material3.TextButton +``` + +Also change the opt-in annotation on `CardFormContent` from: +```kotlin +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +``` +to: +```kotlin +@OptIn(ExperimentalMaterial3Api::class) +``` +(`ExperimentalMaterial3Api` is still required by the deck `ExposedDropdownMenuBox`/`menuAnchor`; `ExperimentalLayoutApi` was only for `FlowRow`.) + +- [ ] **Step 4: Remove the Save button from the top app bar** + +In `CardFormContent`'s `TopAppBar`, delete the `actions` lambda so the top bar keeps only the title and back arrow. The `TopAppBar(...)` becomes: +```kotlin + TopAppBar( + title = { Text(if (state.isEdit) "Edit card" else "New card") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + ), + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) +``` +(i.e. remove the `actions = { TextButton(onClick = onSave, enabled = state.canSave) { Text("Save") } },` line entirely.) + +- [ ] **Step 5: Remove the inline attachment chips and the reverse FilterChip** + +In the content `Column`, delete the entire `FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { ... }` block (the one containing the "Add image" and "Record audio" `AssistChip`s) **and** the `if (!state.isEdit) { FilterChip( ... "Also create reverse card" ... ) }` block that follows it. + +Do NOT touch: the deck dropdown, the Front/Back `OutlinedTextField`s (and the Front `focusRequester`), the image-preview `Box` (with its remove `FilledIconButton`), or the "Audio attached" `Row` (with its remove `IconButton`). Those stay exactly as they are. + +- [ ] **Step 6: Add the `bottomBar` to the Scaffold** + +Add a `bottomBar` parameter to the `Scaffold(...)` in `CardFormContent` (alongside the existing `snackbarHost` and `topBar` params): +```kotlin + bottomBar = { + BottomAppBar( + modifier = Modifier.imePadding(), + actions = { + if (state.imageName == null) { + IconButton(onClick = onAddImage, enabled = !state.uploadingImage) { + if (state.uploadingImage) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Default.Image, contentDescription = "Add image") + } + } + } + if (state.audioName == null) { + IconButton(onClick = onToggleRecording, enabled = !state.uploadingAudio) { + when { + state.uploadingAudio -> + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + isRecording -> + Icon(Icons.Default.Stop, contentDescription = "Stop recording", tint = MaterialTheme.colorScheme.error) + else -> + Icon(Icons.Default.Mic, contentDescription = "Record audio") + } + } + } + if (!state.isEdit) { + IconToggleButton( + checked = state.createReverse, + onCheckedChange = onToggleReverse, + ) { + Icon( + if (state.createReverse) Icons.Default.Check else Icons.Default.SwapHoriz, + contentDescription = "Also create reverse card", + ) + } + } + }, + floatingActionButton = { + // M3 FABs have no `enabled` flag: show disabled via muted colors + a gated click. + val saveEnabled = state.canSave + FloatingActionButton( + onClick = { if (saveEnabled) onSave() }, + containerColor = if (saveEnabled) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + contentColor = if (saveEnabled) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + }, + ) { + Icon(Icons.Default.Check, contentDescription = "Save") + } + }, + ) + }, +``` + +- [ ] **Step 7: Add a preview exercising the bar with attachments** + +After the existing previews in `CardFormScreen.kt`, add: +```kotlin +@Preview(name = "Card form · with attachments", showBackground = true) +@Composable +private fun CardFormWithAttachmentsPreview() { + AzriTheme { + CardFormContent( + state = CardFormUiState( + front = "dog", back = "perro", + imageName = "img.jpg", audioName = "audio.m4a", + ), + isRecording = false, + onFrontChange = {}, onBackChange = {}, onSelectDeck = {}, onToggleReverse = {}, + onAddImage = {}, onRemoveImage = {}, onToggleRecording = {}, onRemoveAudio = {}, + onSave = {}, onBack = {}, + ) + } +} +``` +(This state has an image and audio attached, so the inline preview/remove blocks show and the bar's Add-image / Record-audio icons are correctly hidden — verifying the conditional logic. The existing previews are unchanged.) + +- [ ] **Step 8: Verify both compile targets** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin :app:compileDebugAndroidTestKotlin` +Expected: BUILD SUCCESSFUL for both. (If Kotlin reports an unused-import error or a still-referenced symbol you tried to delete, reconcile the import list per Step 3.) + +- [ ] **Step 9: Verify the full unit-test suite + debug APK still build** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest :app:assembleDebug` +Expected: BUILD SUCCESSFUL; all unit tests pass (this change has no unit-test impact, but confirm no regression). + +- [ ] **Step 10: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/feature/cardform/CardFormScreen.kt \ + app/src/androidTest/java/nart/simpleanki/feature/cardform/CardFormContentTest.kt +git commit -m "Move card-editor actions into a bottom app bar" +``` +IMPORTANT: Do NOT mention "claude" in the commit message and do NOT add any Co-Authored-By / attribution trailer. Do NOT `git add` other files (there is an unrelated untracked docs plan file in the tree — leave it). + +--- + +## Manual verification (optional, if an emulator is available) + +Open the editor (Study tab → all caught up → Add more cards, or deck-detail → Add card): +- A bottom bar shows **Add image**, **Record audio**, and (new cards only) a **reverse** toggle on the left, with a **Save** FAB on the right. +- The bar floats **above the keyboard** (Front autofocuses, keyboard up, bar still visible). +- Save FAB is **muted/disabled** until Front+Back (and a deck in picker mode) are filled. +- Attach an image → the Add-image icon disappears and the inline image preview + remove appears. +- Start recording → the mic icon becomes a red **Stop** icon; stopping attaches audio and hides the icon. +- Save a new card → form clears, the bar's icons reappear, Front re-focuses. diff --git a/docs/superpowers/plans/2026-06-05-reverse-card-hint.md b/docs/superpowers/plans/2026-06-05-reverse-card-hint.md new file mode 100644 index 0000000..3eb131a --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-reverse-card-hint.md @@ -0,0 +1,148 @@ +# Card Editor: Reverse-Card Hint 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:** Show a persistent inline caption ("A reverse card (Back → Front) will also be created.") in the card editor while the bottom-bar reverse toggle is on. + +**Architecture:** Add one conditional `Row` as the last child of `CardFormContent`'s form `Column`, gated by `state.createReverse && !state.isEdit`. No state changes — `createReverse` already exists. Add an androidTest assertion. + +**Tech Stack:** Kotlin, Jetpack Compose Material3 1.4.0; Compose UI test (compile-verified; optionally run on the connected emulator). + +**Build/test prefix:** ALL Gradle commands MUST be prefixed with `export JAVA_HOME=/opt/homebrew/opt/openjdk &&` and run from `/Users/astemirboziev/Developer/SimpleAnkiProject/azri_android`. + +--- + +## File Structure + +- `app/src/main/java/nart/simpleanki/feature/cardform/CardFormScreen.kt` (modify) — add the hint `Row`. +- `app/src/androidTest/java/nart/simpleanki/feature/cardform/CardFormContentTest.kt` (modify) — assert the hint shows when reverse is on and is hidden otherwise. + +All referenced Compose symbols (`Row`, `Arrangement`, `Alignment`, `Icon`, `Icons.Default.SwapHoriz`, `Modifier.size`, `Text`, `MaterialTheme`) are already imported in `CardFormScreen.kt` — no new imports. + +--- + +## Task 1: Add the reverse-card hint + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/feature/cardform/CardFormScreen.kt` +- Modify: `app/src/androidTest/java/nart/simpleanki/feature/cardform/CardFormContentTest.kt` + +- [ ] **Step 1: Add the test assertions** + +In `CardFormContentTest.kt`, add this new test inside `class CardFormContentTest` (after the existing tests): + +```kotlin + @Test + fun newCard_reverseOn_showsHint() { + composeRule.setContent { + CardFormContent( + state = CardFormUiState(isEdit = false, createReverse = true), + onFrontChange = {}, + onBackChange = {}, + onSelectDeck = {}, + isRecording = false, + onToggleReverse = {}, + onAddImage = {}, + onRemoveImage = {}, + onToggleRecording = {}, + onRemoveAudio = {}, + onSave = {}, + onBack = {}, + ) + } + composeRule.onNodeWithText("A reverse card (Back → Front) will also be created.") + .assertExists() + } + + @Test + fun newCard_reverseOff_hidesHint() { + composeRule.setContent { + CardFormContent( + state = CardFormUiState(isEdit = false, createReverse = false), + onFrontChange = {}, + onBackChange = {}, + onSelectDeck = {}, + isRecording = false, + onToggleReverse = {}, + onAddImage = {}, + onRemoveImage = {}, + onToggleRecording = {}, + onRemoveAudio = {}, + onSave = {}, + onBack = {}, + ) + } + composeRule.onNodeWithText("A reverse card (Back → Front) will also be created.") + .assertDoesNotExist() + } +``` + +(`onNodeWithText` is already imported in this test file; `CardFormUiState` is in the same package, no import needed.) + +- [ ] **Step 2: Confirm the androidTest compiles** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugAndroidTestKotlin` +Expected: BUILD SUCCESSFUL. (The hint string node is added to the UI in Step 3; this confirms the test compiles.) + +- [ ] **Step 3: Add the hint Row to `CardFormContent`** + +In `CardFormScreen.kt`, the form `Column` currently ends with the "Audio attached" block: +```kotlin + // Audio attached + if (state.audioName != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + AudioPlayButton(state.audioName, state.audioPath) + Text("Audio attached", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + IconButton(onClick = onRemoveAudio) { + Icon(Icons.Default.Delete, contentDescription = "Remove audio", tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +``` +Immediately **after** that `if (state.audioName != null) { ... }` block (and still inside the `Column`), add the hint as the Column's last child: +```kotlin + // Reverse-card hint: shown while the bottom-bar reverse toggle is on (new cards only). + if (state.createReverse && !state.isEdit) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.SwapHoriz, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + "A reverse card (Back → Front) will also be created.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +``` + +- [ ] **Step 4: Verify both compile targets** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin :app:compileDebugAndroidTestKotlin` +Expected: BUILD SUCCESSFUL for both. + +- [ ] **Step 5: Run the unit-test suite + debug APK (no regression)** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest :app:assembleDebug` +Expected: BUILD SUCCESSFUL; all unit tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/feature/cardform/CardFormScreen.kt \ + app/src/androidTest/java/nart/simpleanki/feature/cardform/CardFormContentTest.kt +git commit -m "Show a hint when a reverse card will be created" +``` +IMPORTANT: Do NOT mention "claude" in the commit message and do NOT add any Co-Authored-By / attribution trailer. Do NOT `git add` other files (there is an unrelated untracked docs plan file in the tree — leave it). + +--- + +## Manual verification (optional, emulator connected) + +Open the editor (Study tab → Add more cards). Tap the **reverse toggle** (the swap icon in the bottom bar): the caption "A reverse card (Back → Front) will also be created." appears at the bottom of the form; tapping it off removes the caption. Saving a new card (which resets `createReverse`) also clears it. diff --git a/docs/superpowers/specs/2026-06-05-card-editor-bottom-toolbar-design.md b/docs/superpowers/specs/2026-06-05-card-editor-bottom-toolbar-design.md new file mode 100644 index 0000000..1f5ef6d --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-card-editor-bottom-toolbar-design.md @@ -0,0 +1,178 @@ +# Card Editor: Move Actions into a Bottom App Bar — Design + +**Date:** 2026-06-05 +**Status:** Approved (design); pending implementation plan +**Branch:** `feature/card-editor-bottom-toolbar` — **stacked on `feature/card-editor-deck-selector` +(PR #17, still open at design time)**, which adds the deck-picker mode to the same `CardFormScreen`. +Retarget this PR onto `main` once #17 merges. + +## Goal + +Relocate the card editor's three attachment/setting actions — **Add image**, **Record audio**, and +**Also create reverse card** — out of inline chips in the scrollable form and into a Material +**`BottomAppBar`** at the bottom of the editor. Promote **Save** to the bar's primary-action FAB. The +bar floats above the keyboard so the actions stay reachable while typing. + +## Background: current state + +In `CardFormScreen.kt`, the stateless `CardFormContent` renders, inside a scrolling `Column`: +- The deck dropdown (picker mode), Front/Back fields. +- An inline **image preview** (with a remove button) when `state.imageName != null`. +- An inline **"Audio attached"** row (with a remove button) when `state.audioName != null`. +- A `FlowRow` of `AssistChip`s: **"Add image"** (hidden once an image is attached; + shows a spinner while `uploadingImage`) and **"Record audio"** (hidden once audio is attached; + Mic / Stop-while-recording / spinner-while-uploading). +- A reverse `FilterChip` **"Also create reverse card"** (only when `!state.isEdit`). +- **Save** is a `TextButton` in the top app bar `actions`, gated by `state.canSave`. + +`CardFormContent` already receives every callback we need: `onAddImage`, `onToggleRecording` +(plus `isRecording`), `onToggleReverse: (Boolean) -> Unit`, `onRemoveImage`, `onRemoveAudio`, +`onSave`, `onBack`, `onSelectDeck`. `MainActivity` calls `enableEdgeToEdge()`, so `Modifier.imePadding()` +correctly lifts content above the IME. + +## Decisions (from brainstorming) + +- **Use the stable `androidx.compose.material3` `BottomAppBar`** (a *docked* bottom app bar), NOT the + M3 Expressive *floating* toolbar. The Compose `HorizontalFloatingToolbar` is only in a + `1.5.0-alpha` line; our BOM (`2025.10.00`) pins material3 to **1.4.0**, which ships only the + floating-toolbar tokens, not the composable. (MDC-Android `1.14.0`'s `FloatingToolbarLayout` is a + *View*, irrelevant to this all-Compose screen.) `BottomAppBar` needs no dependency change. +- **All three actions move into the bar.** Reverse becomes an `IconToggleButton` (new cards only). +- **Save becomes the bar's `floatingActionButton`** (primary action, right side); removed from the + top app bar. +- **The bar floats above the keyboard** via `Modifier.imePadding()`. +- **Image/audio icons stay conditional** — hidden once attached (mirroring today's chips); the + inline preview/remove blocks remain the way to change/remove an attachment. + +## Scope + +**One production file: `CardFormScreen.kt`.** No ViewModel/state changes; `CardFormUiState` and the +`CardFormContent` parameter list are **unchanged** (every needed callback already exists). Plus an +update to the existing `CardFormContentTest` assertions. + +## Components + +### Top app bar (`CardFormContent`) +- **Remove** the `Save` `TextButton` from `actions`. The top bar keeps the title + (`if (state.isEdit) "Edit card" else "New card"`) and the back `IconButton`. (The `actions = { … }` + lambda becomes empty and is dropped.) + +### Content `Column` (`CardFormContent`) +- **Remove** the `FlowRow` of `AssistChip`s ("Add image", "Record audio") and the reverse + `FilterChip` ("Also create reverse card"). +- **Keep unchanged:** the deck dropdown, Front/Back fields (incl. the Front `FocusRequester` + autofocus), the inline image preview + remove, and the inline "Audio attached" row + remove. + +### New `Scaffold(bottomBar = { … })` +Add a `bottomBar` to the existing `Scaffold` in `CardFormContent`: + +```kotlin +bottomBar = { + BottomAppBar( + modifier = Modifier.imePadding(), + actions = { + if (state.imageName == null) { + IconButton(onClick = onAddImage, enabled = !state.uploadingImage) { + if (state.uploadingImage) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Default.Image, contentDescription = "Add image") + } + } + } + if (state.audioName == null) { + IconButton(onClick = onToggleRecording, enabled = !state.uploadingAudio) { + when { + state.uploadingAudio -> CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + isRecording -> Icon(Icons.Default.Stop, contentDescription = "Stop recording", tint = MaterialTheme.colorScheme.error) + else -> Icon(Icons.Default.Mic, contentDescription = "Record audio") + } + } + } + if (!state.isEdit) { + IconToggleButton( + checked = state.createReverse, + onCheckedChange = onToggleReverse, + ) { + Icon( + if (state.createReverse) Icons.Default.Check else Icons.Default.SwapHoriz, + contentDescription = "Also create reverse card", + ) + } + } + }, + floatingActionButton = { + // M3 FABs have no `enabled` flag: show the disabled state via muted colors + a gated click. + val saveEnabled = state.canSave + FloatingActionButton( + onClick = { if (saveEnabled) onSave() }, + containerColor = if (saveEnabled) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + contentColor = if (saveEnabled) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + }, + ) { + Icon(Icons.Default.Check, contentDescription = "Save") + } + }, + ) +} +``` + +Behavior is preserved exactly: the image/audio icons appear/hide and reflect uploading/recording +state as the chips do today; Save remains gated by `canSave` (now via the FAB's muted-disabled +treatment). The "Card saved" snackbar and the post-save reset are unchanged — after a new-card save, +`imageName`/`audioName`/`createReverse` clear, so the icons reappear automatically. + +### Imports to add (`CardFormScreen.kt`) +`androidx.compose.material3.BottomAppBar`, `androidx.compose.material3.FloatingActionButton`, +`androidx.compose.material3.IconToggleButton`, `androidx.compose.foundation.layout.imePadding`. +(`size`, `IconButton`, `CircularProgressIndicator`, and the `Icons.Default.*` icons are already +imported — used by the existing chips/remove buttons — so they need no new import.) Remove +now-unused imports (`AssistChip`, `FilterChip`, `TextButton`, `FlowRow`, and the +`ExperimentalLayoutApi` opt-in on `CardFormContent`) **only if** no longer referenced anywhere in +the file — verify before deleting. + +## Data flow + +Unchanged. The bar's buttons invoke the same callbacks the chips did +(`onAddImage`/`onToggleRecording`/`onToggleReverse`/`onSave`); state still flows from +`CardFormViewModel` through `CardFormUiState`. + +## Error handling + +- Save FAB is a no-op while `!canSave` (gated `onClick`) and is visibly muted — no invalid save. +- `imePadding()` relies on the already-enabled edge-to-edge setup; if the IME inset weren't + available the bar would simply dock at the bottom (graceful, no crash). +- Uploading/recording disabled states carry over from the chips (`enabled = !uploading…`). + +## Testing + +- **`CardFormContentTest`** (androidTest, compile-verified — emulator unavailable): the two existing + tests assert on chip **text** (`onNodeWithText("Add image")`, `onNodeWithText("Also create reverse + card")`). These become icon buttons, so update those assertions to + `onNodeWithContentDescription("Add image")` / `onNodeWithContentDescription("Also create reverse + card")` (exists / does-not-exist in edit mode). `CardFormContent`'s signature is unchanged, so no + other call sites change. +- **Previews:** existing previews keep compiling (same signature) and now render the bottom bar. Add + one `@Preview` exercising the bar with an attached image + audio (so the inline remove blocks show + and the corresponding bar icons are hidden) to visually verify the conditional logic. +- **Manual (if emulator available):** open editor → bar shows Add image / Record audio / reverse + toggle + Save FAB, floating above the keyboard; Save muted until valid; attach image → image icon + hides, inline preview+remove appears; record audio → Stop icon (error tint) while recording. + +**Build/test prefix:** Gradle commands MUST be prefixed with +`export JAVA_HOME=/opt/homebrew/opt/openjdk &&`, run from +`/Users/astemirboziev/Developer/SimpleAnkiProject/azri_android`. + +## Out of scope + +- The M3 Expressive *floating* toolbar / any `androidx.compose.material3` alpha bump. +- MDC-Android `FloatingToolbarLayout` (View) interop. +- Any change to attach/record/save logic, deck-picker mode, reverse-card creation, or sync. +- Changing how attachments are removed (the inline preview/remove blocks stay as-is). diff --git a/docs/superpowers/specs/2026-06-05-reverse-card-hint-design.md b/docs/superpowers/specs/2026-06-05-reverse-card-hint-design.md new file mode 100644 index 0000000..1287401 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-reverse-card-hint-design.md @@ -0,0 +1,79 @@ +# Card Editor: Reverse-Card Hint — Design + +**Date:** 2026-06-05 +**Status:** Approved (design); pending implementation plan +**Branch:** `feature/card-editor-bottom-toolbar` (joins the open PR #18 — completes the reverse-toggle UX from that change). + +## Goal + +When the reverse-card toggle in the editor's bottom app bar is **on**, show a small persistent +inline hint in the form so the user knows a second (reversed) card will be created. Moving the +"Also create reverse card" control from a labeled `FilterChip` to an icon-only `IconToggleButton` +(PR #18) removed its text; this restores that meaning. + +## Background + +In `CardFormScreen.kt`, `CardFormContent` renders the form `Column` (deck dropdown, Front, Back, +optional image-preview block, optional audio block). The reverse toggle is an `IconToggleButton` +in the `BottomAppBar`, shown only for new cards (`!state.isEdit`), bound to `state.createReverse` +via `onToggleReverse`. `createReverse` already exists in `CardFormUiState` and is cleared on the +post-save reset. No state changes are needed. + +## Decision + +A **persistent inline caption** (chosen over a transient snackbar or a tooltip): clearest, shows the +ongoing state, and sits right above the bar where the toggle lives. + +## Component + +In `CardFormContent`, add as the **last child** of the form `Column` (after the audio block): + +```kotlin +if (state.createReverse && !state.isEdit) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.SwapHoriz, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + "A reverse card (Back → Front) will also be created.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} +``` + +- Guarded by `!state.isEdit` for clarity (the toggle — and thus `createReverse == true` — only + occurs on new cards anyway). +- All referenced symbols (`Row`, `Arrangement`, `Alignment`, `Icon`, `Icons.Default.SwapHoriz`, + `Modifier.size`, `Text`, `MaterialTheme`) are already imported in the file — no new imports. + +## Data flow + +`onToggleReverse(true)` → `state.createReverse = true` → the caption composes; toggling off or the +post-save reset (`createReverse = false`) removes it. Unchanged elsewhere. + +## Testing + +- **`CardFormContentTest`** (androidTest; compile-verified, optionally run on the connected + emulator): add a test that a new-card `CardFormContent` with `createReverse = true` displays the + hint text (`onNodeWithText` substring "reverse card … will also be created" — assert the exact + string used above), and that it is absent when `createReverse = false`. +- **Preview:** the existing `CardFormNewPreview` already sets `createReverse = true`, so it renders + the hint with no new preview needed. + +**Build/test prefix:** Gradle commands MUST be prefixed with +`export JAVA_HOME=/opt/homebrew/opt/openjdk &&`, run from +`/Users/astemirboziev/Developer/SimpleAnkiProject/azri_android`. + +## Out of scope + +- Any change to reverse-card creation logic, the toggle itself, state, or sync. +- A snackbar/tooltip variant (explicitly not chosen). +- Showing the hint in edit mode (reverse toggling is new-cards-only).