From b1a2b32dd90c0188dc46190b0801f4fdb5462d26 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Fri, 5 Jun 2026 12:53:18 +0400 Subject: [PATCH 1/6] Add design spec for in-editor deck selector + autofocus --- ...-06-05-card-editor-deck-selector-design.md | 274 ++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-05-card-editor-deck-selector-design.md diff --git a/docs/superpowers/specs/2026-06-05-card-editor-deck-selector-design.md b/docs/superpowers/specs/2026-06-05-card-editor-deck-selector-design.md new file mode 100644 index 0000000..e65f5b8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-card-editor-deck-selector-design.md @@ -0,0 +1,274 @@ +# Card Editor: In-Editor Deck Selector + Autofocus — Design + +**Date:** 2026-06-05 +**Status:** Approved (design); pending implementation plan +**Branch:** `feature/card-editor-deck-selector` (off `main`). + +## Goal + +Let users add cards straight from the Study tab without detouring through the Library. The +empty-state **"Add more cards"** button opens the card editor directly; that editor gains a **deck +selector** so the user chooses the destination deck in place. The Front field is **autofocused** on +open and re-focused after each save, so the user can type immediately. On a new-card save the form +clears but the selected deck is preserved; leaving the editor resets the selection. + +## Background: current state + +- The card editor (`CardFormScreen` / `CardFormViewModel`) takes a **fixed** `deckId` supplied at + construction via `CardFormArgs`, routed through `cardForm/{deckId}` (add) and + `cardForm/{deckId}/{cardId}` (edit). There is no deck selector and no autofocus. +- On a **new-card** save the VM already keeps the editor open for rapid entry: it replaces state + with a fresh `CardFormUiState(savedTick = savedTick + 1)` (clearing inputs) and re-fires a "Card + saved" snackbar. Editing an existing card instead sets `finished = true` and closes. +- `StudyQueueScreen`'s empty `HeroCard` has **two** buttons, both calling `onGoToLibrary`: + - Brand-new user (no decks/cards, `!hasWork && !hasAnyCards`): **"Go to Library"**. + - All-caught-up (`!hasWork` but has cards): **"Add more cards"**. +- `DeckRepository.observeDecks(): Flow>` already exposes a live deck list. +- An existing dropdown idiom lives in `ApkgImportScreen.FieldDropdown` / + `CsvImportScreen`: `ExposedDropdownMenuBox` + read-only `OutlinedTextField` with + `Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable)` + `DropdownMenuItem` per option. We + mirror it. (Material3 from `composeBom = 2025.10.00`.) + +## Decisions (from brainstorming) + +- **Deck selector appears on the queue path only.** Entering the editor from a deck + (`deck-detail → Add card`, the `cardForm/{deckId}` route) keeps its fixed deck and shows **no** + selector — current behavior unchanged. The selector appears only when the editor is opened with + no deck context (the new `cardForm` route). +- **No pre-selection.** Picker mode starts with no deck chosen; the user must pick a deck before the + first save. (Front still autofocuses so they can type the card while the deck is unpicked.) +- **Brand-new empty state stays on Library.** Only the "Add more cards" button is repointed at the + editor; "Go to Library" is unchanged, because a brand-new user has no deck to add a card to. + +## Components + +### 1. Navigation (`ui/navigation/AzriNavHost.kt`) + +- Add a **new no-arg route** `"cardForm"` (distinct pattern from `cardForm/{deckId}`): + ```kotlin + composable("cardForm") { + CardFormScreen(deckId = null, cardId = null, onClose = { nav.popBackStack() }) + } + ``` +- In the `StudyQueueScreen(...)` call, add `onAddCards = { nav.navigate("cardForm") }`. + +`CardFormScreen`'s `deckId` parameter becomes **nullable** (`deckId: String?`) and is forwarded into +`CardFormArgs`. + +### 2. Empty state button (`feature/queue/StudyQueueScreen.kt`) + +- `StudyQueueScreen` gains `onAddCards: () -> Unit = {}`; pass it through to `StudyQueueContent` + and into `HeroCard`. +- `HeroCard` gains an `onAddCards: () -> Unit` parameter. The **all-caught-up** branch's + "Add more cards" `OutlinedButton` switches its `onClick` from `onGoToLibrary` to `onAddCards`. + The brand-new branch's "Go to Library" button keeps `onGoToLibrary`. +- `StudyQueueContent`'s signature adds `onAddCards: () -> Unit = {}` and forwards it to `HeroCard`. + +### 3. `CardFormArgs` + Koin (`di/AppModule.kt`) + +- `data class CardFormArgs(val deckId: String?, val cardId: String? = null)` — `deckId` now + nullable. +- The Koin `viewModel { params -> ... }` block passes `deckRepository = get()`: + ```kotlin + viewModel { params -> + val a = params.get() + CardFormViewModel( + deckId = a.deckId, + cardRepository = get(), + mediaManager = get(), + deckRepository = get(), + editingCardId = a.cardId, + logManager = get(), + ) + } + ``` + +### 4. `CardFormViewModel` (`feature/cardform/CardFormViewModel.kt`) + +New lightweight option type (same file): +```kotlin +data class DeckOption(val id: String, val name: String) +``` + +`CardFormUiState` gains three fields: +```kotlin +val pickDeck: Boolean = false, // true ⇒ show the deck selector (queue path) +val decks: List = emptyList(), +val selectedDeckId: String? = null, +``` +`canSave` adds a deck requirement: +```kotlin +val canSave: Boolean + get() = front.isNotBlank() && back.isNotBlank() && + !uploadingImage && !uploadingAudio && selectedDeckId != null +``` +(In fixed-deck mode `selectedDeckId` is non-null from the start, so behavior is unchanged.) + +Constructor: `deckId` becomes nullable, and a `deckRepository: DeckRepository? = null` is added +(only consulted in picker mode; defaulted to `null` so the existing fixed-deck call sites and tests +need no change — Koin always supplies a real instance): +```kotlin +class CardFormViewModel( + private val deckId: String?, + private val cardRepository: CardRepository, + private val mediaManager: MediaManager, + private val deckRepository: DeckRepository? = null, + private val editingCardId: String? = null, + private val idGenerator: () -> String = { UUID.randomUUID().toString() }, + private val now: () -> Long = { System.currentTimeMillis() }, + private val logManager: LogManager = LogManager(emptyList()), +) : ViewModel() { +``` + +Initial state and init: +```kotlin +private val _uiState = MutableStateFlow( + CardFormUiState( + isEdit = editingCardId != null, + pickDeck = deckId == null, + selectedDeckId = deckId, + ), +) +``` +```kotlin +init { + if (editingCardId != null) { /* existing async card load, unchanged */ } + if (deckId == null) { + viewModelScope.launch { + deckRepository?.observeDecks()?.collect { decks -> + _uiState.value = _uiState.value.copy( + decks = decks.map { DeckOption(it.id, it.name) }, + ) + } + } + } +} +``` + +New action: +```kotlin +fun onSelectDeck(id: String) { + _uiState.value = _uiState.value.copy(selectedDeckId = id) +} +``` + +`newCard` uses the selected deck (guaranteed non-null because `canSave` gates `save()`): +```kotlin +private fun newCard(...): Card { + val t = now() + return Card( + id = id, front = front, back = back, deckId = _uiState.value.selectedDeckId!!, + ... + ) +} +``` +(Equivalently capture `state.selectedDeckId!!` into a local at the top of `save()` and thread it +through; pick whichever keeps `newCard` clean. The point: persist to the selected deck, not the +constructor arg.) + +New-card reset (in `save()`) preserves the deck context: +```kotlin +_uiState.value = CardFormUiState( + isEdit = false, + savedTick = state.savedTick + 1, + pickDeck = state.pickDeck, + decks = state.decks, + selectedDeckId = state.selectedDeckId, +) +``` + +**Reset on dismiss** needs no code: pressing back pops the `cardForm` route, the VM is cleared, and +the next open recreates it with `selectedDeckId = null`. + +### 5. Deck selector UI + autofocus (`feature/cardform/CardFormScreen.kt`) + +`CardFormContent` gains one new callback param `onSelectDeck: (String) -> Unit` (the deck list and +selection are read from `state`). + +**Deck dropdown** — rendered as the **first** item in the form `Column` (above the Front field), +only when `state.pickDeck`: +```kotlin +if (state.pickDeck) { + var expanded by remember { mutableStateOf(false) } + val selectedName = state.decks.firstOrNull { it.id == state.selectedDeckId }?.name ?: "" + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = selectedName, + onValueChange = {}, + readOnly = true, + label = { Text("Deck") }, + placeholder = { Text("Select a deck") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + state.decks.forEach { deck -> + DropdownMenuItem( + text = { Text(deck.name) }, + onClick = { onSelectDeck(deck.id); expanded = false }, + ) + } + } + } +} +``` + +**Autofocus** — a `FocusRequester` on the Front `OutlinedTextField`: +```kotlin +val frontFocus = remember { FocusRequester() } +LaunchedEffect(Unit) { runCatching { frontFocus.requestFocus() } } +LaunchedEffect(state.savedTick) { if (state.savedTick > 0) runCatching { frontFocus.requestFocus() } } +``` +Attach `.focusRequester(frontFocus)` to the Front field's `Modifier`. (`runCatching` guards the rare +"FocusRequester not attached yet" race so it can never crash; a missed focus is harmless.) These +effects live in `CardFormContent`; the existing snackbar `LaunchedEffect(state.savedTick)` stays in +`CardFormScreen` — both keyed on `savedTick`, independent. + +`CardFormScreen` forwards `onSelectDeck = viewModel::onSelectDeck` into `CardFormContent`. + +## Data flow + +Study tab, all caught up → tap **"Add more cards"** → `nav.navigate("cardForm")` → +`CardFormScreen(deckId = null)` → VM enters picker mode, observes decks, Front autofocuses → user +types Front/Back, picks a deck (Save enabled) → save persists the card to the selected deck → +form clears, deck stays, Front re-focuses for the next card → back pops the route, selection resets. + +## Error handling + +- Picker mode with an empty deck list can't happen via this entry: "Add more cards" only shows when + the user already has cards (hence ≥1 deck). If the list is somehow empty, Save stays disabled + (no `selectedDeckId`) — safe, no crash. +- `frontFocus.requestFocus()` is wrapped in `runCatching` so a not-yet-attached requester is a + no-op rather than a crash. +- Fixed-deck mode is untouched: `selectedDeckId` is non-null from construction, the selector never + renders, and `deckRepository` is never consulted. + +## Testing + +- **`CardFormViewModelTest` (JVM, `FakeCardDao` + `FakeDeckDao`):** + - Picker mode (`deckId = null`): seeded decks appear in `state.decks` as `DeckOption`s; + `pickDeck == true`; `canSave` is false with Front+Back filled but no deck; selecting a deck + flips `canSave` true; `save()` persists the card to the selected deck. + - After a picker-mode save: `front`/`back` cleared, `savedTick` bumped, and `pickDeck`/`decks`/ + `selectedDeckId` preserved. + - Fixed-deck regression: existing tests (constructing `CardFormViewModel("d1", repo, media(), …)`) + stay green untouched — `selectedDeckId` defaults to the arg, `pickDeck == false`, `canSave` + needs no manual pick. +- **Display:** `@Preview`s for picker mode — (a) no deck selected (Save disabled, placeholder + "Select a deck"), (b) a deck selected. Compose screens are compile/preview-verified; the emulator + is unavailable. + +**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 + +- Showing the deck selector in fixed-deck mode (deck-detail "Add card") — intentionally unchanged. +- Remembering a default/last-used deck across sessions (decided: no pre-selection). +- Repointing the brand-new "Go to Library" button at the editor / a no-decks empty state inside the + editor. +- Any change to edit-mode behavior, reverse-card creation, media attachment, or sync. From 7bf8760c694bec88ed35ecdc36b8d8fa100cc96e Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Fri, 5 Jun 2026 12:59:00 +0400 Subject: [PATCH 2/6] Add implementation plan for in-editor deck selector + autofocus --- .../2026-06-05-card-editor-deck-selector.md | 568 ++++++++++++++++++ 1 file changed, 568 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-05-card-editor-deck-selector.md diff --git a/docs/superpowers/plans/2026-06-05-card-editor-deck-selector.md b/docs/superpowers/plans/2026-06-05-card-editor-deck-selector.md new file mode 100644 index 0000000..ae2ae53 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-card-editor-deck-selector.md @@ -0,0 +1,568 @@ +# Card Editor: In-Editor Deck Selector + Autofocus — 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:** Let users add cards straight from the Study tab's "Add more cards" button into a card editor that has an in-place deck selector, with the Front field autofocused on open and after each save. + +**Architecture:** A new no-arg `cardForm` nav route opens `CardFormScreen` with `deckId = null`, putting `CardFormViewModel` into "picker mode": it observes `DeckRepository.observeDecks()`, requires a deck selection before save, and preserves the chosen deck across rapid-entry saves. The Front field uses a `FocusRequester` re-fired on open and on each `savedTick` bump. Fixed-deck mode (deck-detail "Add card") is unchanged. + +**Tech Stack:** Kotlin, Jetpack Compose Material3 (`ExposedDropdownMenuBox`, `FocusRequester`), Koin (`viewModel { parametersOf(...) }`), navigation-compose, JUnit4 + coroutines-test. + +**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 + +- `feature/cardform/CardFormViewModel.kt` (modify) — `DeckOption` type; `pickDeck`/`decks`/`selectedDeckId` state; nullable `deckId`; `deckRepository`; deck loading; `onSelectDeck`; `canSave` deck gate; `save()` uses selected deck + preserves it on reset. +- `app/src/test/.../feature/cardform/CardFormViewModelTest.kt` (modify) — picker-mode tests. +- `di/AppModule.kt` (modify) — `CardFormArgs.deckId` nullable; pass `deckRepository` into the VM. +- `feature/cardform/CardFormScreen.kt` (modify) — nullable `deckId`; deck dropdown; Front autofocus; `onSelectDeck` wiring; previews. +- `ui/navigation/AzriNavHost.kt` (modify) — new `cardForm` route; pass `onAddCards` to `StudyQueueScreen`. +- `feature/queue/StudyQueueScreen.kt` (modify) — `onAddCards` param threaded to `HeroCard`; "Add more cards" button uses it. + +--- + +## Task 1: CardFormViewModel — picker mode + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/feature/cardform/CardFormViewModel.kt` +- Test: `app/src/test/java/nart/simpleanki/feature/cardform/CardFormViewModelTest.kt` + +- [ ] **Step 1: Write the failing tests** + +Add these imports to the top of `CardFormViewModelTest.kt` (after the existing imports): + +```kotlin +import nart.simpleanki.core.data.repository.DeckRepository +import nart.simpleanki.core.data.repository.FakeDeckDao +import nart.simpleanki.core.domain.model.Deck +``` + +Add these tests inside `class CardFormViewModelTest`: + +```kotlin +@Test +fun pickerMode_loadsDecks_andRequiresDeckBeforeSave() = runTest { + val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) + deckRepo.upsert(Deck(id = "d1", name = "French", dateCreated = now, lastModified = now)) + deckRepo.upsert(Deck(id = "d2", name = "Spanish", dateCreated = now, lastModified = now)) + val cardRepo = CardRepository(FakeCardDao(), now = { now }) + val vm = CardFormViewModel( + deckId = null, cardRepository = cardRepo, mediaManager = media(), + deckRepository = deckRepo, now = { now }, + ) + runCurrent() + + assertTrue(vm.uiState.value.pickDeck) + assertEquals(listOf("French", "Spanish"), vm.uiState.value.decks.map { it.name }) + + // Front + Back filled, but no deck chosen yet → cannot save. + vm.onFrontChange("bonjour"); vm.onBackChange("hello") + assertFalse(vm.uiState.value.canSave) + + vm.onSelectDeck("d2") + assertTrue(vm.uiState.value.canSave) +} + +// Note: `DeckRepository(FakeDeckDao(), now = { now })` is constructed inline in each test; +// there is no shared helper. Seed decks via `deckRepo.upsert(Deck(...))`. + +@Test +fun pickerMode_save_persistsToSelectedDeck_andPreservesDeckOnReset() = runTest { + val dao = FakeCardDao() + val cardRepo = CardRepository(dao, now = { now }) + val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) + deckRepo.upsert(Deck(id = "d1", name = "French", dateCreated = now, lastModified = now)) + deckRepo.upsert(Deck(id = "d2", name = "Spanish", dateCreated = now, lastModified = now)) + val vm = CardFormViewModel( + deckId = null, cardRepository = cardRepo, mediaManager = media(), + deckRepository = deckRepo, idGenerator = ids("c-1"), now = { now }, + ) + runCurrent() + vm.onSelectDeck("d2") + vm.onFrontChange("bonjour"); vm.onBackChange("hello") + vm.save(); runCurrent() + + // Card landed in the selected deck d2 (not d1). + assertEquals(1, dao.observeByDeck("d2").first().size) + assertEquals(0, dao.observeByDeck("d1").first().size) + // Form reset for the next card, but the deck selection sticks. + assertEquals(1, vm.uiState.value.savedTick) + assertEquals("", vm.uiState.value.front) + assertEquals("d2", vm.uiState.value.selectedDeckId) + assertTrue(vm.uiState.value.pickDeck) + assertEquals(2, vm.uiState.value.decks.size) +} + +@Test +fun fixedDeckMode_hasNoPicker_andSavesWithoutManualSelection() = runTest { + val dao = FakeCardDao() + val cardRepo = CardRepository(dao, now = { now }) + val vm = CardFormViewModel("d1", cardRepo, media(), idGenerator = ids("c-1"), now = { now }) + assertFalse(vm.uiState.value.pickDeck) + assertEquals("d1", vm.uiState.value.selectedDeckId) + vm.onFrontChange("hello"); vm.onBackChange("hola") + assertTrue(vm.uiState.value.canSave) // no manual deck pick needed + vm.save(); runCurrent() + assertEquals(1, dao.observeByDeck("d1").first().size) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.feature.cardform.CardFormViewModelTest"` +Expected: COMPILE FAILURE — `pickDeck`, `decks`, `selectedDeckId`, `onSelectDeck`, and the `deckRepository =` / nullable `deckId` constructor params don't exist yet. + +- [ ] **Step 3: Add `DeckOption` and the new state fields** + +In `CardFormViewModel.kt`, add the import: +```kotlin +import nart.simpleanki.core.data.repository.DeckRepository +``` + +Add the `DeckOption` type just above `data class CardFormUiState`: +```kotlin +/** A deck choice for the in-editor selector (queue-path "picker mode"). */ +data class DeckOption(val id: String, val name: String) +``` + +Add three fields to `CardFormUiState` (place them after `audioPath`/`uploadingAudio`, before `savedTick`): +```kotlin + /** True ⇒ the user picks the destination deck in-editor (opened from the Study tab). */ + val pickDeck: Boolean = false, + val decks: List = emptyList(), + val selectedDeckId: String? = null, +``` + +Change `canSave` to require a deck: +```kotlin + val canSave: Boolean + get() = front.isNotBlank() && back.isNotBlank() && + !uploadingImage && !uploadingAudio && selectedDeckId != null +``` + +- [ ] **Step 4: Make `deckId` nullable, inject `deckRepository`, load decks, add `onSelectDeck`** + +Change the constructor signature (first param nullable; add `deckRepository` after `mediaManager`): +```kotlin +class CardFormViewModel( + private val deckId: String?, + private val cardRepository: CardRepository, + private val mediaManager: MediaManager, + private val deckRepository: DeckRepository? = null, + private val editingCardId: String? = null, + private val idGenerator: () -> String = { UUID.randomUUID().toString() }, + private val now: () -> Long = { System.currentTimeMillis() }, + private val logManager: LogManager = LogManager(emptyList()), +) : ViewModel() { +``` + +Change the initial state to seed picker mode and the default selection: +```kotlin + private val _uiState = MutableStateFlow( + CardFormUiState( + isEdit = editingCardId != null, + pickDeck = deckId == null, + selectedDeckId = deckId, + ), + ) +``` + +In `init { ... }`, after the existing `if (editingCardId != null) { ... }` block, add deck loading: +```kotlin + if (deckId == null) { + viewModelScope.launch { + deckRepository?.observeDecks()?.collect { decks -> + _uiState.value = _uiState.value.copy( + decks = decks.map { DeckOption(it.id, it.name) }, + ) + } + } + } +``` + +Add the action (place it next to `onFrontChange`/`onBackChange`): +```kotlin + fun onSelectDeck(id: String) { + _uiState.value = _uiState.value.copy(selectedDeckId = id) + } +``` + +- [ ] **Step 5: Use the selected deck in `save()` and preserve it on reset** + +In `save()`, the new-card branch currently calls `newCard(baseId, state.front, state.back, ...)` which reads the class field `deckId`. Capture the selected deck once at the top of `save()` and pass it through. Replace the `private fun newCard(...)` signature and its `Card(...)` so the deck comes from an explicit parameter: + +Change `newCard` to take an explicit `deckId`: +```kotlin + private fun newCard( + id: String, deckId: String, front: String, back: String, isReverse: Boolean, pairId: String?, + image: String?, imagePath: String?, audioName: String?, audioPath: String?, + ): Card { + val t = now() + return Card( + id = id, front = front, back = back, deckId = deckId, + dateCreated = t, lastModified = t, fsrsDue = t, fsrsState = CardState.New.value, + image = image, imagePath = imagePath, audioName = audioName, audioPath = audioPath, + pairId = pairId, isReverse = isReverse, + ) + } +``` + +In `save()`, inside the `else` (new-card) branch, read the selected deck and pass it into both `newCard(...)` calls (original and reverse). At the start of the `else` branch add: +```kotlin + val targetDeckId = state.selectedDeckId ?: return@launch +``` +Then update the two `newCard(...)` calls to pass `targetDeckId` as the new second argument, e.g.: +```kotlin + cardRepository.upsert( + newCard( + baseId, targetDeckId, state.front, state.back, isReverse = false, + pairId = if (state.createReverse) baseId else null, + image = state.imageName, imagePath = state.imagePath, + audioName = state.audioName, audioPath = state.audioPath, + ), + ) + if (state.createReverse) { + cardRepository.upsert( + newCard( + idGenerator(), targetDeckId, state.back, state.front, + isReverse = true, pairId = baseId, + image = null, imagePath = null, audioName = null, audioPath = null, + ), + ) + } +``` + +Change the reset at the end of the new-card branch to preserve deck context: +```kotlin + _uiState.value = CardFormUiState( + isEdit = false, + savedTick = state.savedTick + 1, + pickDeck = state.pickDeck, + decks = state.decks, + selectedDeckId = state.selectedDeckId, + ) +``` + +(The edit branch — `existing != null` — is unchanged.) + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.feature.cardform.CardFormViewModelTest"` +Expected: PASS — all existing fixed-deck tests plus the three new picker-mode tests are green. + +- [ ] **Step 7: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/feature/cardform/CardFormViewModel.kt \ + app/src/test/java/nart/simpleanki/feature/cardform/CardFormViewModelTest.kt +git commit -m "Add deck-picker mode to CardFormViewModel" +``` + +--- + +## Task 2: Wire nullable deckId through CardFormArgs + Koin + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/di/AppModule.kt` + +There is no JVM unit test for Koin wiring in this project; this task is verified by a compiling build (Task 3 also depends on it). + +- [ ] **Step 1: Make `CardFormArgs.deckId` nullable** + +In `AppModule.kt:77`, change: +```kotlin +data class CardFormArgs(val deckId: String? = null, val cardId: String? = null) +``` + +- [ ] **Step 2: Pass `deckRepository` into the VM** + +In the `viewModel { params -> ... CardFormViewModel(...) }` block (around `AppModule.kt:229`), update the construction to pass the deck repository: +```kotlin + viewModel { params -> + val a = params.get() + CardFormViewModel( + deckId = a.deckId, + cardRepository = get(), + mediaManager = get(), + deckRepository = get(), + editingCardId = a.cardId, + logManager = get(), + ) + } +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 4: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/di/AppModule.kt +git commit -m "Allow null deckId in CardFormArgs and inject DeckRepository" +``` + +--- + +## Task 3: Deck selector UI + Front autofocus + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/feature/cardform/CardFormScreen.kt` + +- [ ] **Step 1: Make `CardFormScreen.deckId` nullable and pass `onSelectDeck`** + +Change the `CardFormScreen` signature's `deckId: String` to `deckId: String?`: +```kotlin +@Composable +fun CardFormScreen( + deckId: String?, + cardId: String?, + onClose: () -> Unit, + viewModel: CardFormViewModel = koinViewModel { parametersOf(CardFormArgs(deckId, cardId)) }, +) { +``` + +In the `CardFormContent(...)` call inside `CardFormScreen`, add the deck-selection callback: +```kotlin + onSelectDeck = viewModel::onSelectDeck, +``` + +- [ ] **Step 2: Add the new imports** + +Add to the import block at the top of `CardFormScreen.kt`: +```kotlin +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MenuAnchorType +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +``` + +- [ ] **Step 3: Add `onSelectDeck` param to `CardFormContent`** + +In the `CardFormContent(...)` parameter list, add (after `onBackChange`): +```kotlin + onSelectDeck: (String) -> Unit, +``` + +- [ ] **Step 4: Add the deck dropdown and Front autofocus** + +Inside `CardFormContent`'s body, just before the `Scaffold(...)` call, add the focus requester and effects: +```kotlin + val frontFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { frontFocus.requestFocus() } } + LaunchedEffect(state.savedTick) { + if (state.savedTick > 0) runCatching { frontFocus.requestFocus() } + } +``` + +Inside the form `Column` (the one with `verticalArrangement = Arrangement.spacedBy(16.dp)`), add the deck dropdown as the **first** child, above the Front `OutlinedTextField`: +```kotlin + if (state.pickDeck) { + var deckMenuExpanded by remember { mutableStateOf(false) } + val selectedDeckName = + state.decks.firstOrNull { it.id == state.selectedDeckId }?.name ?: "" + ExposedDropdownMenuBox( + expanded = deckMenuExpanded, + onExpandedChange = { deckMenuExpanded = it }, + ) { + OutlinedTextField( + value = selectedDeckName, + onValueChange = {}, + readOnly = true, + label = { Text("Deck") }, + placeholder = { Text("Select a deck") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(deckMenuExpanded) }, + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + ) + ExposedDropdownMenu( + expanded = deckMenuExpanded, + onDismissRequest = { deckMenuExpanded = false }, + ) { + state.decks.forEach { deck -> + DropdownMenuItem( + text = { Text(deck.name) }, + onClick = { onSelectDeck(deck.id); deckMenuExpanded = false }, + ) + } + } + } + } +``` + +Attach the focus requester to the **Front** `OutlinedTextField` by adding it to its modifier: +```kotlin + OutlinedTextField( + value = state.front, + onValueChange = onFrontChange, + label = { Text("Front") }, + placeholder = { Text("Question") }, + minLines = 3, + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .fillMaxWidth() + .focusRequester(frontFocus), + ) +``` + +- [ ] **Step 5: Update the previews** + +Both existing previews call `CardFormContent(...)` without `onSelectDeck` — add `onSelectDeck = {}` to each. Then add two picker-mode previews after the existing ones: +```kotlin +@Preview(name = "Card form · pick deck (empty)", showBackground = true) +@Composable +private fun CardFormPickDeckPreview() { + AzriTheme { + CardFormContent( + state = CardFormUiState( + pickDeck = true, + decks = listOf(DeckOption("d1", "French"), DeckOption("d2", "Spanish")), + ), + isRecording = false, + onFrontChange = {}, onBackChange = {}, onSelectDeck = {}, onToggleReverse = {}, + onAddImage = {}, onRemoveImage = {}, onToggleRecording = {}, onRemoveAudio = {}, + onSave = {}, onBack = {}, + ) + } +} + +@Preview(name = "Card form · pick deck (selected)", showBackground = true) +@Composable +private fun CardFormPickDeckSelectedPreview() { + AzriTheme { + CardFormContent( + state = CardFormUiState( + front = "bonjour", back = "hello", + pickDeck = true, selectedDeckId = "d2", + decks = listOf(DeckOption("d1", "French"), DeckOption("d2", "Spanish")), + ), + isRecording = false, + onFrontChange = {}, onBackChange = {}, onSelectDeck = {}, onToggleReverse = {}, + onAddImage = {}, onRemoveImage = {}, onToggleRecording = {}, onRemoveAudio = {}, + onSave = {}, onBack = {}, + ) + } +} +``` + +(Remember to also add `onSelectDeck = {}` to the existing `CardFormNewPreview` and `CardFormRecordingPreview` calls.) + +- [ ] **Step 6: Verify it compiles** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 7: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/feature/cardform/CardFormScreen.kt +git commit -m "Add in-editor deck selector and Front autofocus" +``` + +--- + +## Task 4: Navigation route + "Add more cards" button + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/ui/navigation/AzriNavHost.kt` +- Modify: `app/src/main/java/nart/simpleanki/feature/queue/StudyQueueScreen.kt` + +- [ ] **Step 1: Thread `onAddCards` through `StudyQueueScreen` → `HeroCard`** + +In `StudyQueueScreen.kt`, add `onAddCards: () -> Unit = {}` to the `StudyQueueScreen(...)` parameter list (after `onGoToLibrary`), and pass it into the `StudyQueueContent(...)` call: +```kotlin + onAddCards = onAddCards, +``` + +Add `onAddCards: () -> Unit = {}` to the `StudyQueueContent(...)` parameter list (after `onGoToLibrary`). Find where `HeroCard(state, onStudyAll, onGoToLibrary)` is called (around line 180) and add the new arg: +```kotlin + item { HeroCard(state, onStudyAll, onGoToLibrary, onAddCards) } +``` + +Update the `HeroCard` signature: +```kotlin +private fun HeroCard( + state: StudyQueueUiState, + onStudyAll: () -> Unit, + onGoToLibrary: () -> Unit, + onAddCards: () -> Unit, +) { +``` + +In the **all-caught-up** branch (the `else` branch with the "All caught up" text and the "Add more cards" `OutlinedButton`), change that button's `onClick` from `onGoToLibrary` to `onAddCards`: +```kotlin + OutlinedButton( + onClick = onAddCards, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = MaterialTheme.shapes.large, + ) { + Icon(Icons.Filled.Add, contentDescription = null) + Text( + "Add more cards", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } +``` + +(The brand-new branch's "Go to Library" button keeps `onClick = onGoToLibrary` — do not change it.) + +- [ ] **Step 2: Add the `cardForm` route and pass `onAddCards`** + +In `AzriNavHost.kt`, in the `StudyQueueScreen(...)` call inside `composable(QUEUE) { ... }` (around line 177), add the new callback: +```kotlin + StudyQueueScreen( + onStudyAll = { nav.navigate("studyAll") }, + onStudyDeck = { nav.navigate("study/$it") }, + onStudyFolder = { nav.navigate("studyFolder/$it") }, + onGoToLibrary = { nav.switchTab(LIBRARY) }, + onAddCards = { nav.navigate("cardForm") }, + onOpenPaywall = { showPaywall = true }, + ) +``` + +Add the no-arg `cardForm` route next to the existing `cardForm/{deckId}` route (around line 268): +```kotlin + composable("cardForm") { + CardFormScreen( + deckId = null, + cardId = null, + onClose = { nav.popBackStack() }, + ) + } +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 4: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/ui/navigation/AzriNavHost.kt \ + app/src/main/java/nart/simpleanki/feature/queue/StudyQueueScreen.kt +git commit -m "Open card editor from the 'Add more cards' empty state" +``` + +--- + +## Final verification + +- [ ] **Step 1: Full unit-test suite + debug build** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest :app:assembleDebug` +Expected: BUILD SUCCESSFUL; all unit tests pass. + +- [ ] **Step 2 (optional, emulator): manual smoke test** + +If an emulator is available: Study tab → reach the "All caught up" state → tap **Add more cards** → the editor opens with the keyboard up and Front focused; a **Deck** dropdown shows; Save is disabled until a deck is picked; after saving, the form clears, the deck stays selected, and Front re-focuses. Press back → reopening shows no deck pre-selected. From 0d57b7a2f525cca2709f862e9050da92055ac0b8 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Fri, 5 Jun 2026 13:02:07 +0400 Subject: [PATCH 3/6] Add deck-picker mode to CardFormViewModel --- .../feature/cardform/CardFormViewModel.kt | 68 ++++++++++++++----- .../feature/cardform/CardFormViewModelTest.kt | 66 ++++++++++++++++++ 2 files changed, 118 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/nart/simpleanki/feature/cardform/CardFormViewModel.kt b/app/src/main/java/nart/simpleanki/feature/cardform/CardFormViewModel.kt index 57e0239..beaa7c7 100644 --- a/app/src/main/java/nart/simpleanki/feature/cardform/CardFormViewModel.kt +++ b/app/src/main/java/nart/simpleanki/feature/cardform/CardFormViewModel.kt @@ -10,10 +10,14 @@ import nart.simpleanki.core.analytics.LogManager import nart.simpleanki.core.analytics.LoggableEvent import nart.simpleanki.core.data.media.MediaManager import nart.simpleanki.core.data.repository.CardRepository +import nart.simpleanki.core.data.repository.DeckRepository import nart.simpleanki.core.domain.model.Card import nart.simpleanki.core.domain.model.CardState import java.util.UUID +/** A deck choice for the in-editor selector (queue-path "picker mode"). */ +data class DeckOption(val id: String, val name: String) + data class CardFormUiState( val front: String = "", val back: String = "", @@ -25,13 +29,18 @@ data class CardFormUiState( val audioName: String? = null, val audioPath: String? = null, val uploadingAudio: Boolean = false, + /** True ⇒ the user picks the destination deck in-editor (opened from the Study tab). */ + val pickDeck: Boolean = false, + val decks: List = emptyList(), + val selectedDeckId: String? = null, /** Increments on each successful new-card save; drives the "Card saved" toast (re-triggerable). */ val savedTick: Int = 0, /** Set once after editing an existing card, signaling the screen to close. */ val finished: Boolean = false, ) { val canSave: Boolean - get() = front.isNotBlank() && back.isNotBlank() && !uploadingImage && !uploadingAudio + get() = front.isNotBlank() && back.isNotBlank() && + !uploadingImage && !uploadingAudio && selectedDeckId != null } /** @@ -39,18 +48,29 @@ data class CardFormUiState( * [MediaManager]; upload to the cloud happens later during premium sync. When * [CardFormUiState.createReverse] is set for a new card, a second reversed card is created * with swapped front/back and a shared [Card.pairId]. + * + * Pass [deckId] = null to enter "picker mode": the user selects the destination deck + * in-editor (e.g. when opening the editor from the Study tab without a deck context). + * Picker mode ([deckId] == null) requires a non-null [deckRepository]; fixed-deck mode leaves it null. */ class CardFormViewModel( - private val deckId: String, + private val deckId: String?, private val cardRepository: CardRepository, private val mediaManager: MediaManager, + private val deckRepository: DeckRepository? = null, private val editingCardId: String? = null, private val idGenerator: () -> String = { UUID.randomUUID().toString() }, private val now: () -> Long = { System.currentTimeMillis() }, private val logManager: LogManager = LogManager(emptyList()), ) : ViewModel() { - private val _uiState = MutableStateFlow(CardFormUiState(isEdit = editingCardId != null)) + private val _uiState = MutableStateFlow( + CardFormUiState( + isEdit = editingCardId != null, + pickDeck = deckId == null, + selectedDeckId = deckId, + ), + ) val uiState: StateFlow = _uiState.asStateFlow() private var editingCard: Card? = null @@ -68,6 +88,16 @@ class CardFormViewModel( } } } + if (deckId == null) { + checkNotNull(deckRepository) { "deckRepository is required in picker mode (deckId == null)" } + viewModelScope.launch { + deckRepository.observeDecks().collect { decks -> + _uiState.value = _uiState.value.copy( + decks = decks.map { DeckOption(it.id, it.name) }, + ) + } + } + } } fun onFrontChange(value: String) { @@ -78,6 +108,10 @@ class CardFormViewModel( _uiState.value = _uiState.value.copy(back = value) } + fun onSelectDeck(id: String) { + _uiState.value = _uiState.value.copy(selectedDeckId = id) + } + fun onToggleReverse(value: Boolean) { _uiState.value = _uiState.value.copy(createReverse = value) } @@ -130,28 +164,23 @@ class CardFormViewModel( // Editing targets one card: close the editor once saved. _uiState.value = _uiState.value.copy(finished = true) } else { + val targetDeckId = state.selectedDeckId ?: return@launch val baseId = idGenerator() cardRepository.upsert( newCard( - baseId, state.front, state.back, isReverse = false, + baseId, targetDeckId, state.front, state.back, isReverse = false, pairId = if (state.createReverse) baseId else null, image = state.imageName, imagePath = state.imagePath, - audioName = state.audioName, audioPath = state.audioPath + audioName = state.audioName, audioPath = state.audioPath, ), ) if (state.createReverse) { // Reverse cards are intentionally audio-free (mirrors iOS). cardRepository.upsert( newCard( - idGenerator(), - state.back, - state.front, - isReverse = true, - pairId = baseId, - image = null, - imagePath = null, - audioName = null, - audioPath = null + idGenerator(), targetDeckId, state.back, state.front, + isReverse = true, pairId = baseId, + image = null, imagePath = null, audioName = null, audioPath = null, ), ) } @@ -163,13 +192,20 @@ class CardFormViewModel( ) ) // Keep the editor open for rapid entry: clear inputs and bump the toast counter. - _uiState.value = CardFormUiState(isEdit = false, savedTick = state.savedTick + 1) + // In picker mode, preserve the deck selection and list for the next card. + _uiState.value = CardFormUiState( + isEdit = false, + savedTick = state.savedTick + 1, + pickDeck = state.pickDeck, + decks = state.decks, + selectedDeckId = state.selectedDeckId, + ) } } } private fun newCard( - id: String, front: String, back: String, isReverse: Boolean, pairId: String?, + id: String, deckId: String, front: String, back: String, isReverse: Boolean, pairId: String?, image: String?, imagePath: String?, audioName: String?, audioPath: String?, ): Card { val t = now() diff --git a/app/src/test/java/nart/simpleanki/feature/cardform/CardFormViewModelTest.kt b/app/src/test/java/nart/simpleanki/feature/cardform/CardFormViewModelTest.kt index cab4ac6..b342288 100644 --- a/app/src/test/java/nart/simpleanki/feature/cardform/CardFormViewModelTest.kt +++ b/app/src/test/java/nart/simpleanki/feature/cardform/CardFormViewModelTest.kt @@ -14,9 +14,12 @@ import nart.simpleanki.core.data.media.FakeMediaUploader import nart.simpleanki.core.data.media.LocalMediaStore import nart.simpleanki.core.data.media.MediaManager import nart.simpleanki.core.data.repository.CardRepository +import nart.simpleanki.core.data.repository.DeckRepository import nart.simpleanki.core.data.repository.FakeCardDao +import nart.simpleanki.core.data.repository.FakeDeckDao import nart.simpleanki.core.domain.model.Card import nart.simpleanki.core.domain.model.CardState +import nart.simpleanki.core.domain.model.Deck import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -201,4 +204,67 @@ class CardFormViewModelTest { vm.onFrontChange("a2"); vm.save(); runCurrent() assertTrue(log.events.any { it.eventName == "card_updated" }) } + + @Test + fun pickerMode_loadsDecks_andRequiresDeckBeforeSave() = runTest { + val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) + deckRepo.upsert(Deck(id = "d1", name = "French", dateCreated = now, lastModified = now)) + deckRepo.upsert(Deck(id = "d2", name = "Spanish", dateCreated = now, lastModified = now)) + val cardRepo = CardRepository(FakeCardDao(), now = { now }) + val vm = CardFormViewModel( + deckId = null, cardRepository = cardRepo, mediaManager = media(), + deckRepository = deckRepo, now = { now }, + ) + runCurrent() + + assertTrue(vm.uiState.value.pickDeck) + assertEquals(listOf("French", "Spanish"), vm.uiState.value.decks.map { it.name }) + + // Front + Back filled, but no deck chosen yet → cannot save. + vm.onFrontChange("bonjour"); vm.onBackChange("hello") + assertFalse(vm.uiState.value.canSave) + + vm.onSelectDeck("d2") + assertTrue(vm.uiState.value.canSave) + } + + @Test + fun pickerMode_save_persistsToSelectedDeck_andPreservesDeckOnReset() = runTest { + val dao = FakeCardDao() + val cardRepo = CardRepository(dao, now = { now }) + val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) + deckRepo.upsert(Deck(id = "d1", name = "French", dateCreated = now, lastModified = now)) + deckRepo.upsert(Deck(id = "d2", name = "Spanish", dateCreated = now, lastModified = now)) + val vm = CardFormViewModel( + deckId = null, cardRepository = cardRepo, mediaManager = media(), + deckRepository = deckRepo, idGenerator = ids("c-1"), now = { now }, + ) + runCurrent() + vm.onSelectDeck("d2") + vm.onFrontChange("bonjour"); vm.onBackChange("hello") + vm.save(); runCurrent() + + // Card landed in the selected deck d2 (not d1). + assertEquals(1, dao.observeByDeck("d2").first().size) + assertEquals(0, dao.observeByDeck("d1").first().size) + // Form reset for the next card, but the deck selection sticks. + assertEquals(1, vm.uiState.value.savedTick) + assertEquals("", vm.uiState.value.front) + assertEquals("d2", vm.uiState.value.selectedDeckId) + assertTrue(vm.uiState.value.pickDeck) + assertEquals(2, vm.uiState.value.decks.size) + } + + @Test + fun fixedDeckMode_hasNoPicker_andSavesWithoutManualSelection() = runTest { + val dao = FakeCardDao() + val cardRepo = CardRepository(dao, now = { now }) + val vm = CardFormViewModel("d1", cardRepo, media(), idGenerator = ids("c-1"), now = { now }) + assertFalse(vm.uiState.value.pickDeck) + assertEquals("d1", vm.uiState.value.selectedDeckId) + vm.onFrontChange("hello"); vm.onBackChange("hola") + assertTrue(vm.uiState.value.canSave) // no manual deck pick needed + vm.save(); runCurrent() + assertEquals(1, dao.observeByDeck("d1").first().size) + } } From 02506c74521e7222af9f4045d91d0e00caf69306 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Fri, 5 Jun 2026 13:08:20 +0400 Subject: [PATCH 4/6] Allow null deckId in CardFormArgs and inject DeckRepository --- app/src/main/java/nart/simpleanki/di/AppModule.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/nart/simpleanki/di/AppModule.kt b/app/src/main/java/nart/simpleanki/di/AppModule.kt index 4328d4d..97f67ea 100644 --- a/app/src/main/java/nart/simpleanki/di/AppModule.kt +++ b/app/src/main/java/nart/simpleanki/di/AppModule.kt @@ -74,7 +74,7 @@ import org.koin.dsl.module import java.io.File /** Injection args for screens that take optional ids (unambiguous vs. positional params). */ -data class CardFormArgs(val deckId: String, val cardId: String? = null) +data class CardFormArgs(val deckId: String? = null, val cardId: String? = null) data class DeckEditArgs(val deckId: String? = null, val folderId: String? = null) data class FolderEditArgs(val folderId: String? = null) data class StudyArgs(val deckId: String? = null, val folderId: String? = null) @@ -232,8 +232,9 @@ val appModule = module { deckId = a.deckId, cardRepository = get(), mediaManager = get(), + deckRepository = get(), editingCardId = a.cardId, - logManager = get() + logManager = get(), ) } viewModel { params -> From b5952b760449b166815b1a3806adb694fdfdd4a1 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Fri, 5 Jun 2026 13:12:14 +0400 Subject: [PATCH 5/6] Add in-editor deck selector and Front autofocus --- .../feature/cardform/CardFormContentTest.kt | 2 + .../feature/cardform/CardFormScreen.kt | 91 ++++++++++++++++++- 2 files changed, 89 insertions(+), 4 deletions(-) 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 0838f5d..9bc9d33 100644 --- a/app/src/androidTest/java/nart/simpleanki/feature/cardform/CardFormContentTest.kt +++ b/app/src/androidTest/java/nart/simpleanki/feature/cardform/CardFormContentTest.kt @@ -21,6 +21,7 @@ class CardFormContentTest { state = CardFormUiState(isEdit = false), onFrontChange = { front = it }, onBackChange = {}, + onSelectDeck = {}, isRecording = false, onToggleReverse = {}, onAddImage = {}, @@ -45,6 +46,7 @@ class CardFormContentTest { state = CardFormUiState(front = "a", back = "b", isEdit = true), onFrontChange = {}, onBackChange = {}, + onSelectDeck = {}, isRecording = false, onToggleReverse = {}, onAddImage = {}, 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 55c2436..50c53a9 100644 --- a/app/src/main/java/nart/simpleanki/feature/cardform/CardFormScreen.kt +++ b/app/src/main/java/nart/simpleanki/feature/cardform/CardFormScreen.kt @@ -30,13 +30,17 @@ import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material3.AssistChip 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.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar @@ -56,6 +60,8 @@ 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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -72,7 +78,7 @@ import org.koin.core.parameter.parametersOf @Composable fun CardFormScreen( - deckId: String, + deckId: String?, cardId: String?, onClose: () -> Unit, viewModel: CardFormViewModel = koinViewModel { parametersOf(CardFormArgs(deckId, cardId)) }, @@ -131,6 +137,7 @@ fun CardFormScreen( isRecording = isRecording, onFrontChange = viewModel::onFrontChange, onBackChange = viewModel::onBackChange, + onSelectDeck = viewModel::onSelectDeck, onToggleReverse = viewModel::onToggleReverse, onAddImage = { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, onRemoveImage = viewModel::onRemoveImage, @@ -149,6 +156,7 @@ fun CardFormContent( isRecording: Boolean, onFrontChange: (String) -> Unit, onBackChange: (String) -> Unit, + onSelectDeck: (String) -> Unit, onToggleReverse: (Boolean) -> Unit, onAddImage: () -> Unit, onRemoveImage: () -> Unit, @@ -158,6 +166,12 @@ fun CardFormContent( onBack: () -> Unit, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { + val frontFocus = remember { FocusRequester() } + // Autofocus Front on open (and re-focus after each save) so the user can type immediately. + LaunchedEffect(Unit) { runCatching { frontFocus.requestFocus() } } + LaunchedEffect(state.savedTick) { + if (state.savedTick > 0) runCatching { frontFocus.requestFocus() } + } Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) { data -> @@ -194,6 +208,38 @@ fun CardFormContent( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { + var deckMenuExpanded by remember { mutableStateOf(false) } + if (state.pickDeck) { + val selectedDeckName = + state.decks.firstOrNull { it.id == state.selectedDeckId }?.name ?: "" + ExposedDropdownMenuBox( + expanded = deckMenuExpanded, + onExpandedChange = { deckMenuExpanded = it }, + ) { + OutlinedTextField( + value = selectedDeckName, + onValueChange = {}, + readOnly = true, + label = { Text("Deck") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(deckMenuExpanded) }, + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + ) + ExposedDropdownMenu( + expanded = deckMenuExpanded, + onDismissRequest = { deckMenuExpanded = false }, + ) { + state.decks.forEach { deck -> + DropdownMenuItem( + text = { Text(deck.name) }, + onClick = { onSelectDeck(deck.id); deckMenuExpanded = false }, + ) + } + } + } + } OutlinedTextField( value = state.front, onValueChange = onFrontChange, @@ -201,7 +247,9 @@ fun CardFormContent( placeholder = { Text("Question") }, minLines = 3, shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .focusRequester(frontFocus), ) OutlinedTextField( value = state.back, @@ -313,7 +361,7 @@ private fun CardFormNewPreview() { CardFormContent( state = CardFormUiState(front = "Bonjour", back = "Hello", createReverse = true), isRecording = false, - onFrontChange = {}, onBackChange = {}, onToggleReverse = {}, + onFrontChange = {}, onBackChange = {}, onSelectDeck = {}, onToggleReverse = {}, onAddImage = {}, onRemoveImage = {}, onToggleRecording = {}, onRemoveAudio = {}, onSave = {}, onBack = {}, ) @@ -327,7 +375,42 @@ private fun CardFormRecordingPreview() { CardFormContent( state = CardFormUiState(front = "cat", back = "gato", isEdit = true), isRecording = true, - onFrontChange = {}, onBackChange = {}, onToggleReverse = {}, + onFrontChange = {}, onBackChange = {}, onSelectDeck = {}, onToggleReverse = {}, + onAddImage = {}, onRemoveImage = {}, onToggleRecording = {}, onRemoveAudio = {}, + onSave = {}, onBack = {}, + ) + } +} + +@Preview(name = "Card form · pick deck (empty)", showBackground = true) +@Composable +private fun CardFormPickDeckPreview() { + AzriTheme { + CardFormContent( + state = CardFormUiState( + pickDeck = true, + decks = listOf(DeckOption("d1", "French"), DeckOption("d2", "Spanish")), + ), + isRecording = false, + onFrontChange = {}, onBackChange = {}, onSelectDeck = {}, onToggleReverse = {}, + onAddImage = {}, onRemoveImage = {}, onToggleRecording = {}, onRemoveAudio = {}, + onSave = {}, onBack = {}, + ) + } +} + +@Preview(name = "Card form · pick deck (selected)", showBackground = true) +@Composable +private fun CardFormPickDeckSelectedPreview() { + AzriTheme { + CardFormContent( + state = CardFormUiState( + front = "bonjour", back = "hello", + pickDeck = true, selectedDeckId = "d2", + decks = listOf(DeckOption("d1", "French"), DeckOption("d2", "Spanish")), + ), + isRecording = false, + onFrontChange = {}, onBackChange = {}, onSelectDeck = {}, onToggleReverse = {}, onAddImage = {}, onRemoveImage = {}, onToggleRecording = {}, onRemoveAudio = {}, onSave = {}, onBack = {}, ) From f6e17e5531b622b0d3ff53642e3c828bf6c08ef6 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Fri, 5 Jun 2026 13:19:04 +0400 Subject: [PATCH 6/6] Open card editor from the 'Add more cards' empty state --- .../simpleanki/feature/queue/StudyQueueScreen.kt | 16 +++++++++++++--- .../nart/simpleanki/ui/navigation/AzriNavHost.kt | 8 ++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueScreen.kt b/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueScreen.kt index aa544a0..dcc19d5 100644 --- a/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueScreen.kt +++ b/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueScreen.kt @@ -86,6 +86,7 @@ fun StudyQueueScreen( onStudyDeck: (String) -> Unit, onStudyFolder: (String) -> Unit, onGoToLibrary: () -> Unit, + onAddCards: () -> Unit = {}, onOpenPaywall: () -> Unit = {}, viewModel: StudyQueueViewModel = koinViewModel(), ) { @@ -99,6 +100,7 @@ fun StudyQueueScreen( onEditGoal = { showGoalSheet = true }, onSortChange = viewModel::setSortOrder, onGoToLibrary = onGoToLibrary, + onAddCards = onAddCards, onOpenPaywall = onOpenPaywall, onDismissNudge = viewModel::dismissPremiumNudge, ) @@ -118,6 +120,7 @@ fun StudyQueueContent( onEditGoal: () -> Unit = {}, onSortChange: (QueueSortOrder) -> Unit = {}, onGoToLibrary: () -> Unit = {}, + onAddCards: () -> Unit = {}, onOpenPaywall: () -> Unit = {}, onDismissNudge: () -> Unit = {}, ) { @@ -177,7 +180,7 @@ fun StudyQueueContent( if ((state.dailyGoalEnabled && state.goalTotal > 0) || !state.hasAnyCards) { item { DailyGoalCard(state, onClick = onEditGoal) } } - item { HeroCard(state, onStudyAll, onGoToLibrary) } + item { HeroCard(state, onStudyAll, onGoToLibrary, onAddCards) } if (state.decks.isNotEmpty()) { item { @@ -339,7 +342,12 @@ private fun DailyGoalCard(state: StudyQueueUiState, onClick: () -> Unit) { } @Composable -private fun HeroCard(state: StudyQueueUiState, onStudyAll: () -> Unit, onGoToLibrary: () -> Unit) { +private fun HeroCard( + state: StudyQueueUiState, + onStudyAll: () -> Unit, + onGoToLibrary: () -> Unit, + onAddCards: () -> Unit, +) { AzriCard( modifier = Modifier .fillMaxWidth() @@ -442,8 +450,10 @@ private fun HeroCard(state: StudyQueueUiState, onStudyAll: () -> Unit, onGoToLib textAlign = TextAlign.Center, ) Spacer(Modifier.height(20.dp)) + // This branch only renders when the user has cards (hasAnyCards), which guarantees + // at least one deck exists — so the card editor's deck picker is never empty here. OutlinedButton( - onClick = onGoToLibrary, + onClick = onAddCards, modifier = Modifier .fillMaxWidth() .height(52.dp), 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 f0ecd22..e89caff 100644 --- a/app/src/main/java/nart/simpleanki/ui/navigation/AzriNavHost.kt +++ b/app/src/main/java/nart/simpleanki/ui/navigation/AzriNavHost.kt @@ -179,6 +179,7 @@ fun AzriNavHost() { onStudyDeck = { nav.navigate("study/$it") }, onStudyFolder = { nav.navigate("studyFolder/$it") }, onGoToLibrary = { nav.switchTab(LIBRARY) }, + onAddCards = { nav.navigate("cardForm") }, onOpenPaywall = { showPaywall = true }, ) } @@ -265,6 +266,13 @@ fun AzriNavHost() { } // The card editor stays open after a save (it shows its own "Card saved" toast and resets // its inputs for rapid entry); only the back arrow pops it. Editing a card closes itself. + composable("cardForm") { + CardFormScreen( + deckId = null, + cardId = null, + onClose = { nav.popBackStack() }, + ) + } composable("cardForm/{deckId}") { entry -> CardFormScreen( deckId = entry.arguments?.getString("deckId").orEmpty(),