diff --git a/app/src/main/java/nart/simpleanki/core/domain/fsrs/StudyQueueBuilder.kt b/app/src/main/java/nart/simpleanki/core/domain/fsrs/StudyQueueBuilder.kt index 9df0705..df81831 100644 --- a/app/src/main/java/nart/simpleanki/core/domain/fsrs/StudyQueueBuilder.kt +++ b/app/src/main/java/nart/simpleanki/core/domain/fsrs/StudyQueueBuilder.kt @@ -55,7 +55,7 @@ object StudyQueueBuilder { filter: ReviewCardFilter, shuffleSeed: Long? = null, ): List { - val filtered = cards.filter { !it.isDeleted }.filter { card -> + val filtered = cards.filter { !it.isDeleted && !it.memorized }.filter { card -> when (filter) { ReviewCardFilter.All -> true ReviewCardFilter.OriginalsOnly -> !card.isReverse diff --git a/app/src/main/java/nart/simpleanki/di/AppModule.kt b/app/src/main/java/nart/simpleanki/di/AppModule.kt index 9238545..39f2f10 100644 --- a/app/src/main/java/nart/simpleanki/di/AppModule.kt +++ b/app/src/main/java/nart/simpleanki/di/AppModule.kt @@ -62,6 +62,7 @@ import nart.simpleanki.feature.profile.ProfileViewModel import nart.simpleanki.feature.queue.DailyGoalViewModel import nart.simpleanki.feature.queue.StudyQueueViewModel import nart.simpleanki.feature.settings.SettingsViewModel +import nart.simpleanki.feature.review.ReviewViewModel import nart.simpleanki.feature.study.StudyViewModel import nart.simpleanki.feature.sync.SyncViewModel import org.koin.android.ext.koin.androidContext @@ -193,6 +194,16 @@ val appModule = module { logManager = get(), ) } + viewModel { params -> + val args = params.get() + ReviewViewModel( + deckId = args.deckId, + folderId = args.folderId, + cardRepository = get(), + deckRepository = get(), + logManager = get(), + ) + } viewModel { StudyQueueViewModel( cardRepository = get(), diff --git a/app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailScreen.kt b/app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailScreen.kt index 6686e46..1d02d51 100644 --- a/app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailScreen.kt +++ b/app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailScreen.kt @@ -18,9 +18,11 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.School +import androidx.compose.material.icons.filled.Style import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -73,6 +75,7 @@ fun DeckDetailScreen( deckId: String, onBack: () -> Unit, onStudy: () -> Unit, + onReview: () -> Unit, onAddCard: () -> Unit, onEditCard: (String) -> Unit, onSettings: () -> Unit, @@ -87,6 +90,7 @@ fun DeckDetailScreen( onQueryChange = viewModel::onQueryChange, onBack = onBack, onStudy = onStudy, + onReview = onReview, onAddCard = onAddCard, onEditCard = onEditCard, onSettings = onSettings, @@ -112,6 +116,7 @@ fun DeckDetailContent( onQueryChange: (String) -> Unit, onBack: () -> Unit, onStudy: () -> Unit, + onReview: () -> Unit = {}, onAddCard: () -> Unit, onEditCard: (String) -> Unit, onSettings: () -> Unit, @@ -213,6 +218,20 @@ fun DeckDetailContent( state.total > 0 -> AllCaughtUp(cards = state.cards, now = now) else -> Unit // empty deck: the list body below shows "No cards yet." } + if (state.total > 0) { + OutlinedButton( + onClick = onReview, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = MaterialTheme.shapes.large, + ) { + Icon(Icons.Filled.Style, contentDescription = null) + Text( + "Review", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + } } if (state.cards.isEmpty()) { diff --git a/app/src/main/java/nart/simpleanki/feature/folderdetail/FolderDetailScreen.kt b/app/src/main/java/nart/simpleanki/feature/folderdetail/FolderDetailScreen.kt index 59a9b52..2b17dc7 100644 --- a/app/src/main/java/nart/simpleanki/feature/folderdetail/FolderDetailScreen.kt +++ b/app/src/main/java/nart/simpleanki/feature/folderdetail/FolderDetailScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Style import androidx.compose.material.icons.outlined.FolderOff import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -41,6 +42,7 @@ fun FolderDetailScreen( folderId: String, onBack: () -> Unit, onOpenDeck: (String) -> Unit, + onReview: () -> Unit, onNewDeck: () -> Unit, onEditFolder: () -> Unit, viewModel: FolderDetailViewModel = koinViewModel { parametersOf(folderId) }, @@ -50,6 +52,7 @@ fun FolderDetailScreen( state = state, onBack = onBack, onOpenDeck = onOpenDeck, + onReview = onReview, onNewDeck = onNewDeck, onEditFolder = onEditFolder, ) @@ -62,6 +65,7 @@ fun FolderDetailContent( state: FolderDetailUiState, onBack: () -> Unit, onOpenDeck: (String) -> Unit, + onReview: () -> Unit = {}, onNewDeck: () -> Unit, onEditFolder: () -> Unit, ) { @@ -78,6 +82,7 @@ fun FolderDetailContent( } }, actions = { + IconButton(onClick = onReview) { Icon(Icons.Filled.Style, "Review folder") } IconButton(onClick = onEditFolder) { Icon(Icons.Default.Edit, "Edit folder") } IconButton(onClick = onNewDeck) { Icon(Icons.Default.Add, "New deck in folder") } }, diff --git a/app/src/main/java/nart/simpleanki/feature/review/ReviewScreen.kt b/app/src/main/java/nart/simpleanki/feature/review/ReviewScreen.kt new file mode 100644 index 0000000..ae53874 --- /dev/null +++ b/app/src/main/java/nart/simpleanki/feature/review/ReviewScreen.kt @@ -0,0 +1,167 @@ +package nart.simpleanki.feature.review + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.TouchApp +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import nart.simpleanki.core.domain.model.Card +import nart.simpleanki.core.domain.model.CardState +import nart.simpleanki.di.StudyArgs +import nart.simpleanki.ui.components.FlipCard +import nart.simpleanki.ui.theme.AzriTheme +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun ReviewScreen( + deckId: String?, + onDone: () -> Unit, + folderId: String? = null, + viewModel: ReviewViewModel = koinViewModel { parametersOf(StudyArgs(deckId = deckId, folderId = folderId)) }, +) { + val state by viewModel.uiState.collectAsState() + ReviewContent(state = state, onDone = onDone) +} + +/** Stateless review carousel, decoupled from the ViewModel for previews. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReviewContent(state: ReviewUiState, onDone: () -> Unit) { + val pagerState = rememberPagerState(pageCount = { state.cards.size }) + Scaffold( + topBar = { + TopAppBar( + title = { + if (state.cards.isNotEmpty()) { + Text("${pagerState.currentPage + 1} of ${state.cards.size}") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + ), + navigationIcon = { + TextButton(onClick = onDone) { Text("Quit") } + }, + ) + }, + ) { padding -> + Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + when { + state.loading -> CircularProgressIndicator() + state.cards.isEmpty() -> EmptyReview(onDone) + else -> { + // Flip resets when the page changes (mirrors iOS clearing flips on scroll). + var revealed by remember(pagerState.currentPage) { mutableStateOf(false) } + var showHint by remember { mutableStateOf(true) } + HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> + val card = state.cards[page] + key(card.id) { + FlipCard( + card = card, + revealed = page == pagerState.currentPage && revealed, + onFlip = { + revealed = true + showHint = false + }, + modifier = Modifier.fillMaxSize().padding(20.dp), + ) + } + } + if (showHint) { + Row( + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.TouchApp, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Tap to flip", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } +} + +@Composable +private fun EmptyReview(onDone: () -> Unit) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "No cards to review here.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = onDone) { Text("Close") } + } +} + +private fun previewCard(id: String, front: String, back: String) = Card( + id = id, front = front, back = back, deckId = "d", + dateCreated = 0, lastModified = 0, fsrsDue = 0, fsrsState = CardState.Review.value, +) + +@Preview(name = "Review · populated", showBackground = true) +@Composable +private fun ReviewPopulatedPreview() { + AzriTheme { + ReviewContent( + state = ReviewUiState( + loading = false, + cards = listOf( + previewCard("1", "hola", "hello"), + previewCard("2", "adiós", "goodbye"), + ), + ), + onDone = {}, + ) + } +} + +@Preview(name = "Review · empty", showBackground = true) +@Composable +private fun ReviewEmptyPreview() { + AzriTheme { + ReviewContent(state = ReviewUiState(loading = false, cards = emptyList()), onDone = {}) + } +} diff --git a/app/src/main/java/nart/simpleanki/feature/review/ReviewViewModel.kt b/app/src/main/java/nart/simpleanki/feature/review/ReviewViewModel.kt new file mode 100644 index 0000000..985f47e --- /dev/null +++ b/app/src/main/java/nart/simpleanki/feature/review/ReviewViewModel.kt @@ -0,0 +1,78 @@ +package nart.simpleanki.feature.review + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import nart.simpleanki.core.analytics.LoggableEvent +import nart.simpleanki.core.analytics.LogManager +import nart.simpleanki.core.data.repository.CardRepository +import nart.simpleanki.core.data.repository.DeckRepository +import nart.simpleanki.core.domain.fsrs.StudyQueueBuilder +import nart.simpleanki.core.domain.model.Card +import nart.simpleanki.core.domain.model.ReviewCardFilter + +data class ReviewUiState( + val loading: Boolean = true, + val cards: List = emptyList(), +) + +/** + * Drives a read-only Review (cram) session: snapshots a deck's or folder's cards once, applies the + * review filter (direction) + optional shuffle, and exposes the immutable pool. No rating, no FSRS + * scheduling, no card writes — purely browsing. + */ +class ReviewViewModel( + /** Deck to review; null when reviewing a folder. */ + private val deckId: String?, + /** Folder to review (all cards across its decks); null when reviewing a single deck. */ + private val folderId: String?, + private val cardRepository: CardRepository, + private val deckRepository: DeckRepository, + private val now: () -> Long = { System.currentTimeMillis() }, + private val logManager: LogManager = LogManager(emptyList()), +) : ViewModel() { + + private val _uiState = MutableStateFlow(ReviewUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { load() } + } + + private suspend fun load() { + val pool = when { + folderId != null -> { + val deckIds = deckRepository.observeDecksInFolder(folderId).first().map { it.id }.toSet() + val cards = cardRepository.observeAllCards().first().filter { it.deckId in deckIds } + StudyQueueBuilder.buildReviewQueue(cards, ReviewCardFilter.All, shuffleSeed = now()) + } + deckId != null -> { + val deck = deckRepository.getById(deckId) + val cards = cardRepository.observeCards(deckId).first() + StudyQueueBuilder.buildReviewQueue( + cards = cards, + filter = deck?.reviewFilter ?: ReviewCardFilter.All, + shuffleSeed = if (deck?.shuffled == true) now() else null, + ) + } + else -> emptyList() + } + _uiState.value = ReviewUiState(loading = false, cards = pool) + logManager.track(Event.ReviewStart(deckId, folderId, pool.size)) + } + + private sealed interface Event : LoggableEvent { + data class ReviewStart(val deckId: String?, val folderId: String?, val count: Int) : Event { + override val eventName = "cram_session_start" + override val params get() = buildMap { + deckId?.let { put("deck_id", it) } + folderId?.let { put("folder_id", it) } + put("count", count) + } + } + } +} 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 2f179f3..f0ecd22 100644 --- a/app/src/main/java/nart/simpleanki/ui/navigation/AzriNavHost.kt +++ b/app/src/main/java/nart/simpleanki/ui/navigation/AzriNavHost.kt @@ -64,6 +64,7 @@ import nart.simpleanki.feature.paywall.PaywallSheet import nart.simpleanki.feature.profile.ProfileScreen import nart.simpleanki.feature.queue.StudyQueueScreen import nart.simpleanki.feature.settings.SettingsScreen +import nart.simpleanki.feature.review.ReviewScreen import nart.simpleanki.feature.study.StudyScreen import org.koin.compose.koinInject @@ -211,6 +212,7 @@ fun AzriNavHost() { folderId = folderId, onBack = { nav.popBackStack() }, onOpenDeck = { nav.navigate("deck/$it") }, + onReview = { nav.navigate("reviewFolder/$folderId") }, onNewDeck = { nav.navigate("deckEditInFolder/$folderId") }, onEditFolder = { nav.navigate("folderEdit/$folderId") }, ) @@ -227,6 +229,7 @@ fun AzriNavHost() { deckId = deckId, onBack = { nav.popBackStack() }, onStudy = { nav.navigate("study/$deckId") }, + onReview = { nav.navigate("review/$deckId") }, onAddCard = { nav.navigate("cardForm/$deckId") }, onEditCard = { cardId -> nav.navigate("cardForm/$deckId/$cardId") }, onSettings = { nav.navigate("deckEdit/$deckId") }, @@ -247,6 +250,19 @@ fun AzriNavHost() { onDone = { nav.popBackStack() }, ) } + composable("review/{deckId}") { entry -> + ReviewScreen( + deckId = entry.arguments?.getString("deckId").orEmpty(), + onDone = { nav.popBackStack() }, + ) + } + composable("reviewFolder/{folderId}") { entry -> + ReviewScreen( + deckId = null, + folderId = entry.arguments?.getString("folderId").orEmpty(), + onDone = { nav.popBackStack() }, + ) + } // 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/{deckId}") { entry -> diff --git a/app/src/test/java/nart/simpleanki/core/domain/fsrs/StudyQueueBuilderTest.kt b/app/src/test/java/nart/simpleanki/core/domain/fsrs/StudyQueueBuilderTest.kt index fb566cf..d3416d7 100644 --- a/app/src/test/java/nart/simpleanki/core/domain/fsrs/StudyQueueBuilderTest.kt +++ b/app/src/test/java/nart/simpleanki/core/domain/fsrs/StudyQueueBuilderTest.kt @@ -69,6 +69,19 @@ class StudyQueueBuilderTest { assertEquals(listOf("rev"), StudyQueueBuilder.buildReviewQueue(cards, ReviewCardFilter.ReversesOnly).map { it.id }) } + @Test + fun reviewQueue_excludesMemorizedAndDeleted() { + val cards = listOf( + card("keep"), + card("mem").copy(memorized = true), + card("gone", deleted = true), + ) + assertEquals( + listOf("keep"), + StudyQueueBuilder.buildReviewQueue(cards, ReviewCardFilter.All).map { it.id }, + ) + } + @Test fun shuffleSeed_isDeterministic() { val cards = (1..8).map { card("c$it", state = CardState.Review.value, due = now - it * day) } diff --git a/app/src/test/java/nart/simpleanki/feature/review/ReviewViewModelTest.kt b/app/src/test/java/nart/simpleanki/feature/review/ReviewViewModelTest.kt new file mode 100644 index 0000000..d847c10 --- /dev/null +++ b/app/src/test/java/nart/simpleanki/feature/review/ReviewViewModelTest.kt @@ -0,0 +1,101 @@ +package nart.simpleanki.feature.review + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +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 nart.simpleanki.core.domain.model.ReviewCardFilter +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ReviewViewModelTest { + + private val now = 1_700_000_000_000L + + @Before fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + @After fun tearDown() = Dispatchers.resetMain() + + private fun card( + id: String, + deckId: String, + reverse: Boolean = false, + memorized: Boolean = false, + deleted: Boolean = false, + ) = Card( + id = id, front = "f", back = "b", deckId = deckId, + dateCreated = now, lastModified = now, fsrsDue = now, fsrsState = CardState.Review.value, + isReverse = reverse, memorized = memorized, isDeleted = deleted, + ) + + @Test + fun deckReview_appliesDeckFilter_andExcludesMemorizedAndDeleted() = runTest { + val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) + val cardRepo = CardRepository(FakeCardDao(), now = { now }) + deckRepo.upsert( + Deck(id = "A", name = "Alpha", dateCreated = now, lastModified = now, reviewFilter = ReviewCardFilter.OriginalsOnly), + ) + cardRepo.upsert(card("orig", "A", reverse = false)) + cardRepo.upsert(card("rev", "A", reverse = true)) // excluded: OriginalsOnly + cardRepo.upsert(card("mem", "A", memorized = true)) // excluded: memorized + cardRepo.upsert(card("gone", "A", deleted = true)) // excluded: deleted (filtered at the DAO layer) + + val vm = ReviewViewModel("A", null, cardRepo, deckRepo, now = { now }) + backgroundScope.launch { vm.uiState.collect {} } + runCurrent() + + val s = vm.uiState.value + assertFalse(s.loading) + assertEquals(listOf("orig"), s.cards.map { it.id }) + } + + @Test + fun folderReview_aggregatesAcrossFoldersDecks_bothDirections() = runTest { + val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) + val cardRepo = CardRepository(FakeCardDao(), now = { now }) + deckRepo.upsert(Deck(id = "A", name = "A", folderId = "F", dateCreated = now, lastModified = now)) + deckRepo.upsert(Deck(id = "B", name = "B", folderId = "F", dateCreated = now, lastModified = now)) + deckRepo.upsert(Deck(id = "C", name = "C", folderId = null, dateCreated = now, lastModified = now)) + cardRepo.upsert(card("a1", "A")) + cardRepo.upsert(card("b1", "B", reverse = true)) + cardRepo.upsert(card("c1", "C")) // excluded: not in folder F + + val vm = ReviewViewModel(null, "F", cardRepo, deckRepo, now = { now }) + backgroundScope.launch { vm.uiState.collect {} } + runCurrent() + + val s = vm.uiState.value + assertFalse(s.loading) + // Folder review uses ReviewCardFilter.All (both directions), across F's decks only. + // Order is shuffled, so compare as a set. + assertEquals(setOf("a1", "b1"), s.cards.map { it.id }.toSet()) + } + + @Test + fun emptyDeck_yieldsEmptyPool() = runTest { + val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) + val cardRepo = CardRepository(FakeCardDao(), now = { now }) + deckRepo.upsert(Deck(id = "A", name = "A", dateCreated = now, lastModified = now)) + + val vm = ReviewViewModel("A", null, cardRepo, deckRepo, now = { now }) + backgroundScope.launch { vm.uiState.collect {} } + runCurrent() + + assertFalse(vm.uiState.value.loading) + assertEquals(emptyList(), vm.uiState.value.cards.map { it.id }) + } +} diff --git a/docs/superpowers/plans/2026-06-04-review-cram-mode.md b/docs/superpowers/plans/2026-06-04-review-cram-mode.md new file mode 100644 index 0000000..1ed27df --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-review-cram-mode.md @@ -0,0 +1,680 @@ +# Review / Cram Mode 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:** Add a read-only "Review" (cram) mode — a horizontal carousel of flip cards for a deck or folder, with no rating and no FSRS scheduling — mirroring iOS. + +**Architecture:** A new self-contained `feature/review/` (ViewModel + Screen) reusing the existing `FlipCard` composable in a Compose `HorizontalPager`. The card pool is built by the existing `StudyQueueBuilder.buildReviewQueue` (extended to exclude `memorized`). Entry points: an always-present "Review" action on deck-detail, and a folder-level action on folder-detail. Two new nav routes. No scheduling, no persistence, no effect on the study queue. + +**Tech Stack:** Kotlin, Jetpack Compose (`androidx.compose.foundation.pager`), Koin, JUnit4 + coroutines-test. + +**Build/test prefix:** ALL Gradle commands MUST be prefixed with `export JAVA_HOME=/opt/homebrew/opt/openjdk &&`. Run from `/Users/astemirboziev/Developer/SimpleAnkiProject/azri_android`. + +**Spec:** `docs/superpowers/specs/2026-06-04-review-cram-mode-design.md`. + +**Note:** new tests are JVM unit tests (run normally). The emulator is unavailable, so Compose screens are compile-verified only. + +--- + +### Task 1: Exclude memorized cards from `buildReviewQueue` + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/core/domain/fsrs/StudyQueueBuilder.kt` +- Test: `app/src/test/java/nart/simpleanki/core/domain/fsrs/StudyQueueBuilderTest.kt` + +- [ ] **Step 1: Write the failing test** + +In `StudyQueueBuilderTest.kt`, add this test after the existing `reviewQueue_filtersByDirection` test (the file already imports `Card`, `CardState`, `ReviewCardFilter`, `assertEquals`): + +```kotlin + @Test + fun reviewQueue_excludesMemorizedAndDeleted() { + val cards = listOf( + card("keep"), + card("mem").copy(memorized = true), + card("gone", deleted = true), + ) + assertEquals( + listOf("keep"), + StudyQueueBuilder.buildReviewQueue(cards, ReviewCardFilter.All).map { it.id }, + ) + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.domain.fsrs.StudyQueueBuilderTest"` +Expected: FAIL on `reviewQueue_excludesMemorizedAndDeleted` — `mem` is still present (`expected:<[keep]> but was:<[keep, mem]>`), because the current filter does not exclude memorized cards. + +- [ ] **Step 3: Add the `memorized` exclusion** + +In `StudyQueueBuilder.kt`, in `buildReviewQueue`, change the first filter line. It currently reads: + +```kotlin + val filtered = cards.filter { !it.isDeleted }.filter { card -> +``` + +Change it to: + +```kotlin + val filtered = cards.filter { !it.isDeleted && !it.memorized }.filter { card -> +``` + +(Nothing else in the function changes.) + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.domain.fsrs.StudyQueueBuilderTest"` +Expected: PASS (all tests in the class). + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/core/domain/fsrs/StudyQueueBuilder.kt app/src/test/java/nart/simpleanki/core/domain/fsrs/StudyQueueBuilderTest.kt +git commit -m "Exclude memorized cards from review queue" +``` + +--- + +### Task 2: `ReviewViewModel` + +**Files:** +- Create: `app/src/main/java/nart/simpleanki/feature/review/ReviewViewModel.kt` +- Test: `app/src/test/java/nart/simpleanki/feature/review/ReviewViewModelTest.kt` + +TDD: write the test first (won't compile until the VM exists), then implement. + +- [ ] **Step 1: Write the failing test** + +Create `app/src/test/java/nart/simpleanki/feature/review/ReviewViewModelTest.kt`: + +```kotlin +package nart.simpleanki.feature.review + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +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 nart.simpleanki.core.domain.model.ReviewCardFilter +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ReviewViewModelTest { + + private val now = 1_700_000_000_000L + + @Before fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + @After fun tearDown() = Dispatchers.resetMain() + + private fun card( + id: String, + deckId: String, + reverse: Boolean = false, + memorized: Boolean = false, + deleted: Boolean = false, + ) = Card( + id = id, front = "f", back = "b", deckId = deckId, + dateCreated = now, lastModified = now, fsrsDue = now, fsrsState = CardState.Review.value, + isReverse = reverse, memorized = memorized, isDeleted = deleted, + ) + + @Test + fun deckReview_appliesDeckFilter_andExcludesMemorizedAndDeleted() = runTest { + val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) + val cardRepo = CardRepository(FakeCardDao(), now = { now }) + deckRepo.upsert( + Deck(id = "A", name = "Alpha", dateCreated = now, lastModified = now, reviewFilter = ReviewCardFilter.OriginalsOnly), + ) + cardRepo.upsert(card("orig", "A", reverse = false)) + cardRepo.upsert(card("rev", "A", reverse = true)) // excluded: OriginalsOnly + cardRepo.upsert(card("mem", "A", memorized = true)) // excluded: memorized + cardRepo.upsert(card("gone", "A", deleted = true)) // excluded: deleted + + val vm = ReviewViewModel("A", null, cardRepo, deckRepo, now = { now }) + backgroundScope.launch { vm.uiState.collect {} } + runCurrent() + + val s = vm.uiState.value + assertFalse(s.loading) + assertEquals(listOf("orig"), s.cards.map { it.id }) + } + + @Test + fun folderReview_aggregatesAcrossFoldersDecks_bothDirections() = runTest { + val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) + val cardRepo = CardRepository(FakeCardDao(), now = { now }) + deckRepo.upsert(Deck(id = "A", name = "A", folderId = "F", dateCreated = now, lastModified = now)) + deckRepo.upsert(Deck(id = "B", name = "B", folderId = "F", dateCreated = now, lastModified = now)) + deckRepo.upsert(Deck(id = "C", name = "C", folderId = null, dateCreated = now, lastModified = now)) + cardRepo.upsert(card("a1", "A")) + cardRepo.upsert(card("b1", "B", reverse = true)) + cardRepo.upsert(card("c1", "C")) // excluded: not in folder F + + val vm = ReviewViewModel(null, "F", cardRepo, deckRepo, now = { now }) + backgroundScope.launch { vm.uiState.collect {} } + runCurrent() + + val s = vm.uiState.value + assertFalse(s.loading) + // Folder review uses ReviewCardFilter.All (both directions), across F's decks only. + // Order is shuffled, so compare as a set. + assertEquals(setOf("a1", "b1"), s.cards.map { it.id }.toSet()) + } + + @Test + fun emptyDeck_yieldsEmptyPool() = runTest { + val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) + val cardRepo = CardRepository(FakeCardDao(), now = { now }) + deckRepo.upsert(Deck(id = "A", name = "A", dateCreated = now, lastModified = now)) + + val vm = ReviewViewModel("A", null, cardRepo, deckRepo, now = { now }) + backgroundScope.launch { vm.uiState.collect {} } + runCurrent() + + assertFalse(vm.uiState.value.loading) + assertEquals(emptyList(), vm.uiState.value.cards.map { it.id }) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails (does not compile)** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.feature.review.ReviewViewModelTest"` +Expected: FAIL — compilation error `unresolved reference: ReviewViewModel`. + +- [ ] **Step 3: Implement `ReviewViewModel`** + +Create `app/src/main/java/nart/simpleanki/feature/review/ReviewViewModel.kt`: + +```kotlin +package nart.simpleanki.feature.review + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import nart.simpleanki.core.analytics.LoggableEvent +import nart.simpleanki.core.analytics.LogManager +import nart.simpleanki.core.data.repository.CardRepository +import nart.simpleanki.core.data.repository.DeckRepository +import nart.simpleanki.core.domain.fsrs.StudyQueueBuilder +import nart.simpleanki.core.domain.model.Card +import nart.simpleanki.core.domain.model.ReviewCardFilter + +data class ReviewUiState( + val loading: Boolean = true, + val cards: List = emptyList(), +) + +/** + * Drives a read-only Review (cram) session: snapshots a deck's or folder's cards once, applies the + * review filter (direction) + optional shuffle, and exposes the immutable pool. No rating, no FSRS + * scheduling, no card writes — purely browsing. + */ +class ReviewViewModel( + /** Deck to review; null when reviewing a folder. */ + private val deckId: String?, + /** Folder to review (all cards across its decks); null when reviewing a single deck. */ + private val folderId: String?, + private val cardRepository: CardRepository, + private val deckRepository: DeckRepository, + private val now: () -> Long = { System.currentTimeMillis() }, + private val logManager: LogManager = LogManager(emptyList()), +) : ViewModel() { + + private val _uiState = MutableStateFlow(ReviewUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { load() } + } + + private suspend fun load() { + val pool = when { + folderId != null -> { + val deckIds = deckRepository.observeDecksInFolder(folderId).first().map { it.id }.toSet() + val cards = cardRepository.observeAllCards().first().filter { it.deckId in deckIds } + StudyQueueBuilder.buildReviewQueue(cards, ReviewCardFilter.All, shuffleSeed = now()) + } + deckId != null -> { + val deck = deckRepository.getById(deckId) + val cards = cardRepository.observeCards(deckId).first() + StudyQueueBuilder.buildReviewQueue( + cards = cards, + filter = deck?.reviewFilter ?: ReviewCardFilter.All, + shuffleSeed = if (deck?.shuffled == true) now() else null, + ) + } + else -> emptyList() + } + _uiState.value = ReviewUiState(loading = false, cards = pool) + logManager.track(Event.ReviewStart(deckId, folderId, pool.size)) + } + + private sealed interface Event : LoggableEvent { + data class ReviewStart(val deckId: String?, val folderId: String?, val count: Int) : Event { + override val eventName = "review_session_start" + override val params get() = buildMap { + deckId?.let { put("deck_id", it) } + folderId?.let { put("folder_id", it) } + put("count", count) + } + } + } +} +``` + +Note: this mirrors `StudyViewModel`'s `Event`/`LoggableEvent` pattern. If `LoggableEvent.params` has a more specific type than `Map`, match it (look at `StudyViewModel.kt`); the `buildMap` type argument may need adjusting to compile. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.feature.review.ReviewViewModelTest"` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/feature/review/ReviewViewModel.kt app/src/test/java/nart/simpleanki/feature/review/ReviewViewModelTest.kt +git commit -m "Add ReviewViewModel: build read-only review pool for deck/folder" +``` + +--- + +### Task 3: `ReviewScreen` (carousel UI) + +**Files:** +- Create: `app/src/main/java/nart/simpleanki/feature/review/ReviewScreen.kt` + +Build-verified + previews (no Compose UI unit tests, per codebase convention). + +- [ ] **Step 1: Create the screen** + +Create `app/src/main/java/nart/simpleanki/feature/review/ReviewScreen.kt`: + +```kotlin +package nart.simpleanki.feature.review + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.TouchApp +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import nart.simpleanki.core.domain.model.Card +import nart.simpleanki.core.domain.model.CardState +import nart.simpleanki.di.StudyArgs +import nart.simpleanki.ui.components.FlipCard +import nart.simpleanki.ui.theme.AzriTheme +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun ReviewScreen( + deckId: String?, + onDone: () -> Unit, + folderId: String? = null, + viewModel: ReviewViewModel = koinViewModel { parametersOf(StudyArgs(deckId = deckId, folderId = folderId)) }, +) { + val state by viewModel.uiState.collectAsState() + ReviewContent(state = state, onDone = onDone) +} + +/** Stateless review carousel, decoupled from the ViewModel for previews. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReviewContent(state: ReviewUiState, onDone: () -> Unit) { + val pagerState = rememberPagerState(pageCount = { state.cards.size }) + Scaffold( + topBar = { + TopAppBar( + title = { + if (state.cards.isNotEmpty()) { + Text("${pagerState.currentPage + 1} of ${state.cards.size}") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + ), + navigationIcon = { + TextButton(onClick = onDone) { Text("Quit") } + }, + ) + }, + ) { padding -> + Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + when { + state.loading -> CircularProgressIndicator() + state.cards.isEmpty() -> EmptyReview(onDone) + else -> { + // Flip resets when the page changes (mirrors iOS clearing flips on scroll). + var revealed by remember(pagerState.currentPage) { mutableStateOf(false) } + var showHint by remember { mutableStateOf(true) } + HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> + FlipCard( + card = state.cards[page], + revealed = page == pagerState.currentPage && revealed, + onFlip = { revealed = true; showHint = false }, + modifier = Modifier.fillMaxSize().padding(20.dp), + ) + } + if (showHint) { + Row( + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.TouchApp, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Tap to flip", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } +} + +@Composable +private fun EmptyReview(onDone: () -> Unit) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "No cards to review here.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = onDone) { Text("Close") } + } +} + +private fun previewCard(id: String, front: String, back: String) = Card( + id = id, front = front, back = back, deckId = "d", + dateCreated = 0, lastModified = 0, fsrsDue = 0, fsrsState = CardState.Review.value, +) + +@Preview(name = "Review · populated", showBackground = true) +@Composable +private fun ReviewPopulatedPreview() { + AzriTheme { + ReviewContent( + state = ReviewUiState( + loading = false, + cards = listOf( + previewCard("1", "hola", "hello"), + previewCard("2", "adiós", "goodbye"), + ), + ), + onDone = {}, + ) + } +} + +@Preview(name = "Review · empty", showBackground = true) +@Composable +private fun ReviewEmptyPreview() { + AzriTheme { + ReviewContent(state = ReviewUiState(loading = false, cards = emptyList()), onDone = {}) + } +} +``` + +Note: `HorizontalPager`/`rememberPagerState(pageCount = { … })` are in `androidx.compose.foundation.pager` (stable). If the project's Compose version exposes a different `rememberPagerState` signature, adapt minimally (e.g. `rememberPagerState(initialPage = 0, pageCount = { state.cards.size })`) and report the change. + +- [ ] **Step 2: Verify it compiles** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 3: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/feature/review/ReviewScreen.kt +git commit -m "Add ReviewScreen: horizontal flip-card carousel" +``` + +--- + +### Task 4: Wire entry points (DI, nav, deck + folder detail) + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/di/AppModule.kt` +- Modify: `app/src/main/java/nart/simpleanki/ui/navigation/AzriNavHost.kt` +- Modify: `app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailScreen.kt` +- Modify: `app/src/main/java/nart/simpleanki/feature/folderdetail/FolderDetailScreen.kt` + +Build-verified. + +- [ ] **Step 1: Register `ReviewViewModel` in Koin** + +In `app/src/main/java/nart/simpleanki/di/AppModule.kt`: + +1a. Add the import alongside the other feature-VM imports (e.g. near `import nart.simpleanki.feature.study.StudyViewModel`): + +```kotlin +import nart.simpleanki.feature.review.ReviewViewModel +``` + +1b. Immediately after the existing `StudyViewModel` `viewModel { params -> … }` block (the one that does `val args = params.get()`), add a parallel block (it reuses the same `StudyArgs`): + +```kotlin + viewModel { params -> + val args = params.get() + ReviewViewModel( + deckId = args.deckId, + folderId = args.folderId, + cardRepository = get(), + deckRepository = get(), + logManager = get(), + ) + } +``` + +- [ ] **Step 2: Add nav routes and pass `onReview` callbacks** + +In `app/src/main/java/nart/simpleanki/ui/navigation/AzriNavHost.kt`: + +2a. Add the import (near `import nart.simpleanki.feature.study.StudyScreen`): + +```kotlin +import nart.simpleanki.feature.review.ReviewScreen +``` + +2b. In the `composable("deck/{deckId}") { … }` block, the `DeckDetailScreen(…)` call currently passes `onStudy = { nav.navigate("study/$deckId") }`. Add an `onReview` argument to that call: + +```kotlin + onReview = { nav.navigate("review/$deckId") }, +``` + +2c. In the `composable("folder/{folderId}") { … }` block, the `FolderDetailScreen(…)` call currently passes `onNewDeck`/`onEditFolder`. Add: + +```kotlin + onReview = { nav.navigate("reviewFolder/$folderId") }, +``` + +2d. Add two new route composables next to the existing `study/...` routes (e.g. right after the `studyFolder/{folderId}` composable): + +```kotlin + composable("review/{deckId}") { entry -> + ReviewScreen( + deckId = entry.arguments?.getString("deckId").orEmpty(), + onDone = { nav.popBackStack() }, + ) + } + composable("reviewFolder/{folderId}") { entry -> + ReviewScreen( + deckId = null, + folderId = entry.arguments?.getString("folderId").orEmpty(), + onDone = { nav.popBackStack() }, + ) + } +``` + +- [ ] **Step 3: Add the deck-detail "Review" action** + +In `app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailScreen.kt`: + +3a. Add two imports (alongside the existing ones): + +```kotlin +import androidx.compose.material.icons.filled.Style +import androidx.compose.material3.OutlinedButton +``` + +3b. Add `onReview` to the outer `DeckDetailScreen` parameter list (after `onStudy: () -> Unit,`): + +```kotlin + onReview: () -> Unit, +``` + +and pass it down in the `DeckDetailContent(…)` call inside `DeckDetailScreen` (after `onStudy = onStudy,`): + +```kotlin + onReview = onReview, +``` + +3c. Add `onReview` to the `DeckDetailContent` parameter list **with a default** (so previews and the instrumented test keep compiling). After `onStudy: () -> Unit,` add: + +```kotlin + onReview: () -> Unit = {}, +``` + +3d. In `DeckDetailContent`, the header `Column` (the one with `verticalArrangement = Arrangement.spacedBy(12.dp)`) contains the `when { studyable > 0 -> Button(...); state.total > 0 -> AllCaughtUp(...); else -> Unit }` block. Immediately **after** that `when { … }` block (and still inside the same `Column`), add an always-present Review action shown whenever the deck has cards: + +```kotlin + if (state.total > 0) { + OutlinedButton( + onClick = onReview, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = MaterialTheme.shapes.large, + ) { + Icon(Icons.Filled.Style, contentDescription = null) + Text( + "Review", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + } +``` + +(The `spacedBy(12.dp)` arrangement spaces it from the Study/AllCaughtUp block automatically — no Spacer needed.) + +- [ ] **Step 4: Add the folder-detail "Review" action** + +In `app/src/main/java/nart/simpleanki/feature/folderdetail/FolderDetailScreen.kt`: + +4a. Add the import: + +```kotlin +import androidx.compose.material.icons.filled.Style +``` + +4b. Add `onReview` to the outer `FolderDetailScreen` parameter list (after `onOpenDeck: (String) -> Unit,`): + +```kotlin + onReview: () -> Unit, +``` + +and pass it into the `FolderDetailContent(…)` call (after `onOpenDeck = onOpenDeck,`): + +```kotlin + onReview = onReview, +``` + +4c. Add `onReview` to `FolderDetailContent`'s parameter list **with a default** (so previews keep compiling). After `onOpenDeck: (String) -> Unit,` add: + +```kotlin + onReview: () -> Unit = {}, +``` + +4d. In `FolderDetailContent`'s `TopAppBar` `actions = { … }` block, add a Review action as the first item (before the Edit/New-deck icons): + +```kotlin + IconButton(onClick = onReview) { Icon(Icons.Filled.Style, "Review folder") } +``` + +- [ ] **Step 5: Verify everything compiles (app + tests)** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin :app:compileDebugAndroidTestKotlin` +Expected: BUILD SUCCESSFUL. (The `onReview` defaults on the two `*Content` composables keep existing previews and the instrumented `DeckDetailContentTest` compiling.) + +- [ ] **Step 6: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/di/AppModule.kt app/src/main/java/nart/simpleanki/ui/navigation/AzriNavHost.kt app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailScreen.kt app/src/main/java/nart/simpleanki/feature/folderdetail/FolderDetailScreen.kt +git commit -m "Wire Review mode entry points: deck detail, folder detail, nav, DI" +``` + +--- + +## Final verification + +- [ ] **Run the full app unit-test suite (no regressions)** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest` +Expected: BUILD SUCCESSFUL. + +- [ ] **Build the debug APK end-to-end** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:assembleDebug` +Expected: BUILD SUCCESSFUL. + +- [ ] **Manual smoke (when an emulator is available)** + +- Open a deck with cards → a "Review" button shows under Study (and under "You're all caught up!" when nothing is due). Tap it → a horizontal carousel; swipe between cards, tap to flip (front returns when you swipe to the next card), "{i} of {n}" updates, Quit returns. No rating buttons appear; the study queue/due counts are unchanged afterward. +- A deck whose `reviewFilter` is Originals/Reverses only shows the matching direction; a deck with `shuffled` on shows a shuffled order. +- Open a folder → the top-bar Review action starts a review across all its decks' cards (shuffled). An empty deck/folder → "No cards to review here." + Close. diff --git a/docs/superpowers/specs/2026-06-04-review-cram-mode-design.md b/docs/superpowers/specs/2026-06-04-review-cram-mode-design.md new file mode 100644 index 0000000..94d3f35 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-review-cram-mode-design.md @@ -0,0 +1,169 @@ +# Review / Cram Mode (Android) — Design + +**Date:** 2026-06-04 +**Status:** Approved (design); pending implementation plan +**Branch:** `feature/review-cram-mode` (off `main`; no overlap with open PRs #12/#13). +**Ports:** iOS `AzriKit/Sources/AzriKit/Review/ReviewManager.swift` + +`SimpleAnkiSwiftUI/.../Core/Review/ReviewView.swift`. + +## Goal + +Let users browse/self-quiz a deck's (or folder's) cards **on demand, without spaced repetition** — +a read-only flip-through that never touches FSRS scheduling or the study queue. Mirrors the iOS +Review feature: a horizontal paging carousel of flip cards. This is the "review anytime" mode the +user asked for when we built the deck-detail "You're all caught up!" state. + +## Background + +- iOS `ReviewManager.prepareCards(from: deck)` selects a deck's cards by `deck.reviewFilter` + (`all` / `originalsOnly` / `reversesOnly`), shuffles if `deck.shuffled`, and excludes + `memorized` cards. It has **no rating and no scheduling** — `advance()` just moves to the next + card. `ReviewView` is a horizontal paging `ScrollView` of `FlipCardView`s: swipe between cards, + tap to flip (resets on scroll to a new card), a "Tap to flip" hint, an "{i} of {n}" title, and a + "Quit" button. +- Android already has the scaffolding: `StudyQueueBuilder.buildReviewQueue(cards, filter, + shuffleSeed)` (defined, used by no screen), the `ReviewCardFilter` enum, and the per-deck + `Deck.reviewFilter` / `Deck.shuffled` fields (synced via Room + Firestore). `Card.memorized` + exists and is synced but has **no Android UI** (set only on iOS). The reusable + `FlipCard(card, revealed, onFlip, modifier)` composable (tap-to-flip, scroll support) is ready to + drop into a pager. + +## Decisions (from brainstorming) + +- **UX:** mirror the iOS horizontal carousel exactly (swipe between cards, tap to flip, "{i} of + {n}", Quit, tap-to-flip hint). **No rating, no FSRS, no end-of-deck summary** — bare browse. +- **Read-only:** the mode never writes cards or affects the study queue / scheduling. +- **Entry points (v1):** (a) an **always-present "Review" action on the deck-detail screen** + (visible in every state, including "all caught up"), and (b) **folder-level review** from the + folder-detail screen. +- **Deck pool:** cards filtered by that deck's `reviewFilter`, shuffled iff `deck.shuffled`. +- **Folder pool:** all cards across the folder's decks (`ReviewCardFilter.All`), always shuffled. +- **Both pools exclude `memorized` and deleted cards** (parity: a card memorized on iOS stays out + of Android review too). +- **New self-contained feature** (`feature/review/`), NOT a fork of the rating-based `StudyScreen` + (keeps the two very different UIs decoupled; avoids regressing the rating flow). + +## Components + +### `core/domain/fsrs/StudyQueueBuilder.kt` → `buildReviewQueue` (modify, one predicate) + +Add a `memorized` exclusion to the existing filter (currently `!it.isDeleted` + direction): + +```kotlin +val filtered = cards.filter { !it.isDeleted && !it.memorized }.filter { card -> + when (filter) { + ReviewCardFilter.All -> true + ReviewCardFilter.OriginalsOnly -> !card.isReverse + ReviewCardFilter.ReversesOnly -> card.isReverse + } +} +return if (shuffleSeed != null) filtered.shuffled(Random(shuffleSeed)) else filtered +``` + +Signature unchanged: `buildReviewQueue(cards, filter, shuffleSeed: Long? = null)`. Pure; still +not driven by FSRS due dates. + +### `feature/review/ReviewViewModel.kt` (new) + +```kotlin +data class ReviewUiState( + val loading: Boolean = true, + val cards: List = emptyList(), +) +``` + +The top bar shows only "{i} of {n}" + Quit (matching iOS), so the VM needs no deck/folder **name** +and therefore no `FolderRepository` dependency. + +Constructor mirrors `StudyViewModel`: +`(deckId: String?, folderId: String?, cardRepository, deckRepository, now: () -> Long = { System.currentTimeMillis() }, logManager)`. +On `init` it `launch { load() }`: + +- **Deck** (`deckId != null`): `val deck = deckRepository.getById(deckId)`; cards = + `cardRepository.observeCards(deckId).first()`; pool = + `StudyQueueBuilder.buildReviewQueue(cards, deck?.reviewFilter ?: ReviewCardFilter.All, + shuffleSeed = if (deck?.shuffled == true) now() else null)`. +- **Folder** (`folderId != null`): `deckIds = deckRepository.observeDecksInFolder(folderId).first() + .map { it.id }.toSet()`; cards = `cardRepository.observeAllCards().first().filter { it.deckId in + deckIds }`; pool = `buildReviewQueue(cards, ReviewCardFilter.All, shuffleSeed = now())` + (always shuffled). +- Sets `ReviewUiState(loading = false, cards = pool)`. Logs a `cram_session_start` + event (`{deck_id|folder_id, count}`) via `logManager`, mirroring iOS analytics. (Named + `cram_session_start`, not `review_session_start`, to avoid colliding with the FSRS study + session's existing `review_session_start` event in `StudyViewModel`.) + +The pool is an immutable snapshot (`.first()`), like the study session — it does not live-update. + +### `feature/review/ReviewScreen.kt` (new) + +`@Composable fun ReviewScreen(deckId: String?, folderId: String?, onDone: () -> Unit)` — +resolves a `ReviewViewModel` (Koin, keyed by deckId/folderId like `StudyScreen`), collects state: + +- **Loading:** centered progress. +- **Empty pool** (`cards.isEmpty()`): centered "No cards to review here." + a "Close" button → + `onDone()`. +- **Populated:** a `HorizontalPager(state = rememberPagerState { cards.size })` where each page is + `FlipCard(card = cards[page], revealed = revealed, onFlip = { revealed = true; showHint = false })` + inside padding. Flip state resets per page: + `var revealed by remember(pagerState.currentPage) { mutableStateOf(false) }` — swiping to a new + card shows its front again (mirrors iOS clearing flips on scroll). Only the current page is + interactive (off-screen pages render `revealed = false`). + - **Top bar** (a `Row` or small `TopAppBar`): a "Quit" text button → `onDone()` on the left, and + `"${pagerState.currentPage + 1} of ${cards.size}"` centered. + - **"Tap to flip" hint** at the bottom (icon + text), shown while `showHint` is true + (`var showHint by remember { mutableStateOf(true) }`), cross-fading out after the first flip. +- `@Preview`s: a populated 3-card pool and the empty state. + +### Wiring (navigation + screens) + +- **`ui/navigation/AzriNavHost.kt`:** add two routes mirroring the `study/...` ones: + - `composable("review/{deckId}") { ReviewScreen(deckId = it, folderId = null, onDone = { nav.popBackStack() }) }` + - `composable("reviewFolder/{folderId}") { ReviewScreen(deckId = null, folderId = it, onDone = { nav.popBackStack() }) }` + - In the `deck/{deckId}` composable, pass `onReview = { nav.navigate("review/$deckId") }`. + - In the `folder/{folderId}` composable, pass `onReview = { nav.navigate("reviewFolder/$folderId") }`. +- **`DeckDetailScreen.kt`:** add an `onReview: () -> Unit` parameter and surface an always-present + "Review" action — a secondary/outlined button beneath the existing study action area, rendered in + **every** state (studyable, all-caught-up, and empty-deck the button is hidden only when the deck + has zero cards). Place it so the all-caught-up state finally offers a way to study on demand. +- **`FolderDetailScreen.kt`:** add an `onReview: () -> Unit` parameter and a "Review" action + (e.g., a top-bar `IconButton` with a play/cards icon, or a button in the header) that starts a + folder review. + +## Data flow + +Tap Review on a deck/folder → nav to `review/{id}` / `reviewFolder/{id}` → `ReviewViewModel.load()` +snapshots cards, applies `buildReviewQueue` (+ shuffle) → `ReviewScreen` renders the pager → swipe +browses, tap flips (resets per page), Quit pops back. No persistence, no scheduling, no effect on +the FSRS study queue. + +## Error / empty handling + +Pure read-only presentation; the only I/O is the one-shot snapshot read. Empty pool (deck/folder +has no cards, the direction filter excludes everything, or all cards are memorized) → the empty +state, not a crash and not an instantly-dismissing session. A folder with no decks → empty state. + +## Testing + +- **`StudyQueueBuilderTest`** (extend): `buildReviewQueue` excludes `memorized` and `isDeleted` + cards; honors each `ReviewCardFilter` direction; shuffle is deterministic for a fixed seed and + identity-ordered when `shuffleSeed == null`. +- **`ReviewViewModelTest`** (new, `runTest` + fakes, mirroring `StudyQueueViewModelTest` idioms): + - Deck review builds the pool from that deck's `reviewFilter`, shuffles iff `deck.shuffled` + (fixed `now` → deterministic order), and excludes memorized/deleted cards. + - Folder review aggregates cards across the folder's decks with `ReviewCardFilter.All`, shuffled. + - Empty deck / empty folder → `loading = false`, `cards == []`. +- **`ReviewScreen`:** build-verified + `@Preview`s (populated + empty); no Compose UI unit tests + (codebase convention). + +**Build/test prefix:** Gradle commands MUST be prefixed with +`export JAVA_HOME=/opt/homebrew/opt/openjdk &&`, run from +`/Users/astemirboziev/Developer/SimpleAnkiProject/azri_android`. New tests are JVM unit tests and +run normally; the emulator is unavailable, so instrumented sources are compile-verified only. + +## Out of scope (v1) + +Rating / FSRS effects (read-only by definition); a "mark memorized" toggle (no Android UI today — +synced field only); audio autoplay on page settle (manual play via the existing `AudioPlayButton`); +an end-of-deck "Reviewed N — Restart/Done" summary (iOS has none); a deck-list ⋮ "Review" entry and +a global "review everything" entry (deferred to v2); per-folder shuffle/filter settings; the deck/ +folder name in the Review top bar (iOS shows only "{i} of {n}").