diff --git a/app/src/main/java/nart/simpleanki/core/data/firestore/FirestoreDtos.kt b/app/src/main/java/nart/simpleanki/core/data/firestore/FirestoreDtos.kt index b2b7354..127fd05 100644 --- a/app/src/main/java/nart/simpleanki/core/data/firestore/FirestoreDtos.kt +++ b/app/src/main/java/nart/simpleanki/core/data/firestore/FirestoreDtos.kt @@ -11,6 +11,7 @@ import nart.simpleanki.core.domain.model.Folder import nart.simpleanki.core.domain.model.Rating import nart.simpleanki.core.domain.model.ReviewCardFilter import nart.simpleanki.core.domain.model.ReviewLog +import nart.simpleanki.core.domain.model.TypingLog import java.util.Date /** @@ -264,3 +265,34 @@ data class StreakStateDto( ) } } + +// MARK: - Typing log (stored flat under users/{uid}/typingLogs) + +data class TypingLogDto( + var id: String = "", + @get:PropertyName("card_id") @set:PropertyName("card_id") var cardId: String = "", + @get:PropertyName("deck_id") @set:PropertyName("deck_id") var deckId: String = "", + var correct: Boolean = false, + @get:PropertyName("typed_text") @set:PropertyName("typed_text") var typedText: String = "", + var timestamp: Timestamp = Timestamp(Date(0)), +) { + fun toDomain(): TypingLog = TypingLog( + id = id, + cardId = cardId, + deckId = deckId, + correct = correct, + typedText = typedText, + timestamp = timestamp.toMillis(), + ) + + companion object { + fun fromDomain(t: TypingLog): TypingLogDto = TypingLogDto( + id = t.id, + cardId = t.cardId, + deckId = t.deckId, + correct = t.correct, + typedText = t.typedText, + timestamp = t.timestamp.toTimestamp(), + ) + } +} diff --git a/app/src/main/java/nart/simpleanki/core/data/local/AzriDatabase.kt b/app/src/main/java/nart/simpleanki/core/data/local/AzriDatabase.kt index 8eab2fc..6db52d7 100644 --- a/app/src/main/java/nart/simpleanki/core/data/local/AzriDatabase.kt +++ b/app/src/main/java/nart/simpleanki/core/data/local/AzriDatabase.kt @@ -9,10 +9,11 @@ import nart.simpleanki.core.data.local.dao.DeckDao import nart.simpleanki.core.data.local.dao.FolderDao import nart.simpleanki.core.data.local.dao.ReviewLogDao import nart.simpleanki.core.data.local.dao.StreakStateDao +import nart.simpleanki.core.data.local.dao.TypingLogDao @Database( - entities = [CardEntity::class, DeckEntity::class, FolderEntity::class, ReviewLogEntity::class, StreakStateEntity::class], - version = 3, + entities = [CardEntity::class, DeckEntity::class, FolderEntity::class, ReviewLogEntity::class, StreakStateEntity::class, TypingLogEntity::class], + version = 4, exportSchema = false, ) abstract class AzriDatabase : RoomDatabase() { @@ -21,6 +22,7 @@ abstract class AzriDatabase : RoomDatabase() { abstract fun folderDao(): FolderDao abstract fun reviewLogDao(): ReviewLogDao abstract fun streakStateDao(): StreakStateDao + abstract fun typingLogDao(): TypingLogDao } /** @@ -54,3 +56,16 @@ val MIGRATION_2_3 = object : Migration(2, 3) { ) } } + +val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `typing_logs` (" + + "`id` TEXT NOT NULL, `cardId` TEXT NOT NULL, `deckId` TEXT NOT NULL, " + + "`correct` INTEGER NOT NULL, `typedText` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, " + + "`dirty` INTEGER NOT NULL, PRIMARY KEY(`id`))", + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_typing_logs_cardId` ON `typing_logs` (`cardId`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_typing_logs_deckId` ON `typing_logs` (`deckId`)") + } +} diff --git a/app/src/main/java/nart/simpleanki/core/data/local/RoomEntities.kt b/app/src/main/java/nart/simpleanki/core/data/local/RoomEntities.kt index 463dd81..6e74ed6 100644 --- a/app/src/main/java/nart/simpleanki/core/data/local/RoomEntities.kt +++ b/app/src/main/java/nart/simpleanki/core/data/local/RoomEntities.kt @@ -90,6 +90,20 @@ data class ReviewLogEntity( val dirty: Boolean = true, ) +@Entity( + tableName = "typing_logs", + indices = [Index("cardId"), Index("deckId")], +) +data class TypingLogEntity( + @PrimaryKey val id: String, + val cardId: String, + val deckId: String, + val correct: Boolean, + val typedText: String, + val timestamp: Long, + val dirty: Boolean = true, +) + @Entity(tableName = "streak_state") data class StreakStateEntity( @PrimaryKey val id: String = "current", diff --git a/app/src/main/java/nart/simpleanki/core/data/local/RoomMappers.kt b/app/src/main/java/nart/simpleanki/core/data/local/RoomMappers.kt index f3891f9..d50204d 100644 --- a/app/src/main/java/nart/simpleanki/core/data/local/RoomMappers.kt +++ b/app/src/main/java/nart/simpleanki/core/data/local/RoomMappers.kt @@ -9,6 +9,7 @@ import nart.simpleanki.core.domain.model.Folder import nart.simpleanki.core.domain.model.Rating import nart.simpleanki.core.domain.model.ReviewCardFilter import nart.simpleanki.core.domain.model.ReviewLog +import nart.simpleanki.core.domain.model.TypingLog /** Room entity <-> domain mappers. */ @@ -132,3 +133,11 @@ fun ReviewLog.toEntity(dirty: Boolean = true): ReviewLogEntity = ReviewLogEntity review = review, dirty = dirty, ) + +fun TypingLogEntity.toDomain(): TypingLog = TypingLog( + id = id, cardId = cardId, deckId = deckId, correct = correct, typedText = typedText, timestamp = timestamp, +) + +fun TypingLog.toEntity(dirty: Boolean = true): TypingLogEntity = TypingLogEntity( + id = id, cardId = cardId, deckId = deckId, correct = correct, typedText = typedText, timestamp = timestamp, dirty = dirty, +) diff --git a/app/src/main/java/nart/simpleanki/core/data/local/dao/Daos.kt b/app/src/main/java/nart/simpleanki/core/data/local/dao/Daos.kt index c66917e..594b63c 100644 --- a/app/src/main/java/nart/simpleanki/core/data/local/dao/Daos.kt +++ b/app/src/main/java/nart/simpleanki/core/data/local/dao/Daos.kt @@ -10,6 +10,7 @@ import nart.simpleanki.core.data.local.DeckEntity import nart.simpleanki.core.data.local.FolderEntity import nart.simpleanki.core.data.local.ReviewLogEntity import nart.simpleanki.core.data.local.StreakStateEntity +import nart.simpleanki.core.data.local.TypingLogEntity @Dao interface FolderDao { @@ -99,6 +100,28 @@ interface ReviewLogDao { fun observeAll(): Flow> } +@Dao +interface TypingLogDao { + // IGNORE makes both append (fresh UUID) and pull (union) idempotent: an existing id is a no-op. + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertAll(logs: List) + + @Query("SELECT * FROM typing_logs WHERE dirty = 1") + suspend fun getDirty(): List + + @Query("UPDATE typing_logs SET dirty = 0 WHERE id = :id") + suspend fun clearDirty(id: String) + + @Query("SELECT id FROM typing_logs") + suspend fun getAllIds(): List + + @Query("SELECT * FROM typing_logs ORDER BY timestamp") + fun observeAll(): Flow> + + @Query("SELECT * FROM typing_logs WHERE deckId = :deckId ORDER BY timestamp") + fun observeForDeck(deckId: String): Flow> +} + @Dao interface StreakStateDao { @Query("SELECT * FROM streak_state WHERE id = 'current'") diff --git a/app/src/main/java/nart/simpleanki/core/data/repository/Repositories.kt b/app/src/main/java/nart/simpleanki/core/data/repository/Repositories.kt index da30487..4941dcf 100644 --- a/app/src/main/java/nart/simpleanki/core/data/repository/Repositories.kt +++ b/app/src/main/java/nart/simpleanki/core/data/repository/Repositories.kt @@ -8,12 +8,14 @@ import nart.simpleanki.core.data.local.dao.DeckDao import nart.simpleanki.core.data.local.dao.FolderDao import nart.simpleanki.core.data.local.dao.ReviewLogDao import nart.simpleanki.core.data.local.dao.StreakStateDao +import nart.simpleanki.core.data.local.dao.TypingLogDao import nart.simpleanki.core.data.local.toDomain import nart.simpleanki.core.data.local.toEntity import nart.simpleanki.core.domain.model.Card import nart.simpleanki.core.domain.model.Deck import nart.simpleanki.core.domain.model.Folder import nart.simpleanki.core.domain.model.ReviewLog +import nart.simpleanki.core.domain.model.TypingLog import nart.simpleanki.core.domain.streak.StreakState import java.util.UUID @@ -128,6 +130,22 @@ class ReviewLogRepository( fun observeLogs(): Flow> = dao.observeAll().map { rows -> rows.map { it.toDomain() } } } +/** Immutable, append-only store of Type-Practice first-attempt outcomes (decoupled from FSRS). */ +class TypingLogRepository( + private val dao: TypingLogDao, + private val newId: () -> String = { UUID.randomUUID().toString() }, +) { + /** Appends one typing outcome, assigning a fresh id when none is set; marked dirty for sync. */ + suspend fun append(log: TypingLog) { + dao.insertAll(listOf(log.copy(id = log.id.ifEmpty { newId() }).toEntity(dirty = true))) + } + + fun observeLogs(): Flow> = dao.observeAll().map { rows -> rows.map { it.toDomain() } } + + fun observeLogsForDeck(deckId: String): Flow> = + dao.observeForDeck(deckId).map { rows -> rows.map { it.toDomain() } } +} + class StreakStateRepository( private val dao: StreakStateDao, private val now: () -> Long = { System.currentTimeMillis() }, diff --git a/app/src/main/java/nart/simpleanki/core/data/repository/TypingMasteryProvider.kt b/app/src/main/java/nart/simpleanki/core/data/repository/TypingMasteryProvider.kt new file mode 100644 index 0000000..98d7672 --- /dev/null +++ b/app/src/main/java/nart/simpleanki/core/data/repository/TypingMasteryProvider.kt @@ -0,0 +1,35 @@ +package nart.simpleanki.core.data.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import nart.simpleanki.core.domain.fsrs.StudyQueueBuilder +import nart.simpleanki.core.domain.model.ReviewCardFilter +import nart.simpleanki.core.domain.typing.DeckMastery +import nart.simpleanki.core.domain.typing.TypingMastery + +/** + * Live per-deck typing mastery for the deck-detail ring. The denominator mirrors exactly what Type + * Practice studies: [StudyQueueBuilder.buildReviewQueue] applies the deck's reviewFilter and drops + * deleted/memorized cards, then blank-back cards are excluded. Mastered = latest-first-try-correct + * cards that are still in that typeable pool. + */ +class TypingMasteryProvider( + private val typingLogRepository: TypingLogRepository, + private val cardRepository: CardRepository, + private val deckRepository: DeckRepository, +) { + fun observeDeckMastery(deckId: String): Flow = + combine( + typingLogRepository.observeLogsForDeck(deckId), + cardRepository.observeCards(deckId), + ) { logs, cards -> + val deck = deckRepository.getById(deckId) + // shuffleSeed = null: a count is order-independent. + val typeable = StudyQueueBuilder.buildReviewQueue( + cards = cards, + filter = deck?.reviewFilter ?: ReviewCardFilter.All, + shuffleSeed = null, + ).filter { it.back.isNotBlank() }.map { it.id }.toSet() + TypingMastery.deckMastery(logs, typeable) + } +} diff --git a/app/src/main/java/nart/simpleanki/core/data/sync/FirestoreSyncService.kt b/app/src/main/java/nart/simpleanki/core/data/sync/FirestoreSyncService.kt index 1559680..0f1f1c2 100644 --- a/app/src/main/java/nart/simpleanki/core/data/sync/FirestoreSyncService.kt +++ b/app/src/main/java/nart/simpleanki/core/data/sync/FirestoreSyncService.kt @@ -7,10 +7,11 @@ import nart.simpleanki.core.data.firestore.DeckDto import nart.simpleanki.core.data.firestore.FolderDto import nart.simpleanki.core.data.firestore.ReviewLogDto import nart.simpleanki.core.data.firestore.StreakStateDto +import nart.simpleanki.core.data.firestore.TypingLogDto /** * Firestore-backed [RemoteSyncSource]. Collections mirror the iOS layout exactly: - * `users/{uid}/folders`, `users/{uid}/decks`, `users/{uid}/cards`, `users/{uid}/reviewLogs`. + * `users/{uid}/folders`, `users/{uid}/decks`, `users/{uid}/cards`, `users/{uid}/reviewLogs`, `users/{uid}/typingLogs`. */ class FirestoreSyncService( private val firestore: FirebaseFirestore, @@ -43,6 +44,12 @@ class FirestoreSyncService( override suspend fun pushReviewLogs(uid: String, dtos: List) = push(uid, "reviewLogs", dtos) { it.id } + override suspend fun fetchTypingLogs(uid: String): List = + col(uid, "typingLogs").get().await().toObjects(TypingLogDto::class.java) + + override suspend fun pushTypingLogs(uid: String, dtos: List) = + push(uid, "typingLogs", dtos) { it.id } + override suspend fun fetchStreakState(uid: String): StreakStateDto? = col(uid, "streakState").document("current").get().await().toObject(StreakStateDto::class.java) diff --git a/app/src/main/java/nart/simpleanki/core/data/sync/RemoteSyncSource.kt b/app/src/main/java/nart/simpleanki/core/data/sync/RemoteSyncSource.kt index 7f73ad3..44b660b 100644 --- a/app/src/main/java/nart/simpleanki/core/data/sync/RemoteSyncSource.kt +++ b/app/src/main/java/nart/simpleanki/core/data/sync/RemoteSyncSource.kt @@ -5,6 +5,7 @@ import nart.simpleanki.core.data.firestore.DeckDto import nart.simpleanki.core.data.firestore.FolderDto import nart.simpleanki.core.data.firestore.ReviewLogDto import nart.simpleanki.core.data.firestore.StreakStateDto +import nart.simpleanki.core.data.firestore.TypingLogDto /** * Remote sync seam over Firestore. Implemented by [FirestoreSyncService]; faked in tests. @@ -23,6 +24,9 @@ interface RemoteSyncSource { suspend fun fetchReviewLogs(uid: String): List suspend fun pushReviewLogs(uid: String, dtos: List) + suspend fun fetchTypingLogs(uid: String): List + suspend fun pushTypingLogs(uid: String, dtos: List) + suspend fun fetchStreakState(uid: String): StreakStateDto? suspend fun pushStreakState(uid: String, dto: StreakStateDto) } diff --git a/app/src/main/java/nart/simpleanki/core/data/sync/SyncManager.kt b/app/src/main/java/nart/simpleanki/core/data/sync/SyncManager.kt index 3e763e2..38fa27f 100644 --- a/app/src/main/java/nart/simpleanki/core/data/sync/SyncManager.kt +++ b/app/src/main/java/nart/simpleanki/core/data/sync/SyncManager.kt @@ -5,11 +5,13 @@ import nart.simpleanki.core.data.firestore.DeckDto import nart.simpleanki.core.data.firestore.FolderDto import nart.simpleanki.core.data.firestore.ReviewLogDto import nart.simpleanki.core.data.firestore.StreakStateDto +import nart.simpleanki.core.data.firestore.TypingLogDto import nart.simpleanki.core.data.local.dao.CardDao import nart.simpleanki.core.data.local.dao.DeckDao import nart.simpleanki.core.data.local.dao.FolderDao import nart.simpleanki.core.data.local.dao.ReviewLogDao import nart.simpleanki.core.data.local.dao.StreakStateDao +import nart.simpleanki.core.data.local.dao.TypingLogDao import nart.simpleanki.core.data.local.toDomain import nart.simpleanki.core.data.local.toEntity import nart.simpleanki.core.data.media.MediaManager @@ -21,7 +23,7 @@ import nart.simpleanki.core.data.media.MediaManager * (last-write-wins by `lastModified`). Soft-deletes (`isDeleted`) propagate * because a deleted remote doc simply overwrites the local row. * - * Review logs are the exception: immutable append-only events, unioned by id on pull + * Review logs and typing logs are the exception: immutable append-only events, unioned by id on pull * (never overwritten, no last-write-wins). */ class SyncManager( @@ -29,6 +31,7 @@ class SyncManager( private val deckDao: DeckDao, private val cardDao: CardDao, private val reviewLogDao: ReviewLogDao, + private val typingLogDao: TypingLogDao, private val streakStateDao: StreakStateDao, private val remote: RemoteSyncSource, private val media: MediaManager, @@ -75,6 +78,11 @@ class SyncManager( remote.pushReviewLogs(uid, rows.map { ReviewLogDto.fromDomain(it.toDomain()) }) rows.forEach { reviewLogDao.clearDirty(it.id) } } + // Typing logs are immutable, append-only events: push any dirty rows, then clear the flag. + typingLogDao.getDirty().takeIf { it.isNotEmpty() }?.let { rows -> + remote.pushTypingLogs(uid, rows.map { TypingLogDto.fromDomain(it.toDomain()) }) + rows.forEach { typingLogDao.clearDirty(it.id) } + } streakStateDao.getDirty()?.let { row -> remote.pushStreakState(uid, StreakStateDto.fromEntity(row)) streakStateDao.clearDirty(row.lastModified) @@ -115,6 +123,13 @@ class SyncManager( .map { it.toDomain().toEntity(dirty = false) } .takeIf { it.isNotEmpty() } ?.let { reviewLogDao.insertAll(it) } + // Typing logs: union by id (immutable, so no last-write-wins) — insert only the ids we lack. + val localTypingIds = typingLogDao.getAllIds().toSet() + remote.fetchTypingLogs(uid) + .filter { it.id.isNotEmpty() && it.id !in localTypingIds } + .map { it.toDomain().toEntity(dirty = false) } + .takeIf { it.isNotEmpty() } + ?.let { typingLogDao.insertAll(it) } remote.fetchStreakState(uid)?.let { dto -> if (shouldApplyRemote(streakStateDao.get()?.lastModified, dto.lastModifiedMillis())) { streakStateDao.upsert(dto.toEntity(dirty = false)) diff --git a/app/src/main/java/nart/simpleanki/core/domain/model/DomainModels.kt b/app/src/main/java/nart/simpleanki/core/domain/model/DomainModels.kt index b4031e9..d158b36 100644 --- a/app/src/main/java/nart/simpleanki/core/domain/model/DomainModels.kt +++ b/app/src/main/java/nart/simpleanki/core/domain/model/DomainModels.kt @@ -20,6 +20,16 @@ data class ReviewLog( val cardId: String = "", ) +/** One Type-Practice first-attempt outcome for a card (append-only; decoupled from FSRS). */ +data class TypingLog( + val id: String = "", + val cardId: String = "", + val deckId: String = "", + val correct: Boolean, + val typedText: String, + val timestamp: Long, +) + data class Card( val id: String, val front: String, diff --git a/app/src/main/java/nart/simpleanki/core/domain/typing/AnswerDiff.kt b/app/src/main/java/nart/simpleanki/core/domain/typing/AnswerDiff.kt new file mode 100644 index 0000000..e6b07e8 --- /dev/null +++ b/app/src/main/java/nart/simpleanki/core/domain/typing/AnswerDiff.kt @@ -0,0 +1,62 @@ +package nart.simpleanki.core.domain.typing + +/** + * Character-level diff between a typed answer and the expected answer, for the wrong-answer reveal. + * Matching is case-insensitive (the answer check is too) but accent-sensitive, so "é" vs "e" is a + * mismatch. Returns, for each of the expected and typed strings, a list of coalesced [Segment]s + * marking which runs are on the longest common subsequence ([Kind.Match]) and which differ + * ([Kind.Mismatch] — i.e. missing chars in the expected string, extra/wrong chars in the typed one). + */ +object AnswerDiff { + enum class Kind { Match, Mismatch } + data class Segment(val text: String, val kind: Kind) + data class Result(val expected: List, val typed: List) + + fun diff(typed: String, expected: String): Result { + val a = typed + val b = expected + val n = a.length + val m = b.length + // dp[i][j] = LCS length of a[i..] and b[j..] (case-insensitive char equality). + val dp = Array(n + 1) { IntArray(m + 1) } + for (i in n - 1 downTo 0) { + for (j in m - 1 downTo 0) { + dp[i][j] = if (a[i].matchesIgnoreCase(b[j])) dp[i + 1][j + 1] + 1 + else maxOf(dp[i + 1][j], dp[i][j + 1]) + } + } + val aMatch = BooleanArray(n) + val bMatch = BooleanArray(m) + var i = 0 + var j = 0 + while (i < n && j < m) { + if (a[i].matchesIgnoreCase(b[j])) { + aMatch[i] = true + bMatch[j] = true + i++ + j++ + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + i++ + } else { + j++ + } + } + return Result(expected = segmentsOf(b, bMatch), typed = segmentsOf(a, aMatch)) + } + + private fun Char.matchesIgnoreCase(other: Char): Boolean = + this == other || lowercaseChar() == other.lowercaseChar() + + /** Coalesces consecutive chars of [s] with the same match-status into [Segment]s. */ + private fun segmentsOf(s: String, match: BooleanArray): List { + val out = mutableListOf() + var k = 0 + while (k < s.length) { + val kind = if (match[k]) Kind.Match else Kind.Mismatch + val start = k + while (k < s.length && (if (match[k]) Kind.Match else Kind.Mismatch) == kind) k++ + out += Segment(s.substring(start, k), kind) + } + return out + } +} diff --git a/app/src/main/java/nart/simpleanki/core/domain/typing/AnswerMatcher.kt b/app/src/main/java/nart/simpleanki/core/domain/typing/AnswerMatcher.kt new file mode 100644 index 0000000..4fc73b7 --- /dev/null +++ b/app/src/main/java/nart/simpleanki/core/domain/typing/AnswerMatcher.kt @@ -0,0 +1,22 @@ +package nart.simpleanki.core.domain.typing + +/** + * Normalizes and compares typed answers for Type Practice. Case-insensitive, whitespace-insensitive, + * and surrounding-punctuation-insensitive (leading/trailing Unicode punctuation is stripped — e.g. + * "¿cómo estás?" -> "cómo estás"), but accent/diacritic-SENSITIVE ("café" != "cafe"). The objective + * signal it produces is the basis for mastery + (Phase 2) diagnostics, so it stays strict on accents. + */ +object AnswerMatcher { + // Leading/trailing Unicode punctuation (\p{P}) and whitespace. + // Note: Unicode Symbol chars (\p{S}, e.g. ©, ★) are intentionally preserved. + private val edges = Regex("^[\\p{P}\\s]+|[\\p{P}\\s]+$") + private val innerWhitespace = Regex("\\s+") + + fun normalize(input: String): String = + input.replace(edges, "").replace(innerWhitespace, " ").lowercase() + + fun matches(typed: String, expected: String): Boolean { + val n = normalize(expected) + return n.isNotEmpty() && normalize(typed) == n + } +} diff --git a/app/src/main/java/nart/simpleanki/core/domain/typing/TypeDirection.kt b/app/src/main/java/nart/simpleanki/core/domain/typing/TypeDirection.kt new file mode 100644 index 0000000..dcfe055 --- /dev/null +++ b/app/src/main/java/nart/simpleanki/core/domain/typing/TypeDirection.kt @@ -0,0 +1,9 @@ +package nart.simpleanki.core.domain.typing + +/** Which side the user types in a Type-Practice session (session-only; not persisted). */ +enum class TypeDirection { + /** Prompt the front, type the back (the answer). Default — matches normal review. */ + TypeBack, + /** Prompt the back, type the front. For decks whose target term sits on the front. */ + TypeFront, +} diff --git a/app/src/main/java/nart/simpleanki/core/domain/typing/TypePracticeSession.kt b/app/src/main/java/nart/simpleanki/core/domain/typing/TypePracticeSession.kt new file mode 100644 index 0000000..1dba55d --- /dev/null +++ b/app/src/main/java/nart/simpleanki/core/domain/typing/TypePracticeSession.kt @@ -0,0 +1,117 @@ +package nart.simpleanki.core.domain.typing + +import nart.simpleanki.core.domain.model.Card + +/** Result of submitting a typed answer. */ +sealed interface SubmitResult { + data object Correct : SubmitResult + data class Wrong(val expected: String) : SubmitResult +} + +/** End-of-session summary (first-try based; accuracy is a 0..100 percent). */ +data class SessionReport( + val completed: Int, + val firstTryCorrect: Int, + val firstTryAccuracy: Int, + val bestCombo: Int, + val newlyMastered: Int, +) + +/** + * In-memory state machine for one Type-Practice session — Android-free and unit-testable. + * + * Whole deck, retry-until-correct: a wrong FIRST attempt is revealed then requeued to later in the + * session; the card's first-attempt correctness is what's scored. Each first-attempt outcome is + * finalized exactly once (correct-on-first, or after [continueAfterWrong] / [override]) and emitted + * to [onFinalize] so the caller persists exactly one log per card. Requeued retries clear the loop + * but never re-score and never emit. + */ +class TypePracticeSession( + pool: List, + private val previouslyMastered: Set = emptySet(), + private val onFinalize: (card: Card, correct: Boolean, typed: String) -> Unit = { _, _, _ -> }, + private val direction: TypeDirection = TypeDirection.TypeBack, +) { + private val queue = ArrayDeque(pool) + private val firstTry = LinkedHashMap() // finalized first-try outcome per card + private var combo = 0 + private var bestCombo = 0 + + // Reveal state after a wrong submit, until continue/override. + private var awaiting = false + private var awaitingFirstAttempt = false + private var awaitingTyped = "" + + private fun answerOf(card: Card): String = + if (direction == TypeDirection.TypeFront) card.front else card.back + + val current: Card? get() = queue.firstOrNull() + val remaining: Int get() = queue.size + val isFinished: Boolean get() = queue.isEmpty() + /** True while a wrong answer is revealed, awaiting Continue/override. */ + val isRevealing: Boolean get() = awaiting + /** "I was right" is only offered on a first attempt. */ + val canOverride: Boolean get() = awaiting && awaitingFirstAttempt + /** The live combo (consecutive first-try corrects; resets to 0 on any wrong submit). */ + val currentCombo: Int get() = combo + + fun submit(answer: String): SubmitResult { + val card = current ?: return SubmitResult.Correct + if (awaiting) return SubmitResult.Wrong(answerOf(card)) // UI gates this; be safe + val firstAttempt = card.id !in firstTry + return if (AnswerMatcher.matches(answer, answerOf(card))) { + if (firstAttempt) { + firstTry[card.id] = true + combo += 1 + if (combo > bestCombo) bestCombo = combo + onFinalize(card, true, answer) + } + queue.removeFirst() + SubmitResult.Correct + } else { + combo = 0 + awaiting = true + awaitingFirstAttempt = firstAttempt + awaitingTyped = answer + SubmitResult.Wrong(answerOf(card)) + } + } + + /** Dismiss the reveal as wrong: finalize the first-try outcome (once) and requeue the card. */ + fun continueAfterWrong() { + val card = current ?: return + if (!awaiting) return + if (awaitingFirstAttempt && card.id !in firstTry) { + firstTry[card.id] = false + onFinalize(card, false, awaitingTyped) + } + awaiting = false + queue.removeFirst() + queue.addLast(card) // returns later this session + } + + /** "I was right" — first attempts only: finalize correct and clear the card. */ + fun override() { + val card = current ?: return + if (!awaiting || !awaitingFirstAttempt) return + if (card.id !in firstTry) { + firstTry[card.id] = true + onFinalize(card, true, awaitingTyped) + } + // Override does not advance the combo — the player needed the reveal. + awaiting = false + queue.removeFirst() + } + + fun report(): SessionReport { + val completed = firstTry.size + val correct = firstTry.values.count { it } + return SessionReport( + completed = completed, + firstTryCorrect = correct, + firstTryAccuracy = if (completed == 0) 0 else correct * 100 / completed, + bestCombo = bestCombo, + newlyMastered = firstTry.count { (k, v) -> v && k !in previouslyMastered }, + ) + } +} diff --git a/app/src/main/java/nart/simpleanki/core/domain/typing/TypingMastery.kt b/app/src/main/java/nart/simpleanki/core/domain/typing/TypingMastery.kt new file mode 100644 index 0000000..0ae96e3 --- /dev/null +++ b/app/src/main/java/nart/simpleanki/core/domain/typing/TypingMastery.kt @@ -0,0 +1,25 @@ +package nart.simpleanki.core.domain.typing + +import nart.simpleanki.core.domain.model.TypingLog + +/** Mastered/total typed cards for one deck. */ +data class DeckMastery(val mastered: Int, val total: Int) + +/** + * Derives typing mastery purely from logs (single source of truth, like StreakProvider over review + * logs). A card is "mastered" iff its LATEST first-attempt log is correct, so mastery regresses + * honestly when a later session misses it. + */ +object TypingMastery { + fun latestPerCard(logs: List): Map = + logs.groupBy { it.cardId }.mapValues { (_, group) -> group.maxBy { it.timestamp } } + + fun masteredCardIds(logs: List): Set = + latestPerCard(logs).filterValues { it.correct }.keys + + fun deckMastery(logs: List, deckCardIds: Set): DeckMastery = + DeckMastery( + mastered = masteredCardIds(logs).count { it in deckCardIds }, + total = deckCardIds.size, + ) +} diff --git a/app/src/main/java/nart/simpleanki/di/AppModule.kt b/app/src/main/java/nart/simpleanki/di/AppModule.kt index 14f804b..fc88090 100644 --- a/app/src/main/java/nart/simpleanki/di/AppModule.kt +++ b/app/src/main/java/nart/simpleanki/di/AppModule.kt @@ -35,6 +35,7 @@ import nart.simpleanki.core.csv.DefaultCsvImportService import nart.simpleanki.core.data.local.AzriDatabase import nart.simpleanki.core.data.local.MIGRATION_1_2 import nart.simpleanki.core.data.local.MIGRATION_2_3 +import nart.simpleanki.core.data.local.MIGRATION_3_4 import nart.simpleanki.core.data.media.FirebaseMediaRepository import nart.simpleanki.core.data.media.LocalMediaStore import nart.simpleanki.core.data.media.MediaManager @@ -46,6 +47,8 @@ import nart.simpleanki.core.data.repository.ReviewLogRepository import nart.simpleanki.core.data.repository.StreakProvider import nart.simpleanki.core.data.repository.StreakStateManager import nart.simpleanki.core.data.repository.StreakStateRepository +import nart.simpleanki.core.data.repository.TypingLogRepository +import nart.simpleanki.core.data.repository.TypingMasteryProvider import nart.simpleanki.core.data.settings.DataStoreSettingsRepository import nart.simpleanki.core.data.settings.SettingsRepository import nart.simpleanki.core.data.sync.FirestoreSyncService @@ -70,6 +73,7 @@ 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.typepractice.TypePracticeViewModel import nart.simpleanki.feature.sync.SyncViewModel import org.koin.android.ext.koin.androidContext import org.koin.core.module.dsl.viewModel @@ -138,7 +142,7 @@ val appModule = module { // Local persistence (Room) single { Room.databaseBuilder(androidContext(), AzriDatabase::class.java, "azri.db") - .addMigrations(MIGRATION_1_2, MIGRATION_2_3) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) .fallbackToDestructiveMigration(dropAllTables = true) .build() } @@ -147,19 +151,22 @@ val appModule = module { single { get().cardDao() } single { get().reviewLogDao() } single { get().streakStateDao() } + single { get().typingLogDao() } // Repositories single { FolderRepository(get()) } single { DeckRepository(get()) } single { CardRepository(get()) } single { ReviewLogRepository(get()) } + single { TypingLogRepository(get()) } + single { TypingMasteryProvider(get(), get(), get()) } single { StreakStateRepository(get()) } single { StreakProvider(get(), get()) } single { StreakStateManager(get(), get()) } // Sync single { FirestoreSyncService(get()) } - single { SyncManager(get(), get(), get(), get(), get(), get(), get()) } + single { SyncManager(get(), get(), get(), get(), get(), get(), get(), get()) } // Billing / entitlement single { EntitlementCache(androidContext()) } @@ -185,7 +192,8 @@ val appModule = module { DeckDetailViewModel( deckId = params.get(), cardRepository = get(), - deckRepository = get() + deckRepository = get(), + typingMasteryProvider = get(), ) } viewModel { params -> @@ -221,6 +229,17 @@ val appModule = module { logManager = get(), ) } + viewModel { params -> + val args = params.get() + // Type Practice is deck-level only (v1) — args.folderId is intentionally unused. + TypePracticeViewModel( + deckId = args.deckId, + cardRepository = get(), + deckRepository = get(), + typingLogRepository = 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 1d02d51..77ab3ed 100644 --- a/app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailScreen.kt +++ b/app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailScreen.kt @@ -6,10 +6,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons @@ -17,11 +19,13 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Keyboard 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.CircularProgressIndicator import androidx.compose.material3.OutlinedButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -65,6 +69,7 @@ import kotlinx.coroutines.launch import nart.simpleanki.core.domain.fsrs.IntervalFormatter import nart.simpleanki.core.domain.model.Card import nart.simpleanki.core.domain.model.CardState +import nart.simpleanki.core.domain.typing.DeckMastery import nart.simpleanki.ui.components.AzriCard import nart.simpleanki.ui.theme.AzriTheme import org.koin.androidx.compose.koinViewModel @@ -76,6 +81,7 @@ fun DeckDetailScreen( onBack: () -> Unit, onStudy: () -> Unit, onReview: () -> Unit, + onTypePractice: () -> Unit, onAddCard: () -> Unit, onEditCard: (String) -> Unit, onSettings: () -> Unit, @@ -91,6 +97,7 @@ fun DeckDetailScreen( onBack = onBack, onStudy = onStudy, onReview = onReview, + onTypePractice = onTypePractice, onAddCard = onAddCard, onEditCard = onEditCard, onSettings = onSettings, @@ -117,6 +124,7 @@ fun DeckDetailContent( onBack: () -> Unit, onStudy: () -> Unit, onReview: () -> Unit = {}, + onTypePractice: () -> Unit = {}, onAddCard: () -> Unit, onEditCard: (String) -> Unit, onSettings: () -> Unit, @@ -232,6 +240,36 @@ fun DeckDetailContent( ) } } + if (state.total > 0) { + OutlinedButton( + onClick = onTypePractice, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = MaterialTheme.shapes.large, + ) { + if (state.mastery.total > 0) { + CircularProgressIndicator( + progress = { state.mastery.mastered.toFloat() / state.mastery.total }, + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } else { + Icon(Icons.Filled.Keyboard, contentDescription = null) + } + Text( + "Type Practice", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 8.dp), + ) + if (state.mastery.total > 0) { + Spacer(Modifier.weight(1f)) + Text( + "${state.mastery.mastered}/${state.mastery.total} mastered", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } } if (state.cards.isEmpty()) { @@ -435,6 +473,7 @@ private fun DeckDetailPreview() { previewCard("2", "gracias", "thank you", CardState.Review, fsrsDue = now + 4 * 86_400_000L), previewCard("3", "por favor", "please", CardState.Learning, fsrsDue = now - 60_000L), ), + mastery = DeckMastery(mastered = 1, total = 2), ), onQueryChange = {}, onBack = {}, onStudy = {}, onAddCard = {}, onEditCard = {}, onSettings = {}, now = now, diff --git a/app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailViewModel.kt b/app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailViewModel.kt index 77aa22e..47e38c9 100644 --- a/app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailViewModel.kt +++ b/app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailViewModel.kt @@ -6,13 +6,16 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import nart.simpleanki.core.data.repository.CardRepository import nart.simpleanki.core.data.repository.DeckRepository +import nart.simpleanki.core.data.repository.TypingMasteryProvider import nart.simpleanki.core.domain.model.Card import nart.simpleanki.core.domain.model.CardState import nart.simpleanki.core.domain.fsrs.withDueTicks +import nart.simpleanki.core.domain.typing.DeckMastery data class DeckDetailUiState( val deckId: String, @@ -21,6 +24,7 @@ data class DeckDetailUiState( val query: String = "", val dueCount: Int = 0, val newCount: Int = 0, + val mastery: DeckMastery = DeckMastery(0, 0), ) { val total: Int get() = cards.size @@ -35,12 +39,17 @@ class DeckDetailViewModel( private val deckId: String, private val cardRepository: CardRepository, deckRepository: DeckRepository? = null, + typingMasteryProvider: TypingMasteryProvider? = null, private val now: () -> Long = { System.currentTimeMillis() }, ) : ViewModel() { private val queryFlow = MutableStateFlow("") private val deckNameFlow = MutableStateFlow("") + // TypingMasteryProvider observes the deck's cards independently (a 2nd cold Room flow) so it stays + // reusable; a momentary cards/mastery skew during a write is benign for this soft progress ring. + private val masteryFlow = typingMasteryProvider?.observeDeckMastery(deckId) ?: flowOf(DeckMastery(0, 0)) + init { if (deckRepository != null) { viewModelScope.launch { @@ -54,7 +63,8 @@ class DeckDetailViewModel( cardRepository.observeCards(deckId).withDueTicks(now), queryFlow, deckNameFlow, - ) { (cards, nowMillis), query, name -> + masteryFlow, + ) { (cards, nowMillis), query, name, mastery -> DeckDetailUiState( deckId = deckId, deckName = name, @@ -62,6 +72,7 @@ class DeckDetailViewModel( query = query, newCount = cards.count { it.fsrsState == CardState.New.value }, dueCount = cards.count { it.fsrsState != CardState.New.value && it.fsrsDue <= nowMillis }, + mastery = mastery, ) }.stateIn( scope = viewModelScope, diff --git a/app/src/main/java/nart/simpleanki/feature/study/StudyScreen.kt b/app/src/main/java/nart/simpleanki/feature/study/StudyScreen.kt index 8f4381f..c2c9d97 100644 --- a/app/src/main/java/nart/simpleanki/feature/study/StudyScreen.kt +++ b/app/src/main/java/nart/simpleanki/feature/study/StudyScreen.kt @@ -40,6 +40,7 @@ import nart.simpleanki.core.domain.model.Card import nart.simpleanki.core.domain.model.CardState import nart.simpleanki.core.domain.model.Rating import nart.simpleanki.ui.theme.AzriTheme +import nart.simpleanki.ui.theme.RatingColors import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf import nart.simpleanki.di.StudyArgs @@ -151,10 +152,10 @@ private fun StudyCard(state: StudyUiState, onReveal: () -> Unit, onRate: (Rating ) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { // iOS rating colors (SwiftUI system): again=pink, hard=orange, good=indigo, easy=mint. - RatingButton("Again", state.ratingIntervals[Rating.Again], Color(0xFFFF2D55), Modifier.weight(1f)) { onRate(Rating.Again) } - RatingButton("Hard", state.ratingIntervals[Rating.Hard], Color(0xFFFF9500), Modifier.weight(1f)) { onRate(Rating.Hard) } - RatingButton("Good", state.ratingIntervals[Rating.Good], Color(0xFF5856D6), Modifier.weight(1f)) { onRate(Rating.Good) } - RatingButton("Easy", state.ratingIntervals[Rating.Easy], Color(0xFF00C7BE), Modifier.weight(1f)) { onRate(Rating.Easy) } + RatingButton("Again", state.ratingIntervals[Rating.Again], RatingColors.Again, Modifier.weight(1f)) { onRate(Rating.Again) } + RatingButton("Hard", state.ratingIntervals[Rating.Hard], RatingColors.Hard, Modifier.weight(1f)) { onRate(Rating.Hard) } + RatingButton("Good", state.ratingIntervals[Rating.Good], RatingColors.Good, Modifier.weight(1f)) { onRate(Rating.Good) } + RatingButton("Easy", state.ratingIntervals[Rating.Easy], RatingColors.Easy, Modifier.weight(1f)) { onRate(Rating.Easy) } } } } diff --git a/app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt b/app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt new file mode 100644 index 0000000..c21fb74 --- /dev/null +++ b/app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt @@ -0,0 +1,566 @@ +package nart.simpleanki.feature.typepractice + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.LocalFireDepartment +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +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.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import nart.simpleanki.core.domain.model.Card +import nart.simpleanki.core.domain.model.CardState +import nart.simpleanki.core.domain.typing.AnswerDiff +import nart.simpleanki.core.domain.typing.SessionReport +import nart.simpleanki.core.domain.typing.TypeDirection +import nart.simpleanki.di.StudyArgs +import nart.simpleanki.ui.components.AudioPlayButton +import nart.simpleanki.ui.components.MediaImage +import nart.simpleanki.ui.theme.AzriTheme +import nart.simpleanki.ui.theme.RatingColors +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun TypePracticeScreen( + deckId: String, + onDone: () -> Unit, + viewModel: TypePracticeViewModel = koinViewModel { parametersOf(StudyArgs(deckId = deckId)) }, +) { + val state by viewModel.uiState.collectAsState() + TypePracticeContent( + state = state, + onChooseDirection = viewModel::chooseDirection, + onInput = viewModel::onInput, + onSubmit = viewModel::onSubmit, + onDontKnow = viewModel::onDontKnow, + onContinue = viewModel::onContinue, + onOverride = viewModel::onOverride, + onRestart = viewModel::restart, + onDone = onDone, + ) +} + +/** Stateless gamified Type-Practice UI, decoupled from the ViewModel for previews. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TypePracticeContent( + state: TypePracticeUiState, + onChooseDirection: (TypeDirection) -> Unit, + onInput: (String) -> Unit, + onSubmit: () -> Unit, + onDontKnow: () -> Unit, + onContinue: () -> Unit, + onOverride: () -> Unit, + onRestart: () -> Unit, + onDone: () -> Unit, +) { + val inSession = !state.loading && !state.awaitingDirection && !state.finished + val progressTarget = if (inSession && state.total > 0) (state.total - state.remaining).toFloat() / state.total else 0f + val progress by animateFloatAsState(targetValue = progressTarget, animationSpec = tween(300), label = "progress") + // One persistent focus requester for the bottom-bar field — re-focused on each new card so the + // keyboard stays up for the whole session (the field never unmounts mid-session). + val focus = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + LaunchedEffect(state.cardTick) { if (inSession) runCatching { focus.requestFocus() } } + // On a wrong answer, release focus so the IME slides away and the result sheet has room. + // On advance, cardTick changes and the effect above re-focuses, bringing the keyboard back. + LaunchedEffect(state.revealing) { if (state.revealing) focusManager.clearFocus(force = true) } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onDone) { Icon(Icons.Default.Close, contentDescription = "Close") } + }, + title = { + if (inSession) { + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth().padding(end = 12.dp) + .semantics { progressBarRangeInfo = ProgressBarRangeInfo(progress, 0f..1f) }, + color = if (state.celebrating) RatingColors.Easy else MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } else { + Text(if (state.finished) "Done" else "Type Practice") + } + }, + actions = { if (inSession) ComboChip(state.combo) }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.background), + ) + }, + bottomBar = { + if (inSession) { + // imePadding() on the bar (not the content) lifts it above the keyboard while the + // top bar stays put — mirrors CardFormScreen. The keyboard is up the whole session. + AnswerBar( + state = state, + focus = focus, + onInput = onInput, + onSubmit = onSubmit, + onDontKnow = onDontKnow, + onContinue = onContinue, + onOverride = onOverride, + modifier = Modifier.imePadding(), + ) + } + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + when { + state.loading -> CircularProgressIndicator() + state.awaitingDirection -> DirectionChooser(onChooseDirection) + state.finished -> SessionReportView(state.report, onRestart, onDone) + else -> PromptArea(state) + } + } + } +} + +private val ComboAmber = RatingColors.Hard + +@Composable +private fun ComboChip(combo: Int) { + val active = combo >= 1 + val pop = remember { Animatable(1f) } + LaunchedEffect(combo) { + if (combo >= 1) { pop.snapTo(1.25f); pop.animateTo(1f, tween(180)) } + } + Surface( + shape = RoundedCornerShape(50), + color = if (active) ComboAmber.copy(alpha = 0.18f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.padding(end = 8.dp).graphicsLayer { scaleX = pop.value; scaleY = pop.value }, + ) { + Row(Modifier.padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.LocalFireDepartment, + contentDescription = "Combo", + tint = if (active) ComboAmber else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text( + "$combo", + style = MaterialTheme.typography.labelLarge, + color = if (active) ComboAmber else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun DirectionChooser(onChoose: (TypeDirection) -> Unit) { + Column( + Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("What do you want to type?", style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center) + Spacer(Modifier.height(8.dp)) + Text( + "Pick the side you'll produce from memory.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(28.dp)) + DirectionOption("Type the back", "See the front → type the back (the answer)") { onChoose(TypeDirection.TypeBack) } + Spacer(Modifier.height(12.dp)) + DirectionOption("Type the front", "See the back → type the front") { onChoose(TypeDirection.TypeFront) } + } +} + +@Composable +private fun DirectionOption(title: String, subtitle: String, onClick: () -> Unit) { + OutlinedButton( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp), + ) { + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +private fun PromptArea(state: TypePracticeUiState) { + val card = state.current ?: return + val typeFront = state.direction == TypeDirection.TypeFront + Column( + Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + AnimatedContent( + targetState = state.cardTick, + transitionSpec = { + (slideInHorizontally(tween(250)) { it / 3 } + fadeIn(tween(250))) togetherWith + (slideOutHorizontally(tween(200)) { -it / 3 } + fadeOut(tween(200))) + }, + label = "card", + ) { _ -> + PromptCard(card, typeFront, celebrating = state.celebrating) + } + } +} + +@Composable +private fun PromptCard(card: Card, typeFront: Boolean, celebrating: Boolean) { + val mint = RatingColors.Easy + Surface( + shape = RoundedCornerShape(20.dp), + color = if (celebrating) mint.copy(alpha = 0.08f) else MaterialTheme.colorScheme.surface, + border = if (celebrating) BorderStroke(1.5.dp, mint) else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(24.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + if (celebrating) { + Box(Modifier.size(36.dp).clip(CircleShape).background(mint), contentAlignment = Alignment.Center) { + Icon(Icons.Filled.Check, contentDescription = "Correct", tint = Color.White, modifier = Modifier.size(22.dp)) + } + Spacer(Modifier.height(14.dp)) + } else { + DirectionPill(typeFront) + Spacer(Modifier.height(14.dp)) + } + if (!typeFront) { + card.image?.let { name -> + MediaImage(name, card.imagePath, Modifier.fillMaxWidth().height(160.dp)) + Spacer(Modifier.height(16.dp)) + } + } + Text( + if (typeFront) card.back else card.front, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + card.audioName?.let { name -> + Spacer(Modifier.height(16.dp)) + AudioPlayButton(name, card.audioPath) + } + } + } +} + +/** A small uppercase status chip (rounded, tinted by [color]) — used for the direction tag and the + * wrong-answer INCORRECT marker so they read as one component. */ +@Composable +private fun StatusPill(text: String, color: Color) { + Surface(shape = RoundedCornerShape(50), color = color.copy(alpha = 0.14f)) { + Text( + text, + style = MaterialTheme.typography.labelSmall, + color = color, + letterSpacing = 1.sp, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) + } +} + +@Composable +private fun DirectionPill(typeFront: Boolean) { + StatusPill(if (typeFront) "TYPE THE FRONT" else "TYPE THE BACK", MaterialTheme.colorScheme.primary) +} + +/** The bottom thumb-rail. While typing/celebrating: a persistent (always-focused) input + actions, + * above the keyboard. On a wrong answer it is replaced by the ResultSheet (no field). */ +@Composable +private fun AnswerBar( + state: TypePracticeUiState, + focus: FocusRequester, + onInput: (String) -> Unit, + onSubmit: () -> Unit, + onDontKnow: () -> Unit, + onContinue: () -> Unit, + onOverride: () -> Unit, + modifier: Modifier = Modifier, +) { + if (state.revealing) { + ResultSheet(state = state, onContinue = onContinue, onOverride = onOverride, modifier = modifier) + return + } + Surface(modifier = modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.background) { + Column( + Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Always mounted + focused while typing/celebrating so the IME stays open; the VM ignores + // input while celebrating, so it is effectively read-only then. + OutlinedTextField( + value = state.input, + onValueChange = onInput, + modifier = Modifier.fillMaxWidth().focusRequester(focus), + singleLine = true, + label = { Text("Type the answer") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { onSubmit() }), + ) + Spacer(Modifier.height(10.dp)) + if (state.celebrating) { + Text("Correct!", style = MaterialTheme.typography.titleMedium, color = RatingColors.Easy) + Spacer(Modifier.height(58.dp)) + } else { + Button( + onClick = onSubmit, + enabled = state.input.isNotBlank(), + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = MaterialTheme.shapes.large, + ) { Text("Check") } + Spacer(Modifier.height(4.dp)) + TextButton(onClick = onDontKnow, modifier = Modifier.fillMaxWidth()) { Text("Don't know") } + } + } + } +} + +/** Wrong-answer result card: rises into the space freed by the dismissed keyboard, in Azri's card + * style (white surface + hairline border, an INCORRECT status pill, color only in the diff), + * showing the correct answer vs. what the user typed, with Continue / "I was right". */ +@Composable +private fun ResultSheet( + state: TypePracticeUiState, + onContinue: () -> Unit, + onOverride: () -> Unit, + modifier: Modifier = Modifier, +) { + val pink = RatingColors.Again + val mint = RatingColors.Easy + val typedMatch = MaterialTheme.colorScheme.onSurfaceVariant + val diff = remember(state.revealedAnswer, state.lastTyped) { + AnswerDiff.diff(typed = state.lastTyped, expected = state.revealedAnswer) + } + // Starts false then targets true, so the slide-in replays on every wrong answer. This relies on + // ResultSheet being unmounted between reveals (via AnswerBar's early-return), which re-inits the + // remembered state — keep that gating if this is ever refactored, or the entrance won't replay. + val enter = remember { MutableTransitionState(false) }.apply { targetState = true } + AnimatedVisibility( + visibleState = enter, + enter = slideInVertically(tween(250)) { it } + fadeIn(tween(250)), + modifier = modifier.fillMaxWidth(), + ) { + Surface(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.background) { + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp)) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = RoundedCornerShape(20.dp), + ) { + Column(Modifier.fillMaxWidth().padding(16.dp)) { + StatusPill("INCORRECT", pink) + Spacer(Modifier.height(10.dp)) + Text("Correct answer", style = MaterialTheme.typography.labelMedium, color = typedMatch) + Spacer(Modifier.height(4.dp)) + Text( + buildAnnotatedString { + diff.expected.forEach { seg -> + when (seg.kind) { + AnswerDiff.Kind.Match -> withStyle(SpanStyle(color = mint)) { append(seg.text) } + AnswerDiff.Kind.Mismatch -> withStyle(SpanStyle(color = pink, textDecoration = TextDecoration.Underline)) { append(seg.text) } + } + } + }, + style = MaterialTheme.typography.headlineSmall, + ) + if (state.lastTyped.isNotBlank()) { + Spacer(Modifier.height(10.dp)) + Text("You typed", style = MaterialTheme.typography.labelMedium, color = typedMatch) + Spacer(Modifier.height(2.dp)) + Text( + buildAnnotatedString { + diff.typed.forEach { seg -> + when (seg.kind) { + AnswerDiff.Kind.Match -> withStyle(SpanStyle(color = typedMatch)) { append(seg.text) } + AnswerDiff.Kind.Mismatch -> withStyle(SpanStyle(color = pink, textDecoration = TextDecoration.LineThrough)) { append(seg.text) } + } + } + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + Spacer(Modifier.height(12.dp)) + Button( + onClick = onContinue, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = MaterialTheme.shapes.large, + ) { Text("Continue") } + if (state.canOverride) { + Spacer(Modifier.height(4.dp)) + TextButton(onClick = onOverride, modifier = Modifier.fillMaxWidth()) { Text("I was right") } + } + } + } + } +} + +@Composable +private fun SessionReportView(report: SessionReport?, onRestart: () -> Unit, onDone: () -> Unit) { + val r = report ?: SessionReport(0, 0, 0, 0, 0) + Column( + Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("Practice complete", style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(24.dp)) + ReportRow("Cards", r.completed.toString()) + ReportRow("First-try accuracy", "${r.firstTryAccuracy}%") + ReportRow("Best combo", r.bestCombo.toString()) + ReportRow("Newly mastered", r.newlyMastered.toString()) + Spacer(Modifier.height(28.dp)) + Button(onClick = onRestart, modifier = Modifier.fillMaxWidth().height(50.dp), shape = MaterialTheme.shapes.large) { Text("Practice again") } + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = onDone, modifier = Modifier.fillMaxWidth().height(50.dp), shape = MaterialTheme.shapes.large) { Text("Done") } + } +} + +@Composable +private fun ReportRow(label: String, value: String) { + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text(value, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +private val previewCard = Card( + id = "c1", front = "¿Cómo estás?", back = "How are you?", deckId = "d1", + dateCreated = 0, lastModified = 0, fsrsDue = 0, fsrsState = CardState.New.value, +) + +private fun previewState( + current: Card? = previewCard, + input: String = "", + revealing: Boolean = false, + celebrating: Boolean = false, + finished: Boolean = false, + awaitingDirection: Boolean = false, + report: SessionReport? = null, + revealedAnswer: String = "", + lastTyped: String = "", + canOverride: Boolean = false, + direction: TypeDirection? = TypeDirection.TypeBack, +) = TypePracticeUiState( + loading = false, awaitingDirection = awaitingDirection, direction = direction, current = current, + input = input, revealing = revealing, revealedAnswer = revealedAnswer, lastTyped = lastTyped, + canOverride = canOverride, remaining = 3, total = 5, combo = 3, finished = finished, + report = report, celebrating = celebrating, +) + +@Composable +private fun PreviewWrap(state: TypePracticeUiState) { + AzriTheme { + TypePracticeContent( + state = state, + onChooseDirection = {}, onInput = {}, onSubmit = {}, onDontKnow = {}, + onContinue = {}, onOverride = {}, onRestart = {}, onDone = {}, + ) + } +} + +@Preview(name = "Type · prompt", showBackground = true) +@Composable +private fun TypePromptPreview() = PreviewWrap(previewState(input = "How are")) + +@Preview(name = "Type · prompt (type front)", showBackground = true) +@Composable +private fun TypeFrontPromptPreview() = PreviewWrap(previewState(input = "¿Cómo", direction = TypeDirection.TypeFront)) + +@Preview(name = "Type · celebrating", showBackground = true) +@Composable +private fun TypeCelebratingPreview() = PreviewWrap(previewState(input = "How are you?", celebrating = true)) + +@Preview(name = "Type · revealed (wrong)", showBackground = true) +@Composable +private fun TypeRevealPreview() = PreviewWrap( + previewState(revealing = true, revealedAnswer = "How are you?", lastTyped = "how is you", canOverride = true), +) + +@Preview(name = "Type · revealed (blank)", showBackground = true) +@Composable +private fun TypeRevealBlankPreview() = PreviewWrap( + previewState(revealing = true, revealedAnswer = "How are you?", lastTyped = "", canOverride = false), +) + +@Preview(name = "Type · direction chooser", showBackground = true) +@Composable +private fun TypeDirectionChooserPreview() = PreviewWrap(previewState(awaitingDirection = true, current = null)) + +@Preview(name = "Type · report", showBackground = true) +@Composable +private fun TypeReportPreview() = PreviewWrap( + previewState(current = null, finished = true, report = SessionReport(completed = 12, firstTryCorrect = 9, firstTryAccuracy = 75, bestCombo = 5, newlyMastered = 3)), +) diff --git a/app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeViewModel.kt b/app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeViewModel.kt new file mode 100644 index 0000000..f2a230c --- /dev/null +++ b/app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeViewModel.kt @@ -0,0 +1,240 @@ +package nart.simpleanki.feature.typepractice + +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.delay +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.data.repository.TypingLogRepository +import nart.simpleanki.core.domain.fsrs.StudyQueueBuilder +import nart.simpleanki.core.domain.model.Card +import nart.simpleanki.core.domain.model.Deck +import nart.simpleanki.core.domain.model.ReviewCardFilter +import nart.simpleanki.core.domain.model.TypingLog +import nart.simpleanki.core.domain.typing.SessionReport +import nart.simpleanki.core.domain.typing.SubmitResult +import nart.simpleanki.core.domain.typing.TypeDirection +import nart.simpleanki.core.domain.typing.TypePracticeSession +import nart.simpleanki.core.domain.typing.TypingMastery + +/** How long the mint success flash holds before auto-advancing. */ +private const val CELEBRATE_MS = 900L + +data class TypePracticeUiState( + val loading: Boolean = true, + /** Show the direction chooser (start of a session, before any card). */ + val awaitingDirection: Boolean = false, + /** Chosen direction once the session has started. */ + val direction: TypeDirection? = null, + val current: Card? = null, + val input: String = "", + /** Showing the correct answer after a wrong submit. */ + val revealing: Boolean = false, + val revealedAnswer: String = "", + val lastTyped: String = "", + /** Whether "I was right" is offered (first attempts only). */ + val canOverride: Boolean = false, + val remaining: Int = 0, + val finished: Boolean = false, + val report: SessionReport? = null, + /** Increments whenever the prompt card changes; the screen keys autofocus on it. */ + val cardTick: Int = 0, + /** Live combo for the chip (consecutive first-try correct; 0 resets on a miss). */ + val combo: Int = 0, + /** Session pool size, for the progress bar (progress = (total - remaining)/total). */ + val total: Int = 0, + /** True during the brief mint success flash before auto-advancing. */ + val celebrating: Boolean = false, +) + +/** + * Drives one Type-Practice session. Decoupled from FSRS: snapshots the deck's typeable cards + * (respecting the deck's review filter), prompts for a [TypeDirection], runs the pure + * [TypePracticeSession], and appends exactly one [TypingLog] per card when its first attempt + * finalizes. No scheduler or review-log writes. + */ +class TypePracticeViewModel( + private val deckId: String?, + private val cardRepository: CardRepository, + private val deckRepository: DeckRepository, + private val typingLogRepository: TypingLogRepository, + private val now: () -> Long = { System.currentTimeMillis() }, + private val logManager: LogManager = LogManager(emptyList()), + /** Supplies the pool shuffle seed; production uses now(). Return null to disable shuffling (tests). */ + private val shuffleSeed: () -> Long? = { now() }, +) : ViewModel() { + + private lateinit var session: TypePracticeSession + private var deck: Deck? = null + private var baseCards: List = emptyList() + private var poolTotal = 0 + private val _uiState = MutableStateFlow(TypePracticeUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { viewModelScope.launch { load() } } + + /** Fetch the deck + its cards, then show the direction chooser (no session yet). */ + private suspend fun load() { + deck = deckId?.let { deckRepository.getById(it) } + baseCards = if (deckId != null) cardRepository.observeCards(deckId).first() else emptyList() + _uiState.value = TypePracticeUiState(loading = false, awaitingDirection = true) + } + + /** Called from the direction chooser; builds + starts the session in the chosen direction. */ + fun chooseDirection(direction: TypeDirection) { + viewModelScope.launch { startSession(direction) } + } + + private suspend fun startSession(direction: TypeDirection) { + val pool = StudyQueueBuilder.buildReviewQueue( + cards = baseCards, + filter = deck?.reviewFilter ?: ReviewCardFilter.All, + // Type Practice always shuffles (a fresh drill order), independent of the deck's shuffle setting. + shuffleSeed = shuffleSeed(), + ).filter { typedSide(it, direction).isNotBlank() } + poolTotal = pool.size + val previouslyMastered = TypingMastery.masteredCardIds( + typingLogRepository.observeLogsForDeck(deckId.orEmpty()).first(), + ) + session = TypePracticeSession( + pool = pool, + previouslyMastered = previouslyMastered, + onFinalize = { card, correct, typed -> + viewModelScope.launch { + typingLogRepository.append( + TypingLog(cardId = card.id, deckId = card.deckId, correct = correct, typedText = typed, timestamp = now()), + ) + } + }, + direction = direction, + ) + logManager.track(Event.Start(deckId, pool.size)) + _uiState.value = _uiState.value.copy(awaitingDirection = false, direction = direction) + renderAdvance() + if (session.isFinished) logComplete() + } + + /** The side the user TYPES for [direction] (the answer side); blank-skip uses this. */ + private fun typedSide(card: Card, direction: TypeDirection): String = + if (direction == TypeDirection.TypeFront) card.front else card.back + + fun onInput(text: String) { + if (_uiState.value.celebrating || _uiState.value.revealing) return + _uiState.value = _uiState.value.copy(input = text) + } + + fun onSubmit() { + if (!::session.isInitialized || _uiState.value.celebrating || _uiState.value.revealing) return + val typed = _uiState.value.input + val answered = session.current + when (val r = session.submit(typed)) { + SubmitResult.Correct -> { + logManager.track(Event.Answered(true)) + _uiState.value = _uiState.value.copy( + celebrating = true, + current = answered, + input = typed, + combo = session.currentCombo, + revealing = false, + ) + viewModelScope.launch { + delay(CELEBRATE_MS) + renderAdvance() + if (session.isFinished) logComplete() + } + } + is SubmitResult.Wrong -> { + logManager.track(Event.Answered(false)) + _uiState.value = _uiState.value.copy( + revealing = true, revealedAnswer = r.expected, lastTyped = typed, + canOverride = session.canOverride, combo = session.currentCombo, + ) + } + } + } + + /** "Don't know": reveal the answer without an attempt; only Continue is offered. */ + fun onDontKnow() { + if (!::session.isInitialized || _uiState.value.celebrating || _uiState.value.revealing) return + if (session.current == null) return + when (val r = session.submit("")) { + is SubmitResult.Wrong -> { + logManager.track(Event.Answered(false)) + _uiState.value = _uiState.value.copy( + revealing = true, revealedAnswer = r.expected, lastTyped = "", + canOverride = false, combo = session.currentCombo, + ) + } + SubmitResult.Correct -> renderAdvance() // unreachable (the typed side is never blank) + } + } + + fun onContinue() { + if (!::session.isInitialized) return + session.continueAfterWrong() + renderAdvance() + if (session.isFinished) logComplete() + } + + fun onOverride() { + if (!::session.isInitialized) return + session.override() + renderAdvance() + if (session.isFinished) logComplete() + } + + /** Restart re-prompts for direction (a new session). */ + fun restart() { viewModelScope.launch { load() } } + + /** Refreshes state from the session after the prompt changes (clears input, bumps autofocus tick). */ + private fun renderAdvance() { + val prev = _uiState.value + _uiState.value = prev.copy( + loading = false, + awaitingDirection = false, + current = session.current, + input = "", + revealing = false, + revealedAnswer = "", + lastTyped = "", + canOverride = false, + remaining = session.remaining, + finished = session.isFinished, + report = if (session.isFinished) session.report() else null, + cardTick = prev.cardTick + 1, + combo = session.currentCombo, + total = poolTotal, + celebrating = false, + ) + } + + private fun logComplete() { + val r = session.report() + logManager.track(Event.Complete(r.completed, r.firstTryAccuracy)) + } + + private sealed interface Event : LoggableEvent { + data class Start(val deckId: String?, val count: Int) : Event { + override val eventName = "type_practice_start" + override val params get() = buildMap { + deckId?.let { put("deck_id", it) } + put("count", count) + } + } + data class Answered(val correct: Boolean) : Event { + override val eventName = "type_practice_answer" + override val params get() = mapOf("correct" to correct) + } + data class Complete(val count: Int, val accuracy: Int) : Event { + override val eventName = "type_practice_complete" + override val params get() = mapOf("count" to count, "accuracy" to accuracy) + } + } +} 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 968dc88..f5769df 100644 --- a/app/src/main/java/nart/simpleanki/ui/navigation/AzriNavHost.kt +++ b/app/src/main/java/nart/simpleanki/ui/navigation/AzriNavHost.kt @@ -70,6 +70,7 @@ 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 nart.simpleanki.feature.typepractice.TypePracticeScreen import org.koin.compose.koinInject private const val QUEUE = "queue" @@ -251,6 +252,7 @@ fun AzriNavHost() { onBack = { nav.popBackStack() }, onStudy = { nav.navigate("study/$deckId") }, onReview = { nav.navigate("review/$deckId") }, + onTypePractice = { nav.navigate("typePractice/$deckId") }, onAddCard = { nav.navigate("cardForm/$deckId") }, onEditCard = { cardId -> nav.navigate("cardForm/$deckId/$cardId") }, onSettings = { nav.navigate("deckEdit/$deckId") }, @@ -277,6 +279,12 @@ fun AzriNavHost() { onDone = { nav.popBackStack() }, ) } + composable("typePractice/{deckId}") { entry -> + TypePracticeScreen( + deckId = entry.arguments?.getString("deckId").orEmpty(), + onDone = { nav.popBackStack() }, + ) + } composable("reviewFolder/{folderId}") { entry -> ReviewScreen( deckId = null, diff --git a/app/src/main/java/nart/simpleanki/ui/theme/RatingColors.kt b/app/src/main/java/nart/simpleanki/ui/theme/RatingColors.kt new file mode 100644 index 0000000..f3ecfa5 --- /dev/null +++ b/app/src/main/java/nart/simpleanki/ui/theme/RatingColors.kt @@ -0,0 +1,15 @@ +package nart.simpleanki.ui.theme + +import androidx.compose.ui.graphics.Color + +/** + * iOS-derived spaced-repetition rating colors, shared across study modes so "a correct typed answer" + * and an "Easy" review read as the same outcome. Single source of truth (previously inline literals + * in StudyScreen). + */ +object RatingColors { + val Again = Color(0xFFFF2D55) // wrong / incorrect + val Hard = Color(0xFFFF9500) + val Good = Color(0xFF5856D6) + val Easy = Color(0xFF00C7BE) // correct / success +} diff --git a/app/src/test/java/nart/simpleanki/core/data/firestore/TypingLogDtoTest.kt b/app/src/test/java/nart/simpleanki/core/data/firestore/TypingLogDtoTest.kt new file mode 100644 index 0000000..2400f67 --- /dev/null +++ b/app/src/test/java/nart/simpleanki/core/data/firestore/TypingLogDtoTest.kt @@ -0,0 +1,14 @@ +package nart.simpleanki.core.data.firestore + +import nart.simpleanki.core.domain.model.TypingLog +import org.junit.Assert.assertEquals +import org.junit.Test + +class TypingLogDtoTest { + @Test fun roundTrip_preservesFields() { + val domain = TypingLog( + id = "t1", cardId = "c1", deckId = "d1", correct = true, typedText = "café", timestamp = 1_700_000L, + ) + assertEquals(domain, TypingLogDto.fromDomain(domain).toDomain()) + } +} diff --git a/app/src/test/java/nart/simpleanki/core/data/local/TypingLogMapperTest.kt b/app/src/test/java/nart/simpleanki/core/data/local/TypingLogMapperTest.kt new file mode 100644 index 0000000..c132b58 --- /dev/null +++ b/app/src/test/java/nart/simpleanki/core/data/local/TypingLogMapperTest.kt @@ -0,0 +1,21 @@ +package nart.simpleanki.core.data.local + +import nart.simpleanki.core.domain.model.TypingLog +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TypingLogMapperTest { + @Test fun roundTrip_preservesFields() { + val domain = TypingLog( + id = "t1", cardId = "c1", deckId = "d1", correct = true, typedText = "café", timestamp = 1_700L, + ) + val back = domain.toEntity(dirty = false).toDomain() + assertEquals(domain, back) + } + + @Test fun toEntity_defaultsDirtyTrue() { + val e = TypingLog(id = "t1", cardId = "c1", deckId = "d1", correct = false, typedText = "x", timestamp = 1L).toEntity() + assertTrue(e.dirty) + } +} diff --git a/app/src/test/java/nart/simpleanki/core/data/repository/FakeDaos.kt b/app/src/test/java/nart/simpleanki/core/data/repository/FakeDaos.kt index ab8ec42..6f9cfc0 100644 --- a/app/src/test/java/nart/simpleanki/core/data/repository/FakeDaos.kt +++ b/app/src/test/java/nart/simpleanki/core/data/repository/FakeDaos.kt @@ -8,11 +8,13 @@ import nart.simpleanki.core.data.local.DeckEntity import nart.simpleanki.core.data.local.FolderEntity import nart.simpleanki.core.data.local.ReviewLogEntity import nart.simpleanki.core.data.local.StreakStateEntity +import nart.simpleanki.core.data.local.TypingLogEntity import nart.simpleanki.core.data.local.dao.CardDao import nart.simpleanki.core.data.local.dao.DeckDao import nart.simpleanki.core.data.local.dao.FolderDao import nart.simpleanki.core.data.local.dao.ReviewLogDao import nart.simpleanki.core.data.local.dao.StreakStateDao +import nart.simpleanki.core.data.local.dao.TypingLogDao /** In-memory fakes implementing the Room DAO interfaces for pure-JVM repository tests. */ @@ -86,6 +88,26 @@ class FakeReviewLogDao : ReviewLogDao { store.map { m -> m.values.sortedBy { it.review } } } +class FakeTypingLogDao : TypingLogDao { + private val store = MutableStateFlow>(emptyMap()) + /** Every entity passed to [insertAll], in call order — lets tests assert what the caller + * forwarded (e.g. SyncManager's union filter), independent of the putIfAbsent dedup below. */ + val inserted = mutableListOf() + override suspend fun insertAll(logs: List) { + inserted += logs + store.value = store.value.toMutableMap().apply { logs.forEach { putIfAbsent(it.id, it) } } + } + override suspend fun getDirty(): List = store.value.values.filter { it.dirty } + override suspend fun clearDirty(id: String) { + store.value[id]?.let { store.value = store.value.toMutableMap().apply { put(id, it.copy(dirty = false)) } } + } + override suspend fun getAllIds(): List = store.value.keys.toList() + override fun observeAll(): Flow> = + store.map { m -> m.values.sortedBy { it.timestamp } } + override fun observeForDeck(deckId: String): Flow> = + store.map { m -> m.values.filter { it.deckId == deckId }.sortedBy { it.timestamp } } +} + class FakeStreakStateDao : StreakStateDao { private val store = MutableStateFlow(null) override fun observe(): Flow = store diff --git a/app/src/test/java/nart/simpleanki/core/data/repository/TypingLogRepositoryTest.kt b/app/src/test/java/nart/simpleanki/core/data/repository/TypingLogRepositoryTest.kt new file mode 100644 index 0000000..cae3285 --- /dev/null +++ b/app/src/test/java/nart/simpleanki/core/data/repository/TypingLogRepositoryTest.kt @@ -0,0 +1,39 @@ +package nart.simpleanki.core.data.repository + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import nart.simpleanki.core.domain.model.TypingLog +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TypingLogRepositoryTest { + private fun log(card: String, deck: String, correct: Boolean) = + TypingLog(cardId = card, deckId = deck, correct = correct, typedText = "x", timestamp = 1L) + + @Test fun append_assignsId_marksDirty_andObservable() = runTest { + val dao = FakeTypingLogDao() + val repo = TypingLogRepository(dao, newId = { "fixed-id" }) + repo.append(log("c1", "d1", correct = true)) + + val all = repo.observeLogs().first() + assertEquals(1, all.size) + assertEquals("fixed-id", all.first().id) + assertTrue(dao.getDirty().isNotEmpty()) + } + + @Test fun observeLogsForDeck_filtersByDeck() = runTest { + val repo = TypingLogRepository(FakeTypingLogDao(), newId = { java.util.UUID.randomUUID().toString() }) + repo.append(log("c1", "d1", true)) + repo.append(log("c2", "d2", true)) + assertEquals(listOf("c1"), repo.observeLogsForDeck("d1").first().map { it.cardId }) + } + + @Test fun append_withSameId_isIdempotent() = runTest { + val repo = TypingLogRepository(FakeTypingLogDao(), newId = { "id1" }) + val log = TypingLog(id = "id1", cardId = "c1", deckId = "d1", correct = true, typedText = "x", timestamp = 1L) + repo.append(log) + repo.append(log) + assertEquals(1, repo.observeLogs().first().size) + } +} diff --git a/app/src/test/java/nart/simpleanki/core/data/repository/TypingMasteryProviderTest.kt b/app/src/test/java/nart/simpleanki/core/data/repository/TypingMasteryProviderTest.kt new file mode 100644 index 0000000..5ccc392 --- /dev/null +++ b/app/src/test/java/nart/simpleanki/core/data/repository/TypingMasteryProviderTest.kt @@ -0,0 +1,50 @@ +package nart.simpleanki.core.data.repository + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +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 nart.simpleanki.core.domain.model.TypingLog +import nart.simpleanki.core.domain.typing.DeckMastery +import org.junit.Assert.assertEquals +import org.junit.Test + +class TypingMasteryProviderTest { + private val now = 1_700_000_000_000L + private fun card(id: String, deck: String, back: String = "b") = Card( + id = id, front = "f", back = back, deckId = deck, + dateCreated = now, lastModified = now, fsrsDue = now, fsrsState = CardState.New.value, + ) + + @Test fun deckMastery_excludesBlankBackCards_andCountsLatestCorrect() = runTest { + val cardDao = FakeCardDao() + val cardRepo = CardRepository(cardDao, now = { now }) + cardRepo.upsert(card("c1", "d1")) + cardRepo.upsert(card("c2", "d1")) + cardRepo.upsert(card("c3", "d1", back = " ")) // blank back -> not typeable, excluded from total + val logDao = FakeTypingLogDao() + val logRepo = TypingLogRepository(logDao, newId = { java.util.UUID.randomUUID().toString() }) + logRepo.append(TypingLog(cardId = "c1", deckId = "d1", correct = true, typedText = "x", timestamp = 1)) + + val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) + val provider = TypingMasteryProvider(logRepo, cardRepo, deckRepo) + assertEquals(DeckMastery(mastered = 1, total = 2), provider.observeDeckMastery("d1").first()) + } + + @Test fun deckMastery_excludesMemorizedAndReviewFilteredCards() = runTest { + val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) + deckRepo.upsert( + Deck(id = "d1", name = "D", dateCreated = now, lastModified = now, reviewFilter = ReviewCardFilter.OriginalsOnly), + ) + val cardRepo = CardRepository(FakeCardDao(), now = { now }) + cardRepo.upsert(card("orig", "d1")) // typeable original + cardRepo.upsert(card("rev", "d1").copy(isReverse = true)) // excluded: OriginalsOnly + cardRepo.upsert(card("mem", "d1").copy(memorized = true)) // excluded: memorized + val logRepo = TypingLogRepository(FakeTypingLogDao(), newId = { java.util.UUID.randomUUID().toString() }) + + val provider = TypingMasteryProvider(logRepo, cardRepo, deckRepo) + assertEquals(DeckMastery(mastered = 0, total = 1), provider.observeDeckMastery("d1").first()) + } +} diff --git a/app/src/test/java/nart/simpleanki/core/data/sync/SyncManagerTest.kt b/app/src/test/java/nart/simpleanki/core/data/sync/SyncManagerTest.kt index 17fc72d..6021f74 100644 --- a/app/src/test/java/nart/simpleanki/core/data/sync/SyncManagerTest.kt +++ b/app/src/test/java/nart/simpleanki/core/data/sync/SyncManagerTest.kt @@ -7,6 +7,7 @@ import nart.simpleanki.core.data.firestore.CardDto import nart.simpleanki.core.data.firestore.DeckDto import nart.simpleanki.core.data.firestore.FolderDto import nart.simpleanki.core.data.firestore.ReviewLogDto +import nart.simpleanki.core.data.firestore.TypingLogDto import nart.simpleanki.core.data.local.CardEntity import nart.simpleanki.core.data.local.FolderEntity import nart.simpleanki.core.data.media.FakeMediaUploader @@ -19,6 +20,7 @@ import nart.simpleanki.core.data.repository.FakeDeckDao import nart.simpleanki.core.data.repository.FakeFolderDao import nart.simpleanki.core.data.repository.FakeReviewLogDao import nart.simpleanki.core.data.repository.FakeStreakStateDao +import nart.simpleanki.core.data.repository.FakeTypingLogDao import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -36,11 +38,13 @@ class SyncManagerTest { var decks: MutableList = mutableListOf(), var cards: MutableList = mutableListOf(), var reviewLogs: MutableList = mutableListOf(), + var typingLogs: MutableList = mutableListOf(), var streakState: StreakStateDto? = null, ) : RemoteSyncSource { val pushedFolders = mutableListOf() val pushedCards = mutableListOf() val pushedReviewLogs = mutableListOf() + val pushedTypingLogs = mutableListOf() var pushedStreakState: StreakStateDto? = null override suspend fun fetchFolders(uid: String) = folders override suspend fun pushFolders(uid: String, dtos: List) { pushedFolders += dtos } @@ -50,6 +54,8 @@ class SyncManagerTest { override suspend fun pushCards(uid: String, dtos: List) { pushedCards += dtos } override suspend fun fetchReviewLogs(uid: String) = reviewLogs override suspend fun pushReviewLogs(uid: String, dtos: List) { pushedReviewLogs += dtos } + override suspend fun fetchTypingLogs(uid: String) = typingLogs + override suspend fun pushTypingLogs(uid: String, dtos: List) { pushedTypingLogs += dtos } override suspend fun fetchStreakState(uid: String) = streakState override suspend fun pushStreakState(uid: String, dto: StreakStateDto) { pushedStreakState = dto } } @@ -93,7 +99,7 @@ class SyncManagerTest { folderDao.upsertAll(listOf(FolderEntity(id = "f1", name = "A", lastModified = 5, dirty = true))) val remote = FakeRemote() val (m, _) = media() - val sync = SyncManager(folderDao, FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), FakeStreakStateDao(), remote, m) + val sync = SyncManager(folderDao, FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), FakeTypingLogDao(), FakeStreakStateDao(), remote, m) sync.sync("u1") @@ -118,7 +124,7 @@ class SyncManagerTest { ) ) val (m, _) = media() - val sync = SyncManager(folderDao, FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), FakeStreakStateDao(), remote, m) + val sync = SyncManager(folderDao, FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), FakeTypingLogDao(), FakeStreakStateDao(), remote, m) sync.sync("u1") @@ -137,7 +143,7 @@ class SyncManagerTest { ) ) val (m, _) = media() - val sync = SyncManager(folderDao, FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), FakeStreakStateDao(), remote, m) + val sync = SyncManager(folderDao, FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), FakeTypingLogDao(), FakeStreakStateDao(), remote, m) sync.sync("u1") @@ -152,7 +158,7 @@ class SyncManagerTest { val name = m.saveImage(byteArrayOf(1, 2, 3)) cardDao.upsertAll(listOf(cardEntity(id = "c1", image = name, imagePath = null, dirty = true))) val remote = FakeRemote() - val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), cardDao, FakeReviewLogDao(), FakeStreakStateDao(), remote, m) + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), cardDao, FakeReviewLogDao(), FakeTypingLogDao(), FakeStreakStateDao(), remote, m) sync.sync("u1") @@ -167,7 +173,7 @@ class SyncManagerTest { val cardDao = FakeCardDao() val (m, up) = media() cardDao.upsertAll(listOf(cardEntity(id = "c1", image = null, imagePath = null, dirty = true))) - val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), cardDao, FakeReviewLogDao(), FakeStreakStateDao(), FakeRemote(), m) + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), cardDao, FakeReviewLogDao(), FakeTypingLogDao(), FakeStreakStateDao(), FakeRemote(), m) sync.sync("u1") @@ -186,7 +192,7 @@ class SyncManagerTest { CardDto(id = "c1", image = "pic.jpg", imagePath = "users/u/images/pic.jpg", lastModified = ts(100)), ), ) - val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), cardDao, FakeReviewLogDao(), FakeStreakStateDao(), remote, m) + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), cardDao, FakeReviewLogDao(), FakeTypingLogDao(), FakeStreakStateDao(), remote, m) sync.sync("u1") @@ -202,7 +208,7 @@ class SyncManagerTest { val name = m.saveImage(byteArrayOf(1, 2, 3)) cardDao.upsertAll(listOf(cardEntity(id = "c1", image = name, imagePath = null, dirty = true))) val remote = FakeRemote() - val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), cardDao, FakeReviewLogDao(), FakeStreakStateDao(), remote, m) + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), cardDao, FakeReviewLogDao(), FakeTypingLogDao(), FakeStreakStateDao(), remote, m) sync.sync("u1") @@ -217,7 +223,7 @@ class SyncManagerTest { logDao.insertAll(listOf(reviewLogEntity("l1", dirty = true))) val remote = FakeRemote() val (m, _) = media() - val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), logDao, FakeStreakStateDao(), remote, m) + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), logDao, FakeTypingLogDao(), FakeStreakStateDao(), remote, m) sync.sync("u1") @@ -234,7 +240,7 @@ class SyncManagerTest { ReviewLogDto.fromDomain(reviewLogEntity("l2", dirty = false).toDomain()), )) val (m, _) = media() - val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), logDao, FakeStreakStateDao(), remote, m) + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), logDao, FakeTypingLogDao(), FakeStreakStateDao(), remote, m) sync.sync("u1") @@ -256,7 +262,7 @@ class SyncManagerTest { dao.upsert(streakEntity(lastModified = 100, dirty = true)) val remote = FakeRemote() val (m, _) = media() - val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), dao, remote, m) + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), FakeTypingLogDao(), dao, remote, m) sync.sync("u1") assertEquals(100L, remote.pushedStreakState!!.lastModifiedMillis()) assertFalse(dao.get()!!.dirty) @@ -268,8 +274,45 @@ class SyncManagerTest { dao.upsert(streakEntity(lastModified = 100, dirty = false)) val newer = StreakStateDto.fromEntity(streakEntity(lastModified = 200, dirty = false)).apply { freezeTokens = 2 } val (m, _) = media() - val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), dao, FakeRemote(streakState = newer), m) + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), FakeTypingLogDao(), dao, FakeRemote(streakState = newer), m) sync.sync("u1") assertEquals(2, dao.get()!!.freezeTokens) } + + private fun typingLogEntity(id: String, dirty: Boolean) = nart.simpleanki.core.data.local.TypingLogEntity( + id = id, cardId = "c1", deckId = "d1", correct = true, typedText = "x", timestamp = 1_000, dirty = dirty, + ) + + @Test + fun typingLogs_pushDirty_thenClearDirty() = runTest { + val logDao = FakeTypingLogDao() + logDao.insertAll(listOf(typingLogEntity("t1", dirty = true))) + val remote = FakeRemote() + val (m, _) = media() + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), logDao, FakeStreakStateDao(), remote, m) + + sync.sync("u1") + + assertEquals(listOf("t1"), remote.pushedTypingLogs.map { it.id }) + assertTrue(logDao.getDirty().isEmpty()) + } + + @Test + fun typingLogs_pullUnionsRemote_andSkipsExisting() = runTest { + val logDao = FakeTypingLogDao() + logDao.insertAll(listOf(typingLogEntity("t1", dirty = false))) + val remote = FakeRemote(typingLogs = mutableListOf( + TypingLogDto.fromDomain(typingLogEntity("t1", dirty = false).toDomain()), + TypingLogDto.fromDomain(typingLogEntity("t2", dirty = false).toDomain()), + )) + val (m, _) = media() + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), logDao, FakeStreakStateDao(), remote, m) + + sync.sync("u1") + + assertEquals(setOf("t1", "t2"), logDao.getAllIds().toSet()) + // Prove SyncManager's own filter (not just the DAO's IGNORE) skipped the duplicate t1: + // only the seed t1 and the synced t2 were ever forwarded to insertAll — not t1 twice. + assertEquals(listOf("t1", "t2"), logDao.inserted.map { it.id }) + } } diff --git a/app/src/test/java/nart/simpleanki/core/domain/typing/AnswerDiffTest.kt b/app/src/test/java/nart/simpleanki/core/domain/typing/AnswerDiffTest.kt new file mode 100644 index 0000000..9018ac9 --- /dev/null +++ b/app/src/test/java/nart/simpleanki/core/domain/typing/AnswerDiffTest.kt @@ -0,0 +1,53 @@ +package nart.simpleanki.core.domain.typing + +import nart.simpleanki.core.domain.typing.AnswerDiff.Kind.Match +import nart.simpleanki.core.domain.typing.AnswerDiff.Kind.Mismatch +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AnswerDiffTest { + private fun seg(text: String, kind: AnswerDiff.Kind) = AnswerDiff.Segment(text, kind) + + @Test fun missingCharInExpected() { + val r = AnswerDiff.diff(typed = "helo", expected = "hello") + assertEquals(listOf(seg("hel", Match), seg("l", Mismatch), seg("o", Match)), r.expected) + assertEquals(listOf(seg("helo", Match)), r.typed) + } + + @Test fun extraCharInTyped() { + val r = AnswerDiff.diff(typed = "helllo", expected = "hello") + assertEquals(listOf(seg("hello", Match)), r.expected) + assertEquals(listOf(seg("hell", Match), seg("l", Mismatch), seg("o", Match)), r.typed) + } + + @Test fun bothEmpty_noSegments() { + val r = AnswerDiff.diff(typed = "", expected = "") + assertTrue(r.expected.isEmpty()) + assertTrue(r.typed.isEmpty()) + } + + @Test fun emptyTyped_allExpectedMismatch() { + val r = AnswerDiff.diff(typed = "", expected = "cat") + assertEquals(listOf(seg("cat", Mismatch)), r.expected) + assertTrue(r.typed.isEmpty()) + } + + @Test fun caseInsensitiveMatches() { + val r = AnswerDiff.diff(typed = "HELLO", expected = "hello") + assertEquals(listOf(seg("hello", Match)), r.expected) + assertEquals(listOf(seg("HELLO", Match)), r.typed) + } + + @Test fun accentIsAMismatch() { + val r = AnswerDiff.diff(typed = "cafe", expected = "café") + assertEquals(listOf(seg("caf", Match), seg("é", Mismatch)), r.expected) + assertEquals(listOf(seg("caf", Match), seg("e", Mismatch)), r.typed) + } + + @Test fun noCommonChars_allMismatch() { + val r = AnswerDiff.diff(typed = "xyz", expected = "abc") + assertEquals(listOf(seg("abc", Mismatch)), r.expected) + assertEquals(listOf(seg("xyz", Mismatch)), r.typed) + } +} diff --git a/app/src/test/java/nart/simpleanki/core/domain/typing/AnswerMatcherTest.kt b/app/src/test/java/nart/simpleanki/core/domain/typing/AnswerMatcherTest.kt new file mode 100644 index 0000000..7946665 --- /dev/null +++ b/app/src/test/java/nart/simpleanki/core/domain/typing/AnswerMatcherTest.kt @@ -0,0 +1,37 @@ +package nart.simpleanki.core.domain.typing + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AnswerMatcherTest { + @Test fun exactMatch() = assertTrue(AnswerMatcher.matches("hello", "hello")) + + @Test fun caseInsensitive() = assertTrue(AnswerMatcher.matches("Hello", "hELLO")) + + @Test fun trimsAndCollapsesWhitespace() = + assertTrue(AnswerMatcher.matches(" how are you ", "how are you")) + + @Test fun ignoresSurroundingPunctuation() { + assertTrue(AnswerMatcher.matches("hello!", "hello")) + assertTrue(AnswerMatcher.matches("¿cómo estás?", "cómo estás")) + assertTrue(AnswerMatcher.matches("well-known", "well-known")) + } + + @Test fun accentsAreEnforced() { + assertFalse(AnswerMatcher.matches("cafe", "café")) + assertTrue(AnswerMatcher.matches("café", "café")) + } + + @Test fun blankNeverMatchesNonBlank() { + assertFalse(AnswerMatcher.matches("", "hello")) + assertFalse(AnswerMatcher.matches(" ", "hello")) + } + + @Test fun blankExpectedAfterNormalizationNeverMatches() { + assertFalse(AnswerMatcher.matches("hello", "?!.")) + assertFalse(AnswerMatcher.matches("", "")) + } + + @Test fun wrongIsWrong() = assertFalse(AnswerMatcher.matches("hola", "hello")) +} diff --git a/app/src/test/java/nart/simpleanki/core/domain/typing/TypePracticeSessionTest.kt b/app/src/test/java/nart/simpleanki/core/domain/typing/TypePracticeSessionTest.kt new file mode 100644 index 0000000..4bbad32 --- /dev/null +++ b/app/src/test/java/nart/simpleanki/core/domain/typing/TypePracticeSessionTest.kt @@ -0,0 +1,127 @@ +package nart.simpleanki.core.domain.typing + +import nart.simpleanki.core.domain.model.Card +import nart.simpleanki.core.domain.model.CardState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TypePracticeSessionTest { + private fun card(id: String, back: String) = Card( + id = id, front = "f-$id", back = back, deckId = "d", + dateCreated = 0, lastModified = 0, fsrsDue = 0, fsrsState = CardState.New.value, + ) + + private class Recorder { + data class Entry(val cardId: String, val correct: Boolean, val typed: String) + val entries = mutableListOf() + val sink: (Card, Boolean, String) -> Unit = { c, ok, t -> entries += Entry(c.id, ok, t) } + } + + @Test fun correctFirstTry_finalizesCorrect_advances_andCombos() { + val rec = Recorder() + val s = TypePracticeSession(listOf(card("c1", "a"), card("c2", "b")), emptySet(), rec.sink) + assertEquals("c1", s.current!!.id) + assertEquals(SubmitResult.Correct, s.submit("a")) + assertEquals("c2", s.current!!.id) + assertEquals(SubmitResult.Correct, s.submit("b")) + assertTrue(s.isFinished) + assertEquals(listOf(Recorder.Entry("c1", true, "a"), Recorder.Entry("c2", true, "b")), rec.entries) + assertEquals(2, s.report().bestCombo) + assertEquals(100, s.report().firstTryAccuracy) + assertEquals(2, s.report().newlyMastered) + } + + @Test fun wrongFirstTry_thenContinue_finalizesWrong_andRequeues() { + val rec = Recorder() + val s = TypePracticeSession(listOf(card("c1", "a"), card("c2", "b")), emptySet(), rec.sink) + assertEquals(SubmitResult.Wrong("a"), s.submit("zzz")) + assertTrue(s.isRevealing) + assertTrue(s.canOverride) + s.continueAfterWrong() + // c1 finalized wrong (one log) and requeued behind c2. + assertEquals(Recorder.Entry("c1", false, "zzz"), rec.entries.single()) + assertEquals("c2", s.current!!.id) + assertFalse(s.isRevealing) + // Clear c2, then c1 comes back; typing it right now clears it WITHOUT a new log. + assertEquals(SubmitResult.Correct, s.submit("b")) + assertEquals("c1", s.current!!.id) + assertEquals(SubmitResult.Correct, s.submit("a")) + assertTrue(s.isFinished) + assertEquals(2, rec.entries.size) // exactly one log per card + assertEquals(50, s.report().firstTryAccuracy) // c1 wrong, c2 right + assertEquals(1, s.report().bestCombo) // c2 was a clean first-try correct -> longest run is 1 + } + + @Test fun override_marksCorrect_clears_andCountsNewlyMastered() { + val rec = Recorder() + val s = TypePracticeSession(listOf(card("c1", "a")), emptySet(), rec.sink) + s.submit("close-but-wrong") + assertTrue(s.canOverride) + s.override() + assertTrue(s.isFinished) + assertEquals(Recorder.Entry("c1", true, "close-but-wrong"), rec.entries.single()) + assertEquals(1, s.report().newlyMastered) + } + + @Test fun previouslyMastered_notRecountedAsNewly() { + val rec = Recorder() + val s = TypePracticeSession(listOf(card("c1", "a")), previouslyMastered = setOf("c1"), onFinalize = rec.sink) + s.submit("a") + assertEquals(0, s.report().newlyMastered) + } + + @Test fun emptyPool_isFinished_zeroReport() { + val s = TypePracticeSession(emptyList(), emptySet()) + assertTrue(s.isFinished) + assertNull(s.current) + assertEquals(SessionReport(0, 0, 0, 0, 0), s.report()) + } + + @Test fun blankSubmit_onFirstAttempt_isWrong_andLogsOnce() { + val rec = Recorder() + val s = TypePracticeSession(listOf(card("c1", "answer")), emptySet(), rec.sink) + assertEquals(SubmitResult.Wrong("answer"), s.submit("")) + assertTrue(s.canOverride) + s.continueAfterWrong() + assertEquals(Recorder.Entry("c1", false, ""), rec.entries.single()) + } + + @Test fun retryAnsweredWrongAgain_doesNotDoubleLog() { + val rec = Recorder() + val s = TypePracticeSession(listOf(card("c1", "a"), card("c2", "b")), emptySet(), rec.sink) + s.submit("wrong"); s.continueAfterWrong() // c1 first-try wrong -> 1 log, requeued behind c2 + assertEquals(SubmitResult.Correct, s.submit("b")) // clear c2 + assertEquals("c1", s.current!!.id) + s.submit("wrong-again"); s.continueAfterWrong() // retry wrong -> no new log, requeued + assertEquals("c1", s.current!!.id) + assertEquals(SubmitResult.Correct, s.submit("a")) // finally clear c1 + assertTrue(s.isFinished) + assertEquals(1, rec.entries.count { it.cardId == "c1" }) // exactly one log for c1 + assertFalse(rec.entries.single { it.cardId == "c1" }.correct) + } + + @Test fun typeFrontDirection_comparesAgainstTheFront() { + val rec = Recorder() + val s = TypePracticeSession( + listOf(card("c1", back = "back-ignored")), + onFinalize = rec.sink, + direction = TypeDirection.TypeFront, + ) + // In TypeFront, the answer is the FRONT ("f-c1"), not the back. + assertEquals(SubmitResult.Wrong("f-c1"), s.submit("nope")) + s.override() + assertEquals(Recorder.Entry("c1", true, "nope"), rec.entries.single()) + assertTrue(s.isFinished) + } + + @Test fun currentCombo_incrementsOnCorrect_resetsOnWrong() { + val s = TypePracticeSession(listOf(card("c1", "a"), card("c2", "b"), card("c3", "c"))) + assertEquals(0, s.currentCombo) + s.submit("a"); assertEquals(1, s.currentCombo) + s.submit("b"); assertEquals(2, s.currentCombo) + s.submit("nope"); assertEquals(0, s.currentCombo) // wrong first-try resets the combo + } +} diff --git a/app/src/test/java/nart/simpleanki/core/domain/typing/TypingMasteryTest.kt b/app/src/test/java/nart/simpleanki/core/domain/typing/TypingMasteryTest.kt new file mode 100644 index 0000000..77e6172 --- /dev/null +++ b/app/src/test/java/nart/simpleanki/core/domain/typing/TypingMasteryTest.kt @@ -0,0 +1,32 @@ +package nart.simpleanki.core.domain.typing + +import nart.simpleanki.core.domain.model.TypingLog +import org.junit.Assert.assertEquals +import org.junit.Test + +class TypingMasteryTest { + private fun log(card: String, correct: Boolean, ts: Long, deck: String = "d") = + TypingLog(id = "$card-$ts", cardId = card, deckId = deck, correct = correct, typedText = "", timestamp = ts) + + @Test fun latestFirstTryWins_masteryRegresses() { + // c1: correct then later wrong -> NOT mastered. c2: wrong then later correct -> mastered. + val logs = listOf( + log("c1", correct = true, ts = 1), + log("c1", correct = false, ts = 2), + log("c2", correct = false, ts = 1), + log("c2", correct = true, ts = 2), + ) + assertEquals(setOf("c2"), TypingMastery.masteredCardIds(logs)) + } + + @Test fun deckMastery_countsAgainstCurrentDeckCards() { + val logs = listOf(log("c1", true, 1), log("c2", true, 1), log("gone", true, 1)) + // "gone" has a log but is no longer in the deck -> excluded; c3 is in the deck but never typed. + val m = TypingMastery.deckMastery(logs, deckCardIds = setOf("c1", "c2", "c3")) + assertEquals(DeckMastery(mastered = 2, total = 3), m) + } + + @Test fun emptyLogs_zeroMastered() { + assertEquals(DeckMastery(0, 2), TypingMastery.deckMastery(emptyList(), setOf("c1", "c2"))) + } +} diff --git a/app/src/test/java/nart/simpleanki/feature/typepractice/TypePracticeViewModelTest.kt b/app/src/test/java/nart/simpleanki/feature/typepractice/TypePracticeViewModelTest.kt new file mode 100644 index 0000000..ce7ec05 --- /dev/null +++ b/app/src/test/java/nart/simpleanki/feature/typepractice/TypePracticeViewModelTest.kt @@ -0,0 +1,153 @@ +package nart.simpleanki.feature.typepractice + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +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.data.repository.FakeTypingLogDao +import nart.simpleanki.core.data.repository.TypingLogRepository +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.typing.TypeDirection +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class TypePracticeViewModelTest { + private val now = 1_700_000_000_000L + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + private fun card(id: String, back: String, front: String = "f-$id") = Card( + id = id, front = front, back = back, deckId = "A", + dateCreated = now, lastModified = now, fsrsDue = now, fsrsState = CardState.New.value, + ) + + private fun model(vararg cards: Card): Pair { + val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) + val cardRepo = CardRepository(FakeCardDao(), now = { now }) + val logRepo = TypingLogRepository(FakeTypingLogDao(), newId = { java.util.UUID.randomUUID().toString() }) + kotlinx.coroutines.runBlocking { + deckRepo.upsert(Deck(id = "A", name = "A", dateCreated = now, lastModified = now)) + cards.forEach { cardRepo.upsert(it) } + } + // shuffleSeed = { null } disables shuffling so cards stay in insertion order (c1, c2, ...) + return TypePracticeViewModel("A", cardRepo, deckRepo, logRepo, now = { now }, shuffleSeed = { null }) to logRepo + } + + @Test + fun correctAnswer_celebrates_thenAdvances_andAppendsOneLog() = runTest(dispatcher.scheduler) { + val (vm, logRepo) = model(card("c1", "answer"), card("c2", "two")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + assertTrue(vm.uiState.value.awaitingDirection) + + vm.chooseDirection(TypeDirection.TypeBack) + advanceUntilIdle() + assertEquals("c1", vm.uiState.value.current!!.id) + assertEquals(2, vm.uiState.value.total) + + vm.onInput("answer") + vm.onSubmit() + assertTrue(vm.uiState.value.celebrating) + assertEquals("c1", vm.uiState.value.current!!.id) + assertEquals(1, vm.uiState.value.combo) + + advanceUntilIdle() + assertFalse(vm.uiState.value.celebrating) + assertEquals("c2", vm.uiState.value.current!!.id) + + val logs = logRepo.observeLogs().first() + assertEquals(1, logs.size) + assertTrue(logs.single().correct) + } + + @Test + fun progress_tracksClearedOverTotal() = runTest(dispatcher.scheduler) { + val (vm, _) = model(card("c1", "a"), card("c2", "b")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + vm.chooseDirection(TypeDirection.TypeBack) + advanceUntilIdle() + assertEquals(2, vm.uiState.value.total) + assertEquals(2, vm.uiState.value.remaining) + + vm.onInput("a"); vm.onSubmit(); advanceUntilIdle() + assertEquals(2, vm.uiState.value.total) + assertEquals(1, vm.uiState.value.remaining) + } + + @Test + fun wrongAnswer_resetsComboChip_andReveals() = runTest(dispatcher.scheduler) { + val (vm, _) = model(card("c1", "answer")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + vm.chooseDirection(TypeDirection.TypeBack) + advanceUntilIdle() + + vm.onInput("nope"); vm.onSubmit() + assertTrue(vm.uiState.value.revealing) + assertEquals(0, vm.uiState.value.combo) + assertEquals("answer", vm.uiState.value.revealedAnswer) + assertFalse(vm.uiState.value.celebrating) + } + + @Test + fun inputIgnoredWhileCelebrating() = runTest(dispatcher.scheduler) { + val (vm, _) = model(card("c1", "a"), card("c2", "b")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + vm.chooseDirection(TypeDirection.TypeBack) + advanceUntilIdle() + + vm.onInput("a"); vm.onSubmit() + assertTrue(vm.uiState.value.celebrating) + vm.onInput("ignored") + assertEquals("a", vm.uiState.value.input) + vm.onSubmit() + assertEquals("c1", vm.uiState.value.current!!.id) + } + + @Test + fun lastCardCorrect_finishesAfterCelebrate() = runTest(dispatcher.scheduler) { + val (vm, _) = model(card("c1", "a")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + vm.chooseDirection(TypeDirection.TypeBack) + advanceUntilIdle() + + vm.onInput("a"); vm.onSubmit() + assertTrue(vm.uiState.value.celebrating) + advanceUntilIdle() + assertTrue(vm.uiState.value.finished) + assertEquals(1, vm.uiState.value.report!!.completed) + } + + @Test + fun typeFront_typesTheFront() = runTest(dispatcher.scheduler) { + val (vm, logRepo) = model(card("c1", back = "definition")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + vm.chooseDirection(TypeDirection.TypeFront) + advanceUntilIdle() + vm.onInput("f-c1"); vm.onSubmit(); advanceUntilIdle() + assertTrue(vm.uiState.value.finished) + assertTrue(logRepo.observeLogs().first().single().correct) + } +} diff --git a/docs/superpowers/plans/2026-06-05-type-practice-mode.md b/docs/superpowers/plans/2026-06-05-type-practice-mode.md new file mode 100644 index 0000000..c2ab80a --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-type-practice-mode.md @@ -0,0 +1,1938 @@ +# Type Practice Mode (Phase 1) 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 standalone, FSRS-decoupled "Type Practice" study mode (whole deck, retry-until-correct, typed answers auto-checked) with its own append-only typing-log store, a per-deck mastery ring, and an end-of-session report. + +**Architecture:** Pure domain units (`AnswerMatcher`, `TypePracticeSession`, `TypingMastery`) hold all logic and are unit-tested without Android. A new append-only `TypingLog` store (Room `typing_logs` table + Firestore sync) mirrors the existing review-log path exactly. `TypePracticeViewModel` drives the pure session, appends exactly one log per card at first-attempt finalization, and never touches FSRS. The deck-detail screen gains a Type Practice entry + a mastery ring derived live from the logs (same pure-derivation style as `StreakProvider`). + +**Tech Stack:** Kotlin, Jetpack Compose (Material3), Koin, Room, Firestore, kotlinx-coroutines, JUnit4 + coroutines-test. + +**Branch:** `feature/type-practice-mode` (off `main`). + +**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`. + +**Commit rules:** No "claude" mention in commit messages; no Co-Authored-By / attribution trailer. NEVER `git add -A` or `git add .` — always add explicit paths, so the untracked `docs/superpowers/plans/2026-06-04-realtime-study-queue.md` is never staged. + +--- + +## File Structure + +**New files:** +- `app/src/main/java/nart/simpleanki/core/domain/typing/AnswerMatcher.kt` — normalize + compare typed answers (pure). +- `app/src/main/java/nart/simpleanki/core/domain/typing/TypePracticeSession.kt` — in-memory retry-until-correct session state machine (pure). +- `app/src/main/java/nart/simpleanki/core/domain/typing/TypingMastery.kt` — mastery derivation from logs (pure) + `DeckMastery`. +- `app/src/main/java/nart/simpleanki/core/data/repository/TypingMasteryProvider.kt` — `Flow` for a deck. +- `app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeViewModel.kt` — session driver + Ui state. +- `app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt` — Compose UI + session report + previews. +- Tests: `AnswerMatcherTest.kt`, `TypePracticeSessionTest.kt`, `TypingMasteryTest.kt`, `TypingLogMapperTest.kt`, `TypingLogDtoTest.kt`, `TypingLogRepositoryTest.kt`, `TypingMasteryProviderTest.kt`, `TypePracticeViewModelTest.kt`. + +**Modified files:** +- `core/domain/model/DomainModels.kt` — add `TypingLog`. +- `core/data/local/RoomEntities.kt` — add `TypingLogEntity`. +- `core/data/local/dao/Daos.kt` — add `TypingLogDao`. +- `core/data/local/RoomMappers.kt` — add `TypingLog` mappers. +- `core/data/local/AzriDatabase.kt` — version 4 + `typingLogDao()` + `MIGRATION_3_4`. +- `core/data/repository/Repositories.kt` — add `TypingLogRepository`. +- `core/data/firestore/FirestoreDtos.kt` — add `TypingLogDto`. +- `core/data/sync/RemoteSyncSource.kt` + `FirestoreSyncService.kt` + `SyncManager.kt` — typing-log fetch/push + union-pull. +- `di/AppModule.kt` — dao/repo/provider/VM registrations, `SyncManager` arg, `MIGRATION_3_4`. +- `feature/deckdetail/DeckDetailViewModel.kt` + `DeckDetailScreen.kt` — mastery flow + Type Practice button/ring. +- `ui/navigation/AzriNavHost.kt` — `typePractice/{deckId}` route + deck-detail wiring. +- `app/src/test/java/nart/simpleanki/core/data/repository/FakeDaos.kt` — `FakeTypingLogDao`. +- `app/src/test/java/nart/simpleanki/core/data/sync/SyncManagerTest.kt` — thread the new DAO + 2 typing-log tests. + +--- + +## Task 1: `AnswerMatcher` (pure answer comparison) + +**Files:** +- Create: `app/src/main/java/nart/simpleanki/core/domain/typing/AnswerMatcher.kt` +- Test: `app/src/test/java/nart/simpleanki/core/domain/typing/AnswerMatcherTest.kt` + +- [ ] **Step 1: Write the failing test** + +Create `app/src/test/java/nart/simpleanki/core/domain/typing/AnswerMatcherTest.kt`: +```kotlin +package nart.simpleanki.core.domain.typing + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AnswerMatcherTest { + @Test fun exactMatch() = assertTrue(AnswerMatcher.matches("hello", "hello")) + + @Test fun caseInsensitive() = assertTrue(AnswerMatcher.matches("Hello", "hELLO")) + + @Test fun trimsAndCollapsesWhitespace() = + assertTrue(AnswerMatcher.matches(" how are you ", "how are you")) + + @Test fun ignoresSurroundingPunctuation() { + assertTrue(AnswerMatcher.matches("hello!", "hello")) + assertTrue(AnswerMatcher.matches("¿cómo estás?", "cómo estás")) + } + + @Test fun accentsAreEnforced() { + assertFalse(AnswerMatcher.matches("cafe", "café")) + assertTrue(AnswerMatcher.matches("café", "café")) + } + + @Test fun blankNeverMatchesNonBlank() { + assertFalse(AnswerMatcher.matches("", "hello")) + assertFalse(AnswerMatcher.matches(" ", "hello")) + } + + @Test fun wrongIsWrong() = assertFalse(AnswerMatcher.matches("hola", "hello")) +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.domain.typing.AnswerMatcherTest"` +Expected: COMPILE FAILURE (`AnswerMatcher` does not exist). + +- [ ] **Step 3: Implement `AnswerMatcher.kt`** + +Create `app/src/main/java/nart/simpleanki/core/domain/typing/AnswerMatcher.kt`: +```kotlin +package nart.simpleanki.core.domain.typing + +/** + * Normalizes and compares typed answers for Type Practice. Case-insensitive, whitespace-insensitive, + * and surrounding-punctuation-insensitive (leading/trailing Unicode punctuation is stripped — e.g. + * "¿cómo estás?" -> "cómo estás"), but accent/diacritic-SENSITIVE ("café" != "cafe"). The objective + * signal it produces is the basis for mastery + (Phase 2) diagnostics, so it stays strict on accents. + */ +object AnswerMatcher { + // Leading/trailing Unicode punctuation (\p{P}) and whitespace. + private val edges = Regex("^[\\p{P}\\s]+|[\\p{P}\\s]+$") + private val innerWhitespace = Regex("\\s+") + + fun normalize(input: String): String = + input.replace(edges, "").replace(innerWhitespace, " ").trim().lowercase() + + fun matches(typed: String, expected: String): Boolean { + val n = normalize(expected) + return n.isNotEmpty() && normalize(typed) == n + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.domain.typing.AnswerMatcherTest"` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** +```bash +git add app/src/main/java/nart/simpleanki/core/domain/typing/AnswerMatcher.kt \ + app/src/test/java/nart/simpleanki/core/domain/typing/AnswerMatcherTest.kt +git commit -m "Add AnswerMatcher for typed-answer comparison" +``` + +--- + +## Task 2: `TypingLog` storage (domain, entity, DAO, mapper, DB v4) + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/core/domain/model/DomainModels.kt` +- Modify: `app/src/main/java/nart/simpleanki/core/data/local/RoomEntities.kt` +- Modify: `app/src/main/java/nart/simpleanki/core/data/local/dao/Daos.kt` +- Modify: `app/src/main/java/nart/simpleanki/core/data/local/RoomMappers.kt` +- Modify: `app/src/main/java/nart/simpleanki/core/data/local/AzriDatabase.kt` +- Modify: `app/src/main/java/nart/simpleanki/di/AppModule.kt` +- Test: `app/src/test/java/nart/simpleanki/core/data/local/TypingLogMapperTest.kt` + +- [ ] **Step 1: Add the `TypingLog` domain model** + +In `DomainModels.kt`, immediately after the `ReviewLog` data class (ends at the line with `val cardId: String = "",` then `)`), add: +```kotlin + +/** One Type-Practice first-attempt outcome for a card (append-only; decoupled from FSRS). */ +data class TypingLog( + val id: String = "", + val cardId: String = "", + val deckId: String = "", + val correct: Boolean, + val typedText: String, + val timestamp: Long, +) +``` + +- [ ] **Step 2: Add the Room entity** + +In `RoomEntities.kt`, after the `ReviewLogEntity` data class, add: +```kotlin + +@Entity( + tableName = "typing_logs", + indices = [Index("cardId"), Index("deckId")], +) +data class TypingLogEntity( + @PrimaryKey val id: String, + val cardId: String, + val deckId: String, + val correct: Boolean, + val typedText: String, + val timestamp: Long, + val dirty: Boolean = true, +) +``` + +- [ ] **Step 3: Add the DAO** + +In `dao/Daos.kt`, add the import alongside the other entity imports at the top: +```kotlin +import nart.simpleanki.core.data.local.TypingLogEntity +``` +Then add this DAO after `ReviewLogDao` (mirrors it; append-only, IGNORE for idempotent append + pull-union): +```kotlin + +@Dao +interface TypingLogDao { + // IGNORE makes both append (fresh UUID) and pull (union) idempotent: an existing id is a no-op. + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertAll(logs: List) + + @Query("SELECT * FROM typing_logs WHERE dirty = 1") + suspend fun getDirty(): List + + @Query("UPDATE typing_logs SET dirty = 0 WHERE id = :id") + suspend fun clearDirty(id: String) + + @Query("SELECT id FROM typing_logs") + suspend fun getAllIds(): List + + @Query("SELECT * FROM typing_logs ORDER BY timestamp") + fun observeAll(): Flow> + + @Query("SELECT * FROM typing_logs WHERE deckId = :deckId ORDER BY timestamp") + fun observeForDeck(deckId: String): Flow> +} +``` + +- [ ] **Step 4: Add the mappers** + +In `RoomMappers.kt`, add the import next to the other domain imports: +```kotlin +import nart.simpleanki.core.domain.model.TypingLog +``` +Then append after the `ReviewLog.toEntity` mapper: +```kotlin + +fun TypingLogEntity.toDomain(): TypingLog = TypingLog( + id = id, cardId = cardId, deckId = deckId, correct = correct, typedText = typedText, timestamp = timestamp, +) + +fun TypingLog.toEntity(dirty: Boolean = true): TypingLogEntity = TypingLogEntity( + id = id, cardId = cardId, deckId = deckId, correct = correct, typedText = typedText, timestamp = timestamp, dirty = dirty, +) +``` + +- [ ] **Step 5: Bump the database version + add the migration** + +In `AzriDatabase.kt`: +1. Add the import after the `StreakStateDao` import: +```kotlin +import nart.simpleanki.core.data.local.dao.TypingLogDao +``` +2. Change the `@Database(...)` annotation's `entities` and `version`: +```kotlin +@Database( + entities = [CardEntity::class, DeckEntity::class, FolderEntity::class, ReviewLogEntity::class, StreakStateEntity::class, TypingLogEntity::class], + version = 4, + exportSchema = false, +) +``` +3. Add the abstract DAO accessor after `streakStateDao()`: +```kotlin + abstract fun typingLogDao(): TypingLogDao +``` +4. Add the migration after `MIGRATION_2_3` (CREATE TABLE + 2 indices; **no SQL `DEFAULT`** — the entity has no `@ColumnInfo` default, so a SQL default would be a schema mismatch): +```kotlin + +val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `typing_logs` (" + + "`id` TEXT NOT NULL, `cardId` TEXT NOT NULL, `deckId` TEXT NOT NULL, " + + "`correct` INTEGER NOT NULL, `typedText` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, " + + "`dirty` INTEGER NOT NULL, PRIMARY KEY(`id`))", + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_typing_logs_cardId` ON `typing_logs` (`cardId`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_typing_logs_deckId` ON `typing_logs` (`deckId`)") + } +} +``` + +- [ ] **Step 6: Wire the DAO + migration into Koin** + +In `di/AppModule.kt`: +1. Add the migration import next to `MIGRATION_2_3`: +```kotlin +import nart.simpleanki.core.data.local.MIGRATION_3_4 +``` +2. In the `Room.databaseBuilder(...)` block, extend the migrations call: +```kotlin + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) +``` +3. After `single { get().streakStateDao() }`, add: +```kotlin + single { get().typingLogDao() } +``` + +- [ ] **Step 7: Write the mapper test** + +Create `app/src/test/java/nart/simpleanki/core/data/local/TypingLogMapperTest.kt`: +```kotlin +package nart.simpleanki.core.data.local + +import nart.simpleanki.core.domain.model.TypingLog +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TypingLogMapperTest { + @Test fun roundTrip_preservesFields() { + val domain = TypingLog( + id = "t1", cardId = "c1", deckId = "d1", correct = true, typedText = "café", timestamp = 1_700L, + ) + val back = domain.toEntity(dirty = false).toDomain() + assertEquals(domain, back) + } + + @Test fun toEntity_defaultsDirtyTrue() { + val e = TypingLog(id = "t1", cardId = "c1", deckId = "d1", correct = false, typedText = "x", timestamp = 1L).toEntity() + assertTrue(e.dirty) + } +} +``` + +- [ ] **Step 8: Build + run the mapper test** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin :app:testDebugUnitTest --tests "nart.simpleanki.core.data.local.TypingLogMapperTest"` +Expected: BUILD SUCCESSFUL; 2 tests pass. + +- [ ] **Step 9: Verify the migration SQL matches Room's generated schema (the footgun)** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:assembleDebug` +Expected: BUILD SUCCESSFUL. Then confirm the generated `typing_logs` CREATE matches the migration char-for-char: +```bash +grep -rn "CREATE TABLE IF NOT EXISTS \`typing_logs\`" app/build/generated/ksp/ 2>/dev/null || \ +grep -rn "typing_logs" app/build/generated/ 2>/dev/null | grep "CREATE TABLE" +``` +Expected: the generated `createAllTables` string for `typing_logs` is byte-identical to the `MIGRATION_3_4` string (same column order, `INTEGER`/`TEXT` types, `NOT NULL`, `PRIMARY KEY(\`id\`)`, no DEFAULT). If they differ, edit `MIGRATION_3_4` to match the generated one exactly. + +- [ ] **Step 10: Commit** +```bash +git add app/src/main/java/nart/simpleanki/core/domain/model/DomainModels.kt \ + app/src/main/java/nart/simpleanki/core/data/local/RoomEntities.kt \ + app/src/main/java/nart/simpleanki/core/data/local/dao/Daos.kt \ + app/src/main/java/nart/simpleanki/core/data/local/RoomMappers.kt \ + app/src/main/java/nart/simpleanki/core/data/local/AzriDatabase.kt \ + app/src/main/java/nart/simpleanki/di/AppModule.kt \ + app/src/test/java/nart/simpleanki/core/data/local/TypingLogMapperTest.kt +git commit -m "Add typing_logs table, entity, DAO, mappers, and v4 migration" +``` + +--- + +## Task 3: `TypingMastery` (pure mastery derivation) + +**Files:** +- Create: `app/src/main/java/nart/simpleanki/core/domain/typing/TypingMastery.kt` +- Test: `app/src/test/java/nart/simpleanki/core/domain/typing/TypingMasteryTest.kt` + +- [ ] **Step 1: Write the failing test** + +Create `app/src/test/java/nart/simpleanki/core/domain/typing/TypingMasteryTest.kt`: +```kotlin +package nart.simpleanki.core.domain.typing + +import nart.simpleanki.core.domain.model.TypingLog +import org.junit.Assert.assertEquals +import org.junit.Test + +class TypingMasteryTest { + private fun log(card: String, correct: Boolean, ts: Long, deck: String = "d") = + TypingLog(id = "$card-$ts", cardId = card, deckId = deck, correct = correct, typedText = "", timestamp = ts) + + @Test fun latestFirstTryWins_masteryRegresses() { + // c1: correct then later wrong -> NOT mastered. c2: wrong then later correct -> mastered. + val logs = listOf( + log("c1", correct = true, ts = 1), + log("c1", correct = false, ts = 2), + log("c2", correct = false, ts = 1), + log("c2", correct = true, ts = 2), + ) + assertEquals(setOf("c2"), TypingMastery.masteredCardIds(logs)) + } + + @Test fun deckMastery_countsAgainstCurrentDeckCards() { + val logs = listOf(log("c1", true, 1), log("c2", true, 1), log("gone", true, 1)) + // "gone" has a log but is no longer in the deck -> excluded; c3 is in the deck but never typed. + val m = TypingMastery.deckMastery(logs, deckCardIds = setOf("c1", "c2", "c3")) + assertEquals(DeckMastery(mastered = 2, total = 3), m) + } + + @Test fun emptyLogs_zeroMastered() { + assertEquals(DeckMastery(0, 2), TypingMastery.deckMastery(emptyList(), setOf("c1", "c2"))) + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.domain.typing.TypingMasteryTest"` +Expected: COMPILE FAILURE (`TypingMastery` / `DeckMastery` do not exist). + +- [ ] **Step 3: Implement `TypingMastery.kt`** + +Create `app/src/main/java/nart/simpleanki/core/domain/typing/TypingMastery.kt`: +```kotlin +package nart.simpleanki.core.domain.typing + +import nart.simpleanki.core.domain.model.TypingLog + +/** Mastered/total typed cards for one deck. */ +data class DeckMastery(val mastered: Int, val total: Int) + +/** + * Derives typing mastery purely from logs (single source of truth, like StreakProvider over review + * logs). A card is "mastered" iff its LATEST first-attempt log is correct, so mastery regresses + * honestly when a later session misses it. + */ +object TypingMastery { + fun latestPerCard(logs: List): Map = + logs.groupBy { it.cardId }.mapValues { (_, group) -> group.maxBy { it.timestamp } } + + fun masteredCardIds(logs: List): Set = + latestPerCard(logs).filterValues { it.correct }.keys + + fun deckMastery(logs: List, deckCardIds: Set): DeckMastery = + DeckMastery( + mastered = masteredCardIds(logs).count { it in deckCardIds }, + total = deckCardIds.size, + ) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.domain.typing.TypingMasteryTest"` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** +```bash +git add app/src/main/java/nart/simpleanki/core/domain/typing/TypingMastery.kt \ + app/src/test/java/nart/simpleanki/core/domain/typing/TypingMasteryTest.kt +git commit -m "Add TypingMastery derivation from typing logs" +``` + +--- + +## Task 4: `TypingLogRepository` + `TypingMasteryProvider` + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/core/data/repository/Repositories.kt` +- Create: `app/src/main/java/nart/simpleanki/core/data/repository/TypingMasteryProvider.kt` +- Modify: `app/src/main/java/nart/simpleanki/di/AppModule.kt` +- Modify: `app/src/test/java/nart/simpleanki/core/data/repository/FakeDaos.kt` +- Test: `app/src/test/java/nart/simpleanki/core/data/repository/TypingLogRepositoryTest.kt` +- Test: `app/src/test/java/nart/simpleanki/core/data/repository/TypingMasteryProviderTest.kt` + +- [ ] **Step 1: Add `FakeTypingLogDao` to `FakeDaos.kt`** + +In `FakeDaos.kt`, add the import next to the other entity imports: +```kotlin +import nart.simpleanki.core.data.local.TypingLogEntity +import nart.simpleanki.core.data.local.dao.TypingLogDao +``` +Then append this fake after `FakeReviewLogDao` (mirrors it; adds `observeForDeck` + an `inserted` capture list): +```kotlin + +class FakeTypingLogDao : TypingLogDao { + private val store = MutableStateFlow>(emptyMap()) + val inserted = mutableListOf() + override suspend fun insertAll(logs: List) { + inserted += logs + store.value = store.value.toMutableMap().apply { logs.forEach { putIfAbsent(it.id, it) } } + } + override suspend fun getDirty(): List = store.value.values.filter { it.dirty } + override suspend fun clearDirty(id: String) { + store.value[id]?.let { store.value = store.value.toMutableMap().apply { put(id, it.copy(dirty = false)) } } + } + override suspend fun getAllIds(): List = store.value.keys.toList() + override fun observeAll(): Flow> = + store.map { m -> m.values.sortedBy { it.timestamp } } + override fun observeForDeck(deckId: String): Flow> = + store.map { m -> m.values.filter { it.deckId == deckId }.sortedBy { it.timestamp } } +} +``` + +- [ ] **Step 2: Write the repository + provider tests** + +Create `app/src/test/java/nart/simpleanki/core/data/repository/TypingLogRepositoryTest.kt`: +```kotlin +package nart.simpleanki.core.data.repository + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import nart.simpleanki.core.domain.model.TypingLog +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TypingLogRepositoryTest { + private fun log(card: String, deck: String, correct: Boolean) = + TypingLog(cardId = card, deckId = deck, correct = correct, typedText = "x", timestamp = 1L) + + @Test fun append_assignsId_marksDirty_andObservable() = runTest { + val dao = FakeTypingLogDao() + val repo = TypingLogRepository(dao, newId = { "fixed-id" }) + repo.append(log("c1", "d1", correct = true)) + + val all = repo.observeLogs().first() + assertEquals(1, all.size) + assertEquals("fixed-id", all.first().id) + assertTrue(dao.getDirty().isNotEmpty()) + } + + @Test fun observeLogsForDeck_filtersByDeck() = runTest { + val repo = TypingLogRepository(FakeTypingLogDao(), newId = { java.util.UUID.randomUUID().toString() }) + repo.append(log("c1", "d1", true)) + repo.append(log("c2", "d2", true)) + assertEquals(listOf("c1"), repo.observeLogsForDeck("d1").first().map { it.cardId }) + } +} +``` + +Create `app/src/test/java/nart/simpleanki/core/data/repository/TypingMasteryProviderTest.kt`: +```kotlin +package nart.simpleanki.core.data.repository + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import nart.simpleanki.core.domain.model.Card +import nart.simpleanki.core.domain.model.CardState +import nart.simpleanki.core.domain.model.TypingLog +import nart.simpleanki.core.domain.typing.DeckMastery +import org.junit.Assert.assertEquals +import org.junit.Test + +class TypingMasteryProviderTest { + private val now = 1_700_000_000_000L + private fun card(id: String, deck: String, back: String = "b") = Card( + id = id, front = "f", back = back, deckId = deck, + dateCreated = now, lastModified = now, fsrsDue = now, fsrsState = CardState.New.value, + ) + + @Test fun deckMastery_excludesBlankBackCards_andCountsLatestCorrect() = runTest { + val cardDao = FakeCardDao() + val cardRepo = CardRepository(cardDao, now = { now }) + cardRepo.upsert(card("c1", "d1")) + cardRepo.upsert(card("c2", "d1")) + cardRepo.upsert(card("c3", "d1", back = " ")) // blank back -> not typeable, excluded from total + val logDao = FakeTypingLogDao() + val logRepo = TypingLogRepository(logDao, newId = { java.util.UUID.randomUUID().toString() }) + logRepo.append(TypingLog(cardId = "c1", deckId = "d1", correct = true, typedText = "x", timestamp = 1)) + + val provider = TypingMasteryProvider(logRepo, cardRepo) + assertEquals(DeckMastery(mastered = 1, total = 2), provider.observeDeckMastery("d1").first()) + } +} +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.data.repository.TypingLogRepositoryTest" --tests "nart.simpleanki.core.data.repository.TypingMasteryProviderTest"` +Expected: COMPILE FAILURE (`TypingLogRepository` / `TypingMasteryProvider` do not exist). + +- [ ] **Step 4: Implement `TypingLogRepository`** + +In `Repositories.kt`, add the import: +```kotlin +import nart.simpleanki.core.domain.model.TypingLog +``` +and the DAO import next to `ReviewLogDao`: +```kotlin +import nart.simpleanki.core.data.local.dao.TypingLogDao +``` +Then add this class after `ReviewLogRepository`: +```kotlin + +/** Immutable, append-only store of Type-Practice first-attempt outcomes (decoupled from FSRS). */ +class TypingLogRepository( + private val dao: TypingLogDao, + private val newId: () -> String = { UUID.randomUUID().toString() }, +) { + /** Appends one typing outcome, assigning a fresh id when none is set; marked dirty for sync. */ + suspend fun append(log: TypingLog) { + dao.insertAll(listOf(log.copy(id = log.id.ifEmpty { newId() }).toEntity(dirty = true))) + } + + fun observeLogs(): Flow> = dao.observeAll().map { rows -> rows.map { it.toDomain() } } + + fun observeLogsForDeck(deckId: String): Flow> = + dao.observeForDeck(deckId).map { rows -> rows.map { it.toDomain() } } +} +``` + +- [ ] **Step 5: Implement `TypingMasteryProvider`** + +Create `app/src/main/java/nart/simpleanki/core/data/repository/TypingMasteryProvider.kt`: +```kotlin +package nart.simpleanki.core.data.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import nart.simpleanki.core.domain.typing.DeckMastery +import nart.simpleanki.core.domain.typing.TypingMastery + +/** + * Live per-deck typing mastery for the deck-detail ring. Derives from logs + the deck's current + * (non-deleted, typeable) cards — blank-back cards are excluded, matching what Type Practice studies. + */ +class TypingMasteryProvider( + private val typingLogRepository: TypingLogRepository, + private val cardRepository: CardRepository, +) { + fun observeDeckMastery(deckId: String): Flow = + combine( + typingLogRepository.observeLogsForDeck(deckId), + cardRepository.observeCards(deckId), + ) { logs, cards -> + val typeable = cards.filter { it.back.isNotBlank() }.map { it.id }.toSet() + TypingMastery.deckMastery(logs, typeable) + } +} +``` + +- [ ] **Step 6: Register both in Koin** + +In `di/AppModule.kt`, add imports next to the other repository imports: +```kotlin +import nart.simpleanki.core.data.repository.TypingLogRepository +import nart.simpleanki.core.data.repository.TypingMasteryProvider +``` +Then, after `single { ReviewLogRepository(get()) }`, add: +```kotlin + single { TypingLogRepository(get()) } + single { TypingMasteryProvider(get(), get()) } +``` + +- [ ] **Step 7: Run the tests** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.data.repository.TypingLogRepositoryTest" --tests "nart.simpleanki.core.data.repository.TypingMasteryProviderTest"` +Expected: PASS (3 tests). + +- [ ] **Step 8: Commit** +```bash +git add app/src/main/java/nart/simpleanki/core/data/repository/Repositories.kt \ + app/src/main/java/nart/simpleanki/core/data/repository/TypingMasteryProvider.kt \ + app/src/main/java/nart/simpleanki/di/AppModule.kt \ + app/src/test/java/nart/simpleanki/core/data/repository/FakeDaos.kt \ + app/src/test/java/nart/simpleanki/core/data/repository/TypingLogRepositoryTest.kt \ + app/src/test/java/nart/simpleanki/core/data/repository/TypingMasteryProviderTest.kt +git commit -m "Add TypingLogRepository and TypingMasteryProvider" +``` + +--- + +## Task 5: `TypePracticeSession` (pure retry-until-correct state machine) + +**Files:** +- Create: `app/src/main/java/nart/simpleanki/core/domain/typing/TypePracticeSession.kt` +- Test: `app/src/test/java/nart/simpleanki/core/domain/typing/TypePracticeSessionTest.kt` + +- [ ] **Step 1: Write the failing test** + +Create `app/src/test/java/nart/simpleanki/core/domain/typing/TypePracticeSessionTest.kt`: +```kotlin +package nart.simpleanki.core.domain.typing + +import nart.simpleanki.core.domain.model.Card +import nart.simpleanki.core.domain.model.CardState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TypePracticeSessionTest { + private fun card(id: String, back: String) = Card( + id = id, front = "f-$id", back = back, deckId = "d", + dateCreated = 0, lastModified = 0, fsrsDue = 0, fsrsState = CardState.New.value, + ) + + private class Recorder { + data class Entry(val cardId: String, val correct: Boolean, val typed: String) + val entries = mutableListOf() + val sink: (Card, Boolean, String) -> Unit = { c, ok, t -> entries += Entry(c.id, ok, t) } + } + + @Test fun correctFirstTry_finalizesCorrect_advances_andCombos() { + val rec = Recorder() + val s = TypePracticeSession(listOf(card("c1", "a"), card("c2", "b")), emptySet(), rec.sink) + assertEquals("c1", s.current!!.id) + assertEquals(SubmitResult.Correct, s.submit("a")) + assertEquals("c2", s.current!!.id) + assertEquals(SubmitResult.Correct, s.submit("b")) + assertTrue(s.isFinished) + assertEquals(listOf(Recorder.Entry("c1", true, "a"), Recorder.Entry("c2", true, "b")), rec.entries) + assertEquals(2, s.report().bestCombo) + assertEquals(100, s.report().firstTryAccuracy) + assertEquals(2, s.report().newlyMastered) + } + + @Test fun wrongFirstTry_thenContinue_finalizesWrong_andRequeues() { + val rec = Recorder() + val s = TypePracticeSession(listOf(card("c1", "a"), card("c2", "b")), emptySet(), rec.sink) + assertEquals(SubmitResult.Wrong("a"), s.submit("zzz")) + assertTrue(s.isRevealing) + assertTrue(s.canOverride) + s.continueAfterWrong() + // c1 finalized wrong (one log) and requeued behind c2. + assertEquals(Recorder.Entry("c1", false, "zzz"), rec.entries.single()) + assertEquals("c2", s.current!!.id) + assertFalse(s.isRevealing) + // Clear c2, then c1 comes back; typing it right now clears it WITHOUT a new log. + assertEquals(SubmitResult.Correct, s.submit("b")) + assertEquals("c1", s.current!!.id) + assertEquals(SubmitResult.Correct, s.submit("a")) + assertTrue(s.isFinished) + assertEquals(2, rec.entries.size) // exactly one log per card + assertEquals(50, s.report().firstTryAccuracy) // c1 wrong, c2 right + assertEquals(1, s.report().bestCombo) // c2 was a clean first-try correct -> longest run is 1 + } + + @Test fun override_marksCorrect_clears_andCountsNewlyMastered() { + val rec = Recorder() + val s = TypePracticeSession(listOf(card("c1", "a")), emptySet(), rec.sink) + s.submit("close-but-wrong") + assertTrue(s.canOverride) + s.override() + assertTrue(s.isFinished) + assertEquals(Recorder.Entry("c1", true, "close-but-wrong"), rec.entries.single()) + assertEquals(1, s.report().newlyMastered) + } + + @Test fun previouslyMastered_notRecountedAsNewly() { + val rec = Recorder() + val s = TypePracticeSession(listOf(card("c1", "a")), previouslyMastered = setOf("c1"), onFinalize = rec.sink) + s.submit("a") + assertEquals(0, s.report().newlyMastered) + } + + @Test fun emptyPool_isFinished_zeroReport() { + val s = TypePracticeSession(emptyList(), emptySet()) + assertTrue(s.isFinished) + assertNull(s.current) + assertEquals(SessionReport(0, 0, 0, 0, 0), s.report()) + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.domain.typing.TypePracticeSessionTest"` +Expected: COMPILE FAILURE (`TypePracticeSession` / `SubmitResult` / `SessionReport` do not exist). + +- [ ] **Step 3: Implement `TypePracticeSession.kt`** + +Create `app/src/main/java/nart/simpleanki/core/domain/typing/TypePracticeSession.kt`: +```kotlin +package nart.simpleanki.core.domain.typing + +import nart.simpleanki.core.domain.model.Card + +/** Result of submitting a typed answer. */ +sealed interface SubmitResult { + data object Correct : SubmitResult + data class Wrong(val expected: String) : SubmitResult +} + +/** End-of-session summary (first-try based; accuracy is a 0..100 percent). */ +data class SessionReport( + val completed: Int, + val firstTryCorrect: Int, + val firstTryAccuracy: Int, + val bestCombo: Int, + val newlyMastered: Int, +) + +/** + * In-memory state machine for one Type-Practice session — Android-free and unit-testable. + * + * Whole deck, retry-until-correct: a wrong FIRST attempt is revealed then requeued to later in the + * session; the card's first-attempt correctness is what's scored. Each first-attempt outcome is + * finalized exactly once (correct-on-first, or after [continueAfterWrong] / [override]) and emitted + * to [onFinalize] so the caller persists exactly one log per card. Requeued retries clear the loop + * but never re-score and never emit. + */ +class TypePracticeSession( + pool: List, + private val previouslyMastered: Set = emptySet(), + private val onFinalize: (card: Card, correct: Boolean, typed: String) -> Unit = { _, _, _ -> }, +) { + private val queue = ArrayDeque(pool) + private val firstTry = LinkedHashMap() // finalized first-try outcome per card + private var combo = 0 + private var bestCombo = 0 + + // Reveal state after a wrong submit, until continue/override. + private var awaiting = false + private var awaitingFirstAttempt = false + private var awaitingTyped = "" + + val current: Card? get() = queue.firstOrNull() + val remaining: Int get() = queue.size + val isFinished: Boolean get() = queue.isEmpty() + /** True while a wrong answer is revealed, awaiting Continue/override. */ + val isRevealing: Boolean get() = awaiting + /** "I was right" is only offered on a first attempt. */ + val canOverride: Boolean get() = awaiting && awaitingFirstAttempt + + fun submit(answer: String): SubmitResult { + val card = current ?: return SubmitResult.Correct + if (awaiting) return SubmitResult.Wrong(card.back) // UI gates this; be safe + val firstAttempt = card.id !in firstTry + return if (AnswerMatcher.matches(answer, card.back)) { + if (firstAttempt) { + firstTry[card.id] = true + combo += 1 + if (combo > bestCombo) bestCombo = combo + onFinalize(card, true, answer) + } + queue.removeFirst() + SubmitResult.Correct + } else { + combo = 0 + awaiting = true + awaitingFirstAttempt = firstAttempt + awaitingTyped = answer + SubmitResult.Wrong(card.back) + } + } + + /** Dismiss the reveal as wrong: finalize the first-try outcome (once) and requeue the card. */ + fun continueAfterWrong() { + val card = current ?: return + if (!awaiting) return + if (awaitingFirstAttempt && card.id !in firstTry) { + firstTry[card.id] = false + onFinalize(card, false, awaitingTyped) + } + awaiting = false + queue.removeFirst() + queue.addLast(card) // returns later this session + } + + /** "I was right" — first attempts only: finalize correct and clear the card. */ + fun override() { + val card = current ?: return + if (!awaiting || !awaitingFirstAttempt) return + if (card.id !in firstTry) { + firstTry[card.id] = true + onFinalize(card, true, awaitingTyped) + } + awaiting = false + queue.removeFirst() + } + + fun report(): SessionReport { + val completed = firstTry.size + val correct = firstTry.values.count { it } + return SessionReport( + completed = completed, + firstTryCorrect = correct, + firstTryAccuracy = if (completed == 0) 0 else correct * 100 / completed, + bestCombo = bestCombo, + newlyMastered = firstTry.filterValues { it }.keys.count { it !in previouslyMastered }, + ) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.domain.typing.TypePracticeSessionTest"` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** +```bash +git add app/src/main/java/nart/simpleanki/core/domain/typing/TypePracticeSession.kt \ + app/src/test/java/nart/simpleanki/core/domain/typing/TypePracticeSessionTest.kt +git commit -m "Add TypePracticeSession retry-until-correct state machine" +``` + +--- + +## Task 6: Firestore sync for typing logs + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/core/data/firestore/FirestoreDtos.kt` +- Modify: `app/src/main/java/nart/simpleanki/core/data/sync/RemoteSyncSource.kt` +- Modify: `app/src/main/java/nart/simpleanki/core/data/sync/FirestoreSyncService.kt` +- Modify: `app/src/main/java/nart/simpleanki/core/data/sync/SyncManager.kt` +- Modify: `app/src/main/java/nart/simpleanki/di/AppModule.kt` +- Modify: `app/src/test/java/nart/simpleanki/core/data/sync/SyncManagerTest.kt` +- Test: `app/src/test/java/nart/simpleanki/core/data/firestore/TypingLogDtoTest.kt` + +- [ ] **Step 1: Add `TypingLogDto`** + +In `FirestoreDtos.kt`, add the import: +```kotlin +import nart.simpleanki.core.domain.model.TypingLog +``` +Then append after `StreakStateDto` (snake_case `@PropertyName`, `timestamp` as Firestore `Timestamp`): +```kotlin + +// MARK: - Typing log (stored flat under users/{uid}/typingLogs) + +data class TypingLogDto( + var id: String = "", + @get:PropertyName("card_id") @set:PropertyName("card_id") var cardId: String = "", + @get:PropertyName("deck_id") @set:PropertyName("deck_id") var deckId: String = "", + var correct: Boolean = false, + @get:PropertyName("typed_text") @set:PropertyName("typed_text") var typedText: String = "", + var timestamp: Timestamp = Timestamp(Date(0)), +) { + fun toDomain(): TypingLog = TypingLog( + id = id, + cardId = cardId, + deckId = deckId, + correct = correct, + typedText = typedText, + timestamp = timestamp.toMillis(), + ) + + companion object { + fun fromDomain(t: TypingLog): TypingLogDto = TypingLogDto( + id = t.id, + cardId = t.cardId, + deckId = t.deckId, + correct = t.correct, + typedText = t.typedText, + timestamp = t.timestamp.toTimestamp(), + ) + } +} +``` + +- [ ] **Step 2: Add fetch/push to the remote seam** + +In `RemoteSyncSource.kt`, add the import: +```kotlin +import nart.simpleanki.core.data.firestore.TypingLogDto +``` +Then add inside the interface (after the reviewLogs pair): +```kotlin + suspend fun fetchTypingLogs(uid: String): List + suspend fun pushTypingLogs(uid: String, dtos: List) +``` + +- [ ] **Step 3: Implement them in `FirestoreSyncService`** + +In `FirestoreSyncService.kt`, add the import: +```kotlin +import nart.simpleanki.core.data.firestore.TypingLogDto +``` +Then add after the `pushReviewLogs` override: +```kotlin + + override suspend fun fetchTypingLogs(uid: String): List = + col(uid, "typingLogs").get().await().toObjects(TypingLogDto::class.java) + + override suspend fun pushTypingLogs(uid: String, dtos: List) = + push(uid, "typingLogs", dtos) { it.id } +``` + +- [ ] **Step 4: Thread typing logs through `SyncManager`** + +In `SyncManager.kt`: +1. Add imports: +```kotlin +import nart.simpleanki.core.data.firestore.TypingLogDto +import nart.simpleanki.core.data.local.dao.TypingLogDao +``` +2. Add the constructor param immediately after `reviewLogDao`: +```kotlin + private val reviewLogDao: ReviewLogDao, + private val typingLogDao: TypingLogDao, + private val streakStateDao: StreakStateDao, +``` +3. In `push(...)`, after the `reviewLogDao` dirty-push block, add: +```kotlin + // Typing logs are immutable, append-only events: push any dirty rows, then clear the flag. + typingLogDao.getDirty().takeIf { it.isNotEmpty() }?.let { rows -> + remote.pushTypingLogs(uid, rows.map { TypingLogDto.fromDomain(it.toDomain()) }) + rows.forEach { typingLogDao.clearDirty(it.id) } + } +``` +4. In `pull(...)`, after the review-logs union block, add: +```kotlin + // Typing logs: union by id (immutable, so no last-write-wins) — insert only the ids we lack. + val localTypingIds = typingLogDao.getAllIds().toSet() + remote.fetchTypingLogs(uid) + .filter { it.id.isNotEmpty() && it.id !in localTypingIds } + .map { it.toDomain().toEntity(dirty = false) } + .takeIf { it.isNotEmpty() } + ?.let { typingLogDao.insertAll(it) } +``` + +- [ ] **Step 5: Update the Koin `SyncManager` registration** + +In `di/AppModule.kt`, change the `SyncManager` single to pass 8 dependencies (it now resolves `typingLogDao` after `reviewLogDao`): +```kotlin + single { SyncManager(get(), get(), get(), get(), get(), get(), get(), get()) } +``` + +- [ ] **Step 6: Update existing `SyncManagerTest` construction + add typing-log tests** + +In `SyncManagerTest.kt`: +1. Add imports next to the existing fakes/dtos: +```kotlin +import nart.simpleanki.core.data.firestore.TypingLogDto +import nart.simpleanki.core.data.repository.FakeTypingLogDao +``` +2. Make `FakeRemote` implement the new seam methods. Add fields + overrides inside `FakeRemote`: +```kotlin + var typingLogs: MutableList = mutableListOf() +``` +```kotlin + val pushedTypingLogs = mutableListOf() +``` +```kotlin + override suspend fun fetchTypingLogs(uid: String) = typingLogs + override suspend fun pushTypingLogs(uid: String, dtos: List) { pushedTypingLogs += dtos } +``` + (Put the `var typingLogs` next to the other `var ...: MutableList<...>` constructor fields, the `pushedTypingLogs` next to the other `pushed...` vals, and the two overrides next to the reviewLogs overrides.) +3. **Thread the new DAO into every `SyncManager(...)` call.** Each call currently passes the review-log DAO as the 4th arg and the streak DAO as the 5th; insert a `FakeTypingLogDao()` between them. There are 11 call sites — the two shapes are: + - With literal fakes, e.g. `SyncManager(folderDao, FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), FakeStreakStateDao(), remote, m)` becomes + `SyncManager(folderDao, FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), FakeTypingLogDao(), FakeStreakStateDao(), remote, m)`. + - With variables, e.g. `SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), logDao, FakeStreakStateDao(), remote, m)` becomes + `SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), logDao, FakeTypingLogDao(), FakeStreakStateDao(), remote, m)` + and the streak tests `..., FakeReviewLogDao(), dao, ...` become `..., FakeReviewLogDao(), FakeTypingLogDao(), dao, ...`. +4. Add a helper + 2 tests at the end of the class (before the final `}`): +```kotlin + private fun typingLogEntity(id: String, dirty: Boolean) = nart.simpleanki.core.data.local.TypingLogEntity( + id = id, cardId = "c1", deckId = "d1", correct = true, typedText = "x", timestamp = 1_000, dirty = dirty, + ) + + @Test + fun typingLogs_pushDirty_thenClearDirty() = runTest { + val logDao = FakeTypingLogDao() + logDao.insertAll(listOf(typingLogEntity("t1", dirty = true))) + val remote = FakeRemote() + val (m, _) = media() + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), logDao, FakeStreakStateDao(), remote, m) + + sync.sync("u1") + + assertEquals(listOf("t1"), remote.pushedTypingLogs.map { it.id }) + assertTrue(logDao.getDirty().isEmpty()) + } + + @Test + fun typingLogs_pullUnionsRemote_andSkipsExisting() = runTest { + val logDao = FakeTypingLogDao() + logDao.insertAll(listOf(typingLogEntity("t1", dirty = false))) + val remote = FakeRemote(typingLogs = mutableListOf( + TypingLogDto.fromDomain(typingLogEntity("t1", dirty = false).toDomain()), + TypingLogDto.fromDomain(typingLogEntity("t2", dirty = false).toDomain()), + )) + val (m, _) = media() + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), logDao, FakeStreakStateDao(), remote, m) + + sync.sync("u1") + + assertEquals(setOf("t1", "t2"), logDao.getAllIds().toSet()) + assertEquals(listOf("t1", "t2"), logDao.inserted.map { it.id }) + } +``` + Note: the `FakeRemote(typingLogs = ...)` named-arg construction requires `typingLogs` to be a constructor parameter of `FakeRemote` (added in sub-step 2). + +- [ ] **Step 7: Add the DTO round-trip test** + +Create `app/src/test/java/nart/simpleanki/core/data/firestore/TypingLogDtoTest.kt`: +```kotlin +package nart.simpleanki.core.data.firestore + +import nart.simpleanki.core.domain.model.TypingLog +import org.junit.Assert.assertEquals +import org.junit.Test + +class TypingLogDtoTest { + @Test fun roundTrip_preservesFields() { + val domain = TypingLog( + id = "t1", cardId = "c1", deckId = "d1", correct = true, typedText = "café", timestamp = 1_700_000L, + ) + assertEquals(domain, TypingLogDto.fromDomain(domain).toDomain()) + } +} +``` + +- [ ] **Step 8: Build + run the sync + DTO tests** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin :app:testDebugUnitTest --tests "nart.simpleanki.core.data.sync.SyncManagerTest" --tests "nart.simpleanki.core.data.firestore.TypingLogDtoTest"` +Expected: BUILD SUCCESSFUL; all `SyncManagerTest` tests (existing + 2 new) and the DTO test pass. + +- [ ] **Step 9: Commit** +```bash +git add app/src/main/java/nart/simpleanki/core/data/firestore/FirestoreDtos.kt \ + app/src/main/java/nart/simpleanki/core/data/sync/RemoteSyncSource.kt \ + app/src/main/java/nart/simpleanki/core/data/sync/FirestoreSyncService.kt \ + app/src/main/java/nart/simpleanki/core/data/sync/SyncManager.kt \ + app/src/main/java/nart/simpleanki/di/AppModule.kt \ + app/src/test/java/nart/simpleanki/core/data/sync/SyncManagerTest.kt \ + app/src/test/java/nart/simpleanki/core/data/firestore/TypingLogDtoTest.kt +git commit -m "Sync typing logs to Firestore (append-only union)" +``` + +--- + +## Task 7: `TypePracticeViewModel` + +**Files:** +- Create: `app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeViewModel.kt` +- Modify: `app/src/main/java/nart/simpleanki/di/AppModule.kt` +- Test: `app/src/test/java/nart/simpleanki/feature/typepractice/TypePracticeViewModelTest.kt` + +- [ ] **Step 1: Write the failing test** + +Create `app/src/test/java/nart/simpleanki/feature/typepractice/TypePracticeViewModelTest.kt`: +```kotlin +package nart.simpleanki.feature.typepractice + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +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.data.repository.FakeTypingLogDao +import nart.simpleanki.core.data.repository.TypingLogRepository +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 +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class TypePracticeViewModelTest { + private val now = 1_700_000_000_000L + + @Before fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + @After fun tearDown() = Dispatchers.resetMain() + + private fun card(id: String, back: String) = Card( + id = id, front = "f-$id", back = back, deckId = "A", + dateCreated = now, lastModified = now, fsrsDue = now, fsrsState = CardState.New.value, + ) + + private fun vm(logRepo: TypingLogRepository): TypePracticeViewModel { + val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) + val cardRepo = CardRepository(FakeCardDao(), now = { now }) + // Seed via repos (suspend) is awkward here; use direct DAO upserts through the repos instead. + return TypePracticeViewModel("A", cardRepo, deckRepo, logRepo, now = { now }) + } + + @Test + fun correctAnswer_advances_appendsOneLog_andFinishes() = runTest { + val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) + val cardRepo = CardRepository(FakeCardDao(), now = { now }) + deckRepo.upsert(Deck(id = "A", name = "A", dateCreated = now, lastModified = now)) + cardRepo.upsert(card("c1", "answer")) + val logRepo = TypingLogRepository(FakeTypingLogDao(), newId = { java.util.UUID.randomUUID().toString() }) + val model = TypePracticeViewModel("A", cardRepo, deckRepo, logRepo, now = { now }) + backgroundScope.launch { model.uiState.collect {} } + runCurrent() + + assertFalse(model.uiState.value.loading) + assertEquals("c1", model.uiState.value.current!!.id) + + model.onInput("answer") + model.onSubmit() + runCurrent() + + assertTrue(model.uiState.value.finished) + assertEquals(1, model.uiState.value.report!!.firstTryCorrect) + val logs = logRepo.observeLogs().first() + assertEquals(1, logs.size) + assertTrue(logs.single().correct) + } + + @Test + fun wrongAnswer_revealsThenContinue_logsWrong_andRequeues() = runTest { + val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) + val cardRepo = CardRepository(FakeCardDao(), now = { now }) + deckRepo.upsert(Deck(id = "A", name = "A", dateCreated = now, lastModified = now)) + cardRepo.upsert(card("c1", "answer")) + val logRepo = TypingLogRepository(FakeTypingLogDao(), newId = { java.util.UUID.randomUUID().toString() }) + val model = TypePracticeViewModel("A", cardRepo, deckRepo, logRepo, now = { now }) + backgroundScope.launch { model.uiState.collect {} } + runCurrent() + + model.onInput("nope") + model.onSubmit() + runCurrent() + assertTrue(model.uiState.value.revealing) + assertEquals("answer", model.uiState.value.revealedAnswer) + assertTrue(model.uiState.value.canOverride) + + model.onContinue() + runCurrent() + assertFalse(model.uiState.value.revealing) + assertEquals("c1", model.uiState.value.current!!.id) // requeued back to itself (only card) + val logs = logRepo.observeLogs().first() + assertEquals(1, logs.size) + assertFalse(logs.single().correct) + } + + @Test + fun blankBackCards_excluded_emptyPoolFinishesImmediately() = runTest { + val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) + val cardRepo = CardRepository(FakeCardDao(), now = { now }) + deckRepo.upsert(Deck(id = "A", name = "A", dateCreated = now, lastModified = now)) + cardRepo.upsert(card("c1", " ")) // blank back -> not typeable + val logRepo = TypingLogRepository(FakeTypingLogDao(), newId = { java.util.UUID.randomUUID().toString() }) + val model = TypePracticeViewModel("A", cardRepo, deckRepo, logRepo, now = { now }) + backgroundScope.launch { model.uiState.collect {} } + runCurrent() + + assertTrue(model.uiState.value.finished) + assertEquals(0, model.uiState.value.report!!.completed) + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.feature.typepractice.TypePracticeViewModelTest"` +Expected: COMPILE FAILURE (`TypePracticeViewModel` does not exist). + +- [ ] **Step 3: Implement `TypePracticeViewModel.kt`** + +Create `app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeViewModel.kt`: +```kotlin +package nart.simpleanki.feature.typepractice + +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.data.repository.TypingLogRepository +import nart.simpleanki.core.domain.fsrs.StudyQueueBuilder +import nart.simpleanki.core.domain.model.Card +import nart.simpleanki.core.domain.model.ReviewCardFilter +import nart.simpleanki.core.domain.model.TypingLog +import nart.simpleanki.core.domain.typing.SessionReport +import nart.simpleanki.core.domain.typing.SubmitResult +import nart.simpleanki.core.domain.typing.TypePracticeSession +import nart.simpleanki.core.domain.typing.TypingMastery + +data class TypePracticeUiState( + val loading: Boolean = true, + val current: Card? = null, + val input: String = "", + /** Showing the correct answer after a wrong submit. */ + val revealing: Boolean = false, + val revealedAnswer: String = "", + val lastTyped: String = "", + /** Whether "I was right" is offered (first attempts only). */ + val canOverride: Boolean = false, + val remaining: Int = 0, + val finished: Boolean = false, + val report: SessionReport? = null, + /** Increments whenever the prompt card changes; the screen keys autofocus on it. */ + val cardTick: Int = 0, +) + +/** + * Drives one Type-Practice session. Decoupled from FSRS: snapshots the deck's typeable cards + * (respecting the deck's review filter), runs the pure [TypePracticeSession], and appends exactly + * one [TypingLog] per card when its first attempt finalizes. No scheduler or review-log writes. + */ +class TypePracticeViewModel( + private val deckId: String?, + private val cardRepository: CardRepository, + private val deckRepository: DeckRepository, + private val typingLogRepository: TypingLogRepository, + private val now: () -> Long = { System.currentTimeMillis() }, + private val logManager: LogManager = LogManager(emptyList()), +) : ViewModel() { + + private lateinit var session: TypePracticeSession + private val _uiState = MutableStateFlow(TypePracticeUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { viewModelScope.launch { load() } } + + private suspend fun load() { + val deck = deckId?.let { deckRepository.getById(it) } + val cards = if (deckId != null) cardRepository.observeCards(deckId).first() else emptyList() + val pool = StudyQueueBuilder.buildReviewQueue( + cards = cards, + filter = deck?.reviewFilter ?: ReviewCardFilter.All, + shuffleSeed = now(), + ).filter { it.back.isNotBlank() } + val previouslyMastered = TypingMastery.masteredCardIds( + typingLogRepository.observeLogsForDeck(deckId.orEmpty()).first(), + ) + session = TypePracticeSession(pool, previouslyMastered) { card, correct, typed -> + viewModelScope.launch { + typingLogRepository.append( + TypingLog(cardId = card.id, deckId = card.deckId, correct = correct, typedText = typed, timestamp = now()), + ) + } + } + logManager.track(Event.Start(deckId, pool.size)) + renderAdvance() + if (session.isFinished) logComplete() + } + + fun onInput(text: String) { + _uiState.value = _uiState.value.copy(input = text) + } + + fun onSubmit() { + val typed = _uiState.value.input + when (val r = session.submit(typed)) { + SubmitResult.Correct -> { + logManager.track(Event.Answered(true)) + renderAdvance() + if (session.isFinished) logComplete() + } + is SubmitResult.Wrong -> { + logManager.track(Event.Answered(false)) + _uiState.value = _uiState.value.copy( + revealing = true, revealedAnswer = r.expected, lastTyped = typed, canOverride = session.canOverride, + ) + } + } + } + + /** "Don't know": reveal the answer without an attempt; only Continue is offered. */ + fun onDontKnow() { + val card = session.current ?: return + when (session.submit("")) { + is SubmitResult.Wrong -> { + logManager.track(Event.Answered(false)) + _uiState.value = _uiState.value.copy( + revealing = true, revealedAnswer = card.back, lastTyped = "", canOverride = false, + ) + } + SubmitResult.Correct -> renderAdvance() // unreachable (blank backs are filtered out) + } + } + + fun onContinue() { + session.continueAfterWrong() + renderAdvance() + if (session.isFinished) logComplete() + } + + fun onOverride() { + session.override() + renderAdvance() + if (session.isFinished) logComplete() + } + + fun restart() { viewModelScope.launch { load() } } + + /** Refreshes state from the session after the prompt changes (clears input, bumps autofocus tick). */ + private fun renderAdvance() { + val prev = _uiState.value + _uiState.value = prev.copy( + loading = false, + current = session.current, + input = "", + revealing = false, + revealedAnswer = "", + lastTyped = "", + canOverride = false, + remaining = session.remaining, + finished = session.isFinished, + report = if (session.isFinished) session.report() else null, + cardTick = prev.cardTick + 1, + ) + } + + private fun logComplete() { + val r = session.report() + logManager.track(Event.Complete(r.completed, r.firstTryAccuracy)) + } + + private sealed interface Event : LoggableEvent { + data class Start(val deckId: String?, val count: Int) : Event { + override val eventName = "type_practice_start" + override val params get() = buildMap { + deckId?.let { put("deck_id", it) } + put("count", count) + } + } + data class Answered(val correct: Boolean) : Event { + override val eventName = "type_practice_answer" + override val params get() = mapOf("correct" to correct) + } + data class Complete(val count: Int, val accuracy: Int) : Event { + override val eventName = "type_practice_complete" + override val params get() = mapOf("count" to count, "accuracy" to accuracy) + } + } +} +``` + +- [ ] **Step 4: Register the ViewModel in Koin** + +In `di/AppModule.kt`, add imports: +```kotlin +import nart.simpleanki.feature.typepractice.TypePracticeViewModel +``` +Then, after the `ReviewViewModel` `viewModel { ... }` block, add (reuses the existing `StudyArgs`): +```kotlin + viewModel { params -> + val args = params.get() + TypePracticeViewModel( + deckId = args.deckId, + cardRepository = get(), + deckRepository = get(), + typingLogRepository = get(), + logManager = get(), + ) + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin :app:testDebugUnitTest --tests "nart.simpleanki.feature.typepractice.TypePracticeViewModelTest"` +Expected: BUILD SUCCESSFUL; 3 tests pass. + +- [ ] **Step 6: Commit** +```bash +git add app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeViewModel.kt \ + app/src/main/java/nart/simpleanki/di/AppModule.kt \ + app/src/test/java/nart/simpleanki/feature/typepractice/TypePracticeViewModelTest.kt +git commit -m "Add TypePracticeViewModel driving the session and logging" +``` + +--- + +## Task 8: `TypePracticeScreen` (Compose UI + session report) + +**Files:** +- Create: `app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt` + +No unit tests (Compose UI; logic is covered by Tasks 5 & 7). Verified by compile + previews. + +- [ ] **Step 1: Implement `TypePracticeScreen.kt`** + +Create `app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt`: +```kotlin +package nart.simpleanki.feature.typepractice + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +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.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import nart.simpleanki.core.domain.model.Card +import nart.simpleanki.core.domain.model.CardState +import nart.simpleanki.core.domain.typing.SessionReport +import nart.simpleanki.di.StudyArgs +import nart.simpleanki.ui.components.AudioPlayButton +import nart.simpleanki.ui.components.MediaImage +import nart.simpleanki.ui.theme.AzriTheme +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun TypePracticeScreen( + deckId: String, + onDone: () -> Unit, + viewModel: TypePracticeViewModel = koinViewModel { parametersOf(StudyArgs(deckId = deckId)) }, +) { + val state by viewModel.uiState.collectAsState() + TypePracticeContent( + state = state, + onInput = viewModel::onInput, + onSubmit = viewModel::onSubmit, + onDontKnow = viewModel::onDontKnow, + onContinue = viewModel::onContinue, + onOverride = viewModel::onOverride, + onRestart = viewModel::restart, + onDone = onDone, + ) +} + +/** Stateless Type-Practice UI, decoupled from the ViewModel for previews. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TypePracticeContent( + state: TypePracticeUiState, + onInput: (String) -> Unit, + onSubmit: () -> Unit, + onDontKnow: () -> Unit, + onContinue: () -> Unit, + onOverride: () -> Unit, + onRestart: () -> Unit, + onDone: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (state.finished) "Done" else "Type · ${state.remaining} left") }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.background), + navigationIcon = { + IconButton(onClick = onDone) { Icon(Icons.Default.Close, contentDescription = "Close") } + }, + ) + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + when { + state.loading -> CircularProgressIndicator() + state.finished -> SessionReportView(state.report, onRestart, onDone) + else -> PracticeCard(state, onInput, onSubmit, onDontKnow, onContinue, onOverride) + } + } + } +} + +@Composable +private fun PracticeCard( + state: TypePracticeUiState, + onInput: (String) -> Unit, + onSubmit: () -> Unit, + onDontKnow: () -> Unit, + onContinue: () -> Unit, + onOverride: () -> Unit, +) { + val card = state.current ?: return + val focus = remember { FocusRequester() } + // Re-focus the field each time the prompt changes. + LaunchedEffect(state.cardTick) { runCatching { focus.requestFocus() } } + + Column( + Modifier.fillMaxSize().padding(20.dp).verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(8.dp)) + Text( + "PROMPT", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + letterSpacing = 1.sp, + ) + Spacer(Modifier.height(12.dp)) + card.image?.let { name -> + MediaImage(name, card.imagePath, Modifier.fillMaxWidth().height(160.dp)) + Spacer(Modifier.height(16.dp)) + } + Text( + card.front, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + card.audioName?.let { name -> + Spacer(Modifier.height(12.dp)) + AudioPlayButton(name, card.audioPath) + } + + Spacer(Modifier.height(28.dp)) + + OutlinedTextField( + value = state.input, + onValueChange = onInput, + modifier = Modifier.fillMaxWidth().focusRequester(focus), + enabled = !state.revealing, + singleLine = true, + label = { Text("Type the answer") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { if (!state.revealing) onSubmit() }), + ) + + Spacer(Modifier.height(16.dp)) + + if (state.revealing) { + RevealPanel(state, onContinue, onOverride) + } else { + Button( + onClick = onSubmit, + enabled = state.input.isNotBlank(), + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = MaterialTheme.shapes.large, + ) { Text("Check") } + Spacer(Modifier.height(4.dp)) + TextButton(onClick = onDontKnow, modifier = Modifier.fillMaxWidth()) { + Text("Don't know") + } + } + } +} + +@Composable +private fun RevealPanel(state: TypePracticeUiState, onContinue: () -> Unit, onOverride: () -> Unit) { + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Text("Correct answer", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(4.dp)) + Text( + state.revealedAnswer, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + ) + if (state.lastTyped.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + Text( + "You typed: ${state.lastTyped}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + } + Spacer(Modifier.height(16.dp)) + Button( + onClick = onContinue, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = MaterialTheme.shapes.large, + ) { Text("Continue") } + if (state.canOverride) { + Spacer(Modifier.height(4.dp)) + TextButton(onClick = onOverride, modifier = Modifier.fillMaxWidth()) { + Text("I was right") + } + } + } +} + +@Composable +private fun SessionReportView(report: SessionReport?, onRestart: () -> Unit, onDone: () -> Unit) { + val r = report ?: SessionReport(0, 0, 0, 0, 0) + Column( + Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("Practice complete", style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(24.dp)) + ReportRow("Cards", r.completed.toString()) + ReportRow("First-try accuracy", "${r.firstTryAccuracy}%") + ReportRow("Best combo", r.bestCombo.toString()) + ReportRow("Newly mastered", r.newlyMastered.toString()) + Spacer(Modifier.height(28.dp)) + Button( + onClick = onRestart, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = MaterialTheme.shapes.large, + ) { Text("Practice again") } + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = onDone, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = MaterialTheme.shapes.large, + ) { Text("Done") } + } +} + +@Composable +private fun ReportRow(label: String, value: String) { + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text(value, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +private val previewCard = Card( + id = "c1", front = "¿Cómo estás?", back = "How are you?", deckId = "d1", + dateCreated = 0, lastModified = 0, fsrsDue = 0, fsrsState = CardState.New.value, +) + +@Preview(name = "Type · prompt", showBackground = true) +@Composable +private fun TypePromptPreview() { + AzriTheme { + TypePracticeContent( + state = TypePracticeUiState(loading = false, current = previewCard, input = "How are", remaining = 5), + onInput = {}, onSubmit = {}, onDontKnow = {}, onContinue = {}, onOverride = {}, onRestart = {}, onDone = {}, + ) + } +} + +@Preview(name = "Type · revealed (wrong)", showBackground = true) +@Composable +private fun TypeRevealPreview() { + AzriTheme { + TypePracticeContent( + state = TypePracticeUiState( + loading = false, current = previewCard, remaining = 5, + revealing = true, revealedAnswer = "How are you?", lastTyped = "how is you", canOverride = true, + ), + onInput = {}, onSubmit = {}, onDontKnow = {}, onContinue = {}, onOverride = {}, onRestart = {}, onDone = {}, + ) + } +} + +@Preview(name = "Type · report", showBackground = true) +@Composable +private fun TypeReportPreview() { + AzriTheme { + TypePracticeContent( + state = TypePracticeUiState( + loading = false, finished = true, + report = SessionReport(completed = 12, firstTryCorrect = 9, firstTryAccuracy = 75, bestCombo = 5, newlyMastered = 3), + ), + onInput = {}, onSubmit = {}, onDontKnow = {}, onContinue = {}, onOverride = {}, onRestart = {}, onDone = {}, + ) + } +} +``` + +- [ ] **Step 2: Verify it compiles (main + previews)** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin` +Expected: BUILD SUCCESSFUL. (If `MediaImage` / `AudioPlayButton` signatures differ, match their definitions in `ui/components` — they're used in `FlipCard.kt` as `MediaImage(name, path, modifier)` and `AudioPlayButton(name, path)`.) + +- [ ] **Step 3: Commit** +```bash +git add app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt +git commit -m "Add Type Practice screen with reveal and session report" +``` + +--- + +## Task 9: Deck-detail integration + navigation + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailViewModel.kt` +- Modify: `app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailScreen.kt` +- Modify: `app/src/main/java/nart/simpleanki/di/AppModule.kt` +- Modify: `app/src/main/java/nart/simpleanki/ui/navigation/AzriNavHost.kt` + +- [ ] **Step 1: Add the mastery flow to `DeckDetailViewModel`** + +In `DeckDetailViewModel.kt`: +1. Add imports: +```kotlin +import kotlinx.coroutines.flow.flowOf +import nart.simpleanki.core.data.repository.TypingMasteryProvider +import nart.simpleanki.core.domain.typing.DeckMastery +``` +2. Add `mastery` to the UI state (after `newCount`): +```kotlin + val newCount: Int = 0, + val mastery: DeckMastery = DeckMastery(0, 0), +``` +3. Add the provider as a constructor param (nullable, like `deckRepository`, so previews/tests can omit it): +```kotlin +class DeckDetailViewModel( + private val deckId: String, + private val cardRepository: CardRepository, + deckRepository: DeckRepository? = null, + typingMasteryProvider: TypingMasteryProvider? = null, + private val now: () -> Long = { System.currentTimeMillis() }, +) : ViewModel() { +``` +4. Add a mastery flow field (after `deckNameFlow`): +```kotlin + private val masteryFlow = typingMasteryProvider?.observeDeckMastery(deckId) ?: flowOf(DeckMastery(0, 0)) +``` +5. Extend the `combine` to include it (add the 4th flow + lambda param, and set `mastery`): +```kotlin + val uiState: StateFlow = + combine( + cardRepository.observeCards(deckId).withDueTicks(now), + queryFlow, + deckNameFlow, + masteryFlow, + ) { (cards, nowMillis), query, name, mastery -> + DeckDetailUiState( + deckId = deckId, + deckName = name, + cards = cards, + query = query, + newCount = cards.count { it.fsrsState == CardState.New.value }, + dueCount = cards.count { it.fsrsState != CardState.New.value && it.fsrsDue <= nowMillis }, + mastery = mastery, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = DeckDetailUiState(deckId = deckId), + ) +``` + +- [ ] **Step 2: Add the Type Practice button + mastery ring to `DeckDetailScreen`** + +In `DeckDetailScreen.kt`: +1. Add imports (the project already depends on material-icons-extended — `School`/`Style` are used here): +```kotlin +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +``` + (If `Spacer`/`width`/`Row` are already imported, skip the duplicates.) +2. Add `onTypePractice` to **both** `DeckDetailScreen(...)` and `DeckDetailContent(...)` signatures. In `DeckDetailScreen`'s parameter list, after `onReview: () -> Unit,`: +```kotlin + onTypePractice: () -> Unit, +``` + and pass it through in the `DeckDetailContent(...)` call: +```kotlin + onReview = onReview, + onTypePractice = onTypePractice, +``` + In `DeckDetailContent`'s parameter list, after `onReview: () -> Unit = {},`: +```kotlin + onTypePractice: () -> Unit = {}, +``` +3. In the header `Column`, after the existing `if (state.total > 0) { OutlinedButton(onReview...) { ... } }` block, add the Type Practice button + ring: +```kotlin + if (state.total > 0) { + OutlinedButton( + onClick = onTypePractice, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = MaterialTheme.shapes.large, + ) { + if (state.mastery.total > 0) { + CircularProgressIndicator( + progress = { state.mastery.mastered.toFloat() / state.mastery.total }, + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } else { + Icon(Icons.Filled.Keyboard, contentDescription = null) + } + Text( + "Type Practice", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 8.dp), + ) + if (state.mastery.total > 0) { + Spacer(Modifier.weight(1f)) + Text( + "${state.mastery.mastered}/${state.mastery.total} mastered", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +``` +4. Update each `DeckDetailContent(...)` / `DeckDetailScreen(...)` call in the previews at the bottom of the file to pass `onTypePractice = {}` (there are preview calls passing `onReview = {}` / `onStudy = {}`; add `onTypePractice = {}` to each so they compile). + +- [ ] **Step 3: Wire Koin + navigation** + +In `di/AppModule.kt`, update the `DeckDetailViewModel` registration to inject the provider: +```kotlin + viewModel { params -> + DeckDetailViewModel( + deckId = params.get(), + cardRepository = get(), + deckRepository = get(), + typingMasteryProvider = get(), + ) + } +``` + +In `AzriNavHost.kt`: +1. Add the import next to the other screen imports: +```kotlin +import nart.simpleanki.feature.typepractice.TypePracticeScreen +``` +2. In the `composable("deck/{deckId}")` block, add the `onTypePractice` lambda to the `DeckDetailScreen(...)` call (after `onReview`): +```kotlin + onReview = { nav.navigate("review/$deckId") }, + onTypePractice = { nav.navigate("typePractice/$deckId") }, +``` +3. After the `composable("review/{deckId}")` block, add the route: +```kotlin + composable("typePractice/{deckId}") { entry -> + TypePracticeScreen( + deckId = entry.arguments?.getString("deckId").orEmpty(), + onDone = { nav.popBackStack() }, + ) + } +``` + +- [ ] **Step 4: Full build + the whole unit suite + APK** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin :app:compileDebugAndroidTestKotlin :app:testDebugUnitTest :app:assembleDebug` +Expected: BUILD SUCCESSFUL; all unit tests pass. (`compileDebugAndroidTestKotlin` guards against an androidTest call site that constructs `DeckDetailContent`/`DeckDetailScreen` without `onTypePractice`.) + +- [ ] **Step 5: Commit** +```bash +git add app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailViewModel.kt \ + app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailScreen.kt \ + app/src/main/java/nart/simpleanki/di/AppModule.kt \ + app/src/main/java/nart/simpleanki/ui/navigation/AzriNavHost.kt +git commit -m "Add Type Practice entry point and mastery ring to deck detail" +``` + +--- + +## Final verification +- [ ] `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest :app:assembleDebug` → BUILD SUCCESSFUL, all unit tests green. +- [ ] Confirm no commit message mentions "claude" and none carry a Co-Authored-By/attribution trailer: `git log --format='%an <%ae>%n%B' origin/main..HEAD | grep -i -E "claude|co-authored-by"` → no output. +- [ ] Confirm the untracked realtime-study-queue plan was never staged: `git status --short` still shows `?? docs/superpowers/plans/2026-06-04-realtime-study-queue.md`. +- [ ] (Optional, emulator) Open a deck → **Type Practice**: the field auto-focuses; a correct answer advances; a wrong answer reveals the correct answer with **Continue** + **I was right**; finishing shows the report; the deck-detail ring reflects mastered/total and does **not** change FSRS due counts or the study streak. +``` diff --git a/docs/superpowers/plans/2026-06-06-type-practice-answer-diff.md b/docs/superpowers/plans/2026-06-06-type-practice-answer-diff.md new file mode 100644 index 0000000..b167c8b --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-type-practice-answer-diff.md @@ -0,0 +1,281 @@ +# Type Practice Char-Level Answer Diff 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:** On a wrong typed answer, render a character-level colored diff in the Type Practice reveal — the correct answer with missed characters underlined, and the user's input with wrong/extra characters struck through. + +**Architecture:** A new pure `AnswerDiff` (LCS over characters, case-insensitive / accent-sensitive) returns per-character `Match`/`Mismatch` segments for both the expected and typed strings. `RevealPanel` (the only UI change) computes the diff from the already-present `revealedAnswer` + `lastTyped` state and renders two `AnnotatedString`s. No ViewModel/state/matcher/log/mastery change. + +**Tech Stack:** Kotlin, Jetpack Compose (`buildAnnotatedString`/`SpanStyle`/`TextDecoration`), JUnit4. + +**Branch:** `feature/type-practice-mode` (extends the Type Practice feature already on this branch). + +**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`. + +**Commit rule:** No "claude" mention in commit messages; no Co-Authored-By / attribution trailer. NEVER `git add -A` / `git add .` — add explicit paths only, so the untracked `docs/superpowers/plans/2026-06-04-realtime-study-queue.md` is never staged. + +--- + +## File Structure +- `app/src/main/java/nart/simpleanki/core/domain/typing/AnswerDiff.kt` (create) — pure LCS diff; the single unit holding all diff logic. +- `app/src/test/java/nart/simpleanki/core/domain/typing/AnswerDiffTest.kt` (create) — pure JVM tests. +- `app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt` (modify) — `RevealPanel` renders the diff (the only UI change; imports added). + +--- + +## Task 1: `AnswerDiff` (pure character-level diff) + +**Files:** +- Create: `app/src/main/java/nart/simpleanki/core/domain/typing/AnswerDiff.kt` +- Test: `app/src/test/java/nart/simpleanki/core/domain/typing/AnswerDiffTest.kt` + +- [ ] **Step 1: Write the failing test** + +Create `app/src/test/java/nart/simpleanki/core/domain/typing/AnswerDiffTest.kt`: +```kotlin +package nart.simpleanki.core.domain.typing + +import nart.simpleanki.core.domain.typing.AnswerDiff.Kind.Match +import nart.simpleanki.core.domain.typing.AnswerDiff.Kind.Mismatch +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AnswerDiffTest { + private fun seg(text: String, kind: AnswerDiff.Kind) = AnswerDiff.Segment(text, kind) + + @Test fun missingCharInExpected() { + val r = AnswerDiff.diff(typed = "helo", expected = "hello") + assertEquals(listOf(seg("hel", Match), seg("l", Mismatch), seg("o", Match)), r.expected) + assertEquals(listOf(seg("helo", Match)), r.typed) + } + + @Test fun extraCharInTyped() { + val r = AnswerDiff.diff(typed = "helllo", expected = "hello") + assertEquals(listOf(seg("hello", Match)), r.expected) + assertTrue(r.typed.any { it.kind == Mismatch }) + } + + @Test fun emptyTyped_allExpectedMismatch() { + val r = AnswerDiff.diff(typed = "", expected = "cat") + assertEquals(listOf(seg("cat", Mismatch)), r.expected) + assertTrue(r.typed.isEmpty()) + } + + @Test fun caseInsensitiveMatches() { + val r = AnswerDiff.diff(typed = "HELLO", expected = "hello") + assertEquals(listOf(seg("hello", Match)), r.expected) + assertEquals(listOf(seg("HELLO", Match)), r.typed) + } + + @Test fun accentIsAMismatch() { + val r = AnswerDiff.diff(typed = "cafe", expected = "café") + assertEquals(listOf(seg("caf", Match), seg("é", Mismatch)), r.expected) + assertEquals(listOf(seg("caf", Match), seg("e", Mismatch)), r.typed) + } + + @Test fun noCommonChars_allMismatch() { + val r = AnswerDiff.diff(typed = "xyz", expected = "abc") + assertEquals(listOf(seg("abc", Mismatch)), r.expected) + assertEquals(listOf(seg("xyz", Mismatch)), r.typed) + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.domain.typing.AnswerDiffTest"` +Expected: COMPILE FAILURE (`AnswerDiff` does not exist). + +- [ ] **Step 3: Implement `AnswerDiff.kt`** + +Create `app/src/main/java/nart/simpleanki/core/domain/typing/AnswerDiff.kt`: +```kotlin +package nart.simpleanki.core.domain.typing + +/** + * Character-level diff between a typed answer and the expected answer, for the wrong-answer reveal. + * Matching is case-insensitive (the answer check is too) but accent-sensitive, so "é" vs "e" is a + * mismatch. Returns, for each of the expected and typed strings, a list of coalesced [Segment]s + * marking which runs are on the longest common subsequence ([Kind.Match]) and which differ + * ([Kind.Mismatch] — i.e. missing chars in the expected string, extra/wrong chars in the typed one). + */ +object AnswerDiff { + enum class Kind { Match, Mismatch } + data class Segment(val text: String, val kind: Kind) + data class Result(val expected: List, val typed: List) + + fun diff(typed: String, expected: String): Result { + val a = typed + val b = expected + val n = a.length + val m = b.length + // dp[i][j] = LCS length of a[i..] and b[j..] (case-insensitive char equality). + val dp = Array(n + 1) { IntArray(m + 1) } + for (i in n - 1 downTo 0) { + for (j in m - 1 downTo 0) { + dp[i][j] = if (a[i].matchesIgnoreCase(b[j])) dp[i + 1][j + 1] + 1 + else maxOf(dp[i + 1][j], dp[i][j + 1]) + } + } + val aMatch = BooleanArray(n) + val bMatch = BooleanArray(m) + var i = 0 + var j = 0 + while (i < n && j < m) { + if (a[i].matchesIgnoreCase(b[j])) { + aMatch[i] = true + bMatch[j] = true + i++ + j++ + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + i++ + } else { + j++ + } + } + return Result(expected = segmentsOf(b, bMatch), typed = segmentsOf(a, aMatch)) + } + + private fun Char.matchesIgnoreCase(other: Char): Boolean = + this == other || lowercaseChar() == other.lowercaseChar() + + /** Coalesces consecutive chars of [s] with the same match-status into [Segment]s. */ + private fun segmentsOf(s: String, match: BooleanArray): List { + val out = mutableListOf() + var k = 0 + while (k < s.length) { + val kind = if (match[k]) Kind.Match else Kind.Mismatch + val start = k + while (k < s.length && (if (match[k]) Kind.Match else Kind.Mismatch) == kind) k++ + out += Segment(s.substring(start, k), kind) + } + return out + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.domain.typing.AnswerDiffTest"` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** +```bash +git add app/src/main/java/nart/simpleanki/core/domain/typing/AnswerDiff.kt \ + app/src/test/java/nart/simpleanki/core/domain/typing/AnswerDiffTest.kt +git commit -m "Add AnswerDiff for character-level answer comparison" +``` + +--- + +## Task 2: Render the diff in `RevealPanel` + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt` + +No unit test (Compose UI; the logic is covered by Task 1). Verified by compile + the existing `TypeRevealPreview` (which supplies a wrong typed string, so it renders the diff). + +- [ ] **Step 1: Add imports** + +In `TypePracticeScreen.kt`, add these imports (alongside the existing ones): +```kotlin +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import nart.simpleanki.core.domain.typing.AnswerDiff +``` + +- [ ] **Step 2: Replace the `RevealPanel` composable** + +Replace the ENTIRE existing `RevealPanel` composable with this version. It keeps the Continue / "I was right" buttons unchanged and swaps the two plain `Text`s (the correct answer and "You typed: …") for diff-colored ones: +```kotlin +@Composable +private fun RevealPanel(state: TypePracticeUiState, onContinue: () -> Unit, onOverride: () -> Unit) { + val diff = remember(state.revealedAnswer, state.lastTyped) { + AnswerDiff.diff(typed = state.lastTyped, expected = state.revealedAnswer) + } + val matchColor = MaterialTheme.colorScheme.primary + val missColor = MaterialTheme.colorScheme.error + val typedMatchColor = MaterialTheme.colorScheme.onSurfaceVariant + + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "Correct answer", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + Text( + buildAnnotatedString { + diff.expected.forEach { seg -> + when (seg.kind) { + AnswerDiff.Kind.Match -> + withStyle(SpanStyle(color = matchColor)) { append(seg.text) } + AnswerDiff.Kind.Mismatch -> + withStyle(SpanStyle(color = missColor, textDecoration = TextDecoration.Underline)) { append(seg.text) } + } + } + }, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + ) + if (state.lastTyped.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + Text( + "You typed", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(2.dp)) + Text( + buildAnnotatedString { + diff.typed.forEach { seg -> + when (seg.kind) { + AnswerDiff.Kind.Match -> + withStyle(SpanStyle(color = typedMatchColor)) { append(seg.text) } + AnswerDiff.Kind.Mismatch -> + withStyle(SpanStyle(color = missColor, textDecoration = TextDecoration.LineThrough)) { append(seg.text) } + } + } + }, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + } + Spacer(Modifier.height(16.dp)) + Button( + onClick = onContinue, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = MaterialTheme.shapes.large, + ) { Text("Continue") } + if (state.canOverride) { + Spacer(Modifier.height(4.dp)) + TextButton(onClick = onOverride, modifier = Modifier.fillMaxWidth()) { + Text("I was right") + } + } + } +} +``` +(`remember`, `MaterialTheme`, `Modifier`, `Alignment`, `Spacer`, `height`, `fillMaxWidth`, `TextAlign`, `Button`, `TextButton`, `Text`, `dp` are all already imported in this file. Do not change any other composable, and leave the existing previews as-is — `TypeRevealPreview` already exercises the diff path.) + +- [ ] **Step 3: Verify it compiles + full unit suite** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin :app:testDebugUnitTest` +Expected: BUILD SUCCESSFUL; all unit tests pass (the suite gains the 6 `AnswerDiffTest` tests from Task 1). + +- [ ] **Step 4: Commit** +```bash +git add app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt +git commit -m "Render char-level diff in the Type Practice wrong-answer reveal" +``` + +--- + +## Final verification +- [ ] `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest :app:assembleDebug` → BUILD SUCCESSFUL, all unit tests green. +- [ ] Confirm no commit message mentions "claude" and none carry a Co-Authored-By/attribution trailer: `git log --format='%B' origin/main..HEAD | grep -i -E "claude|co-authored-by"` → no output. +- [ ] Confirm the untracked realtime-study-queue plan was never staged: `git status --short` still shows `?? docs/superpowers/plans/2026-06-04-realtime-study-queue.md`. +- [ ] (Optional, emulator) In a Type Practice session, type a wrong answer → the reveal shows the correct answer with the missed characters underlined in the error color and your input with the wrong/extra characters struck through; "Don't know" shows the whole answer marked as missed with no "You typed" line. diff --git a/docs/superpowers/plans/2026-06-06-type-practice-gamified-redesign.md b/docs/superpowers/plans/2026-06-06-type-practice-gamified-redesign.md new file mode 100644 index 0000000..4f5249f --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-type-practice-gamified-redesign.md @@ -0,0 +1,950 @@ +# Gamified Type Practice Redesign 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:** Restyle the in-session Type Practice screen into a gamified "split-zones" layout — top progress bar + live combo chip, prompt hero card, bottom-anchored answer zone, a mint success-flash on correct answers, and per-card motion — reusing the app's rating-color palette and staying FSRS-decoupled. + +**Architecture:** A shared `RatingColors` palette (DRY). The pure `TypePracticeSession` exposes its live `combo`. `TypePracticeViewModel` adds `combo`/`total`/`celebrating` to `UiState` and, on a correct answer, holds a ~400ms "celebrating" phase (mint flash) before auto-advancing. `TypePracticeScreen` is restructured into the gamified layout (top progress + combo chip, prompt hero card, bottom answer zone, mint celebrate, pink diff, per-card slide-in). + +**Tech Stack:** Kotlin, Jetpack Compose (Material3, animation), coroutines, JUnit4 + coroutines-test. + +**Branch:** `feature/type-practice-mode`. + +**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`. + +**Commit rule:** No "claude" mention in commit messages; no Co-Authored-By / attribution trailer. NEVER `git add -A` / `git add .` — add explicit paths only (the untracked `docs/superpowers/plans/2026-06-04-realtime-study-queue.md` and the gitignored `.superpowers/` must never be staged). + +--- + +## File Structure +- `app/src/main/java/nart/simpleanki/ui/theme/RatingColors.kt` (create) — shared rating palette. +- `app/src/main/java/nart/simpleanki/feature/study/StudyScreen.kt` (modify) — use `RatingColors`. +- `app/src/main/java/nart/simpleanki/core/domain/typing/TypePracticeSession.kt` (modify) — expose `currentCombo`. +- `app/src/test/java/nart/simpleanki/core/domain/typing/TypePracticeSessionTest.kt` (modify) — combo test. +- `app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeViewModel.kt` (modify) — combo/total/celebrating phase. +- `app/src/test/java/nart/simpleanki/feature/typepractice/TypePracticeViewModelTest.kt` (modify) — virtual-time + celebrate tests. +- `app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt` (modify) — full gamified restructure. + +--- + +## Task 1: Shared `RatingColors` palette + +**Files:** +- Create: `app/src/main/java/nart/simpleanki/ui/theme/RatingColors.kt` +- Modify: `app/src/main/java/nart/simpleanki/feature/study/StudyScreen.kt` + +No unit test (a pure color-constants object; comparing `androidx.compose.ui.graphics.Color` in a plain JVM test is brittle). Verified by compile + the existing `StudyViewModelTest` still passing. + +- [ ] **Step 1: Create `RatingColors.kt`** +```kotlin +package nart.simpleanki.ui.theme + +import androidx.compose.ui.graphics.Color + +/** + * iOS-derived spaced-repetition rating colors, shared across study modes so "a correct typed answer" + * and an "Easy" review read as the same outcome. Single source of truth (previously inline literals + * in StudyScreen). + */ +object RatingColors { + val Again = Color(0xFFFF2D55) // wrong / incorrect + val Hard = Color(0xFFFF9500) + val Good = Color(0xFF5856D6) + val Easy = Color(0xFF00C7BE) // correct / success +} +``` + +- [ ] **Step 2: Use it in `StudyScreen.kt`** + +Add the import (next to the other `nart.simpleanki...` imports): +```kotlin +import nart.simpleanki.ui.theme.RatingColors +``` +Replace the four inline color literals in the rating-button `Row` (currently `Color(0xFFFF2D55)`, `Color(0xFFFF9500)`, `Color(0xFF5856D6)`, `Color(0xFF00C7BE)`) with the shared values: +```kotlin + RatingButton("Again", state.ratingIntervals[Rating.Again], RatingColors.Again, Modifier.weight(1f)) { onRate(Rating.Again) } + RatingButton("Hard", state.ratingIntervals[Rating.Hard], RatingColors.Hard, Modifier.weight(1f)) { onRate(Rating.Hard) } + RatingButton("Good", state.ratingIntervals[Rating.Good], RatingColors.Good, Modifier.weight(1f)) { onRate(Rating.Good) } + RatingButton("Easy", state.ratingIntervals[Rating.Easy], RatingColors.Easy, Modifier.weight(1f)) { onRate(Rating.Easy) } +``` +If `androidx.compose.ui.graphics.Color` is now unused in `StudyScreen.kt`, remove its import; otherwise leave it. + +- [ ] **Step 3: Verify compile + study tests** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin :app:testDebugUnitTest --tests "nart.simpleanki.feature.study.StudyViewModelTest"` +Expected: BUILD SUCCESSFUL; StudyViewModelTest passes. + +- [ ] **Step 4: Commit** +```bash +git add app/src/main/java/nart/simpleanki/ui/theme/RatingColors.kt \ + app/src/main/java/nart/simpleanki/feature/study/StudyScreen.kt +git commit -m "Extract shared RatingColors palette" +``` + +--- + +## Task 2: Expose the live combo from `TypePracticeSession` + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/core/domain/typing/TypePracticeSession.kt` +- Test: `app/src/test/java/nart/simpleanki/core/domain/typing/TypePracticeSessionTest.kt` + +- [ ] **Step 1: Write the failing test** + +Append inside `class TypePracticeSessionTest` (the `card(id, back)` helper already sets `front = "f-$id"`): +```kotlin + @Test fun currentCombo_incrementsOnCorrect_resetsOnWrong() { + val s = TypePracticeSession(listOf(card("c1", "a"), card("c2", "b"), card("c3", "c"))) + assertEquals(0, s.currentCombo) + s.submit("a"); assertEquals(1, s.currentCombo) + s.submit("b"); assertEquals(2, s.currentCombo) + s.submit("nope"); assertEquals(0, s.currentCombo) // wrong first-try resets the combo + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.domain.typing.TypePracticeSessionTest"` +Expected: COMPILE FAILURE (`currentCombo` does not exist). + +- [ ] **Step 3: Expose `currentCombo`** + +In `TypePracticeSession.kt`, add this getter next to the other public `val`s (e.g. after `canOverride`): +```kotlin + /** The live combo (consecutive first-try corrects; resets to 0 on any wrong submit). */ + val currentCombo: Int get() = combo +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.domain.typing.TypePracticeSessionTest"` +Expected: PASS (all existing + the new test). + +- [ ] **Step 5: Commit** +```bash +git add app/src/main/java/nart/simpleanki/core/domain/typing/TypePracticeSession.kt \ + app/src/test/java/nart/simpleanki/core/domain/typing/TypePracticeSessionTest.kt +git commit -m "Expose live combo from TypePracticeSession" +``` + +--- + +## Task 3: ViewModel — combo, total, and the celebrating phase + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeViewModel.kt` +- Test: `app/src/test/java/nart/simpleanki/feature/typepractice/TypePracticeViewModelTest.kt` + +### Step 1: Write the failing tests (replace the whole test file) + +The celebrate phase uses `delay`, so the test class moves to a `StandardTestDispatcher` and drives virtual time (`advanceUntilIdle`). Replace the ENTIRE contents of `TypePracticeViewModelTest.kt` with: +```kotlin +package nart.simpleanki.feature.typepractice + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +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.data.repository.FakeTypingLogDao +import nart.simpleanki.core.data.repository.TypingLogRepository +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.typing.TypeDirection +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class TypePracticeViewModelTest { + private val now = 1_700_000_000_000L + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + private fun card(id: String, back: String, front: String = "f-$id") = Card( + id = id, front = front, back = back, deckId = "A", + dateCreated = now, lastModified = now, fsrsDue = now, fsrsState = CardState.New.value, + ) + + private fun model(vararg cards: Card): Pair { + val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) + val cardRepo = CardRepository(FakeCardDao(), now = { now }) + val logRepo = TypingLogRepository(FakeTypingLogDao(), newId = { java.util.UUID.randomUUID().toString() }) + // seed + kotlinx.coroutines.runBlocking { + deckRepo.upsert(Deck(id = "A", name = "A", dateCreated = now, lastModified = now)) + cards.forEach { cardRepo.upsert(it) } + } + return TypePracticeViewModel("A", cardRepo, deckRepo, logRepo, now = { now }) to logRepo + } + + @Test + fun correctAnswer_celebrates_thenAdvances_andAppendsOneLog() = runTest(dispatcher.scheduler) { + val (vm, logRepo) = model(card("c1", "answer"), card("c2", "two")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + assertTrue(vm.uiState.value.awaitingDirection) + + vm.chooseDirection(TypeDirection.TypeBack) + advanceUntilIdle() + assertEquals("c1", vm.uiState.value.current!!.id) + assertEquals(2, vm.uiState.value.total) + + vm.onInput("answer") + vm.onSubmit() + // celebrating is set synchronously, BEFORE the delayed advance runs + assertTrue(vm.uiState.value.celebrating) + assertEquals("c1", vm.uiState.value.current!!.id) // still showing the just-answered card + assertEquals(1, vm.uiState.value.combo) + + advanceUntilIdle() // runs the ~400ms delay + assertFalse(vm.uiState.value.celebrating) + assertEquals("c2", vm.uiState.value.current!!.id) // advanced + + val logs = logRepo.observeLogs().first() + assertEquals(1, logs.size) + assertTrue(logs.single().correct) + } + + @Test + fun progress_tracksClearedOverTotal() = runTest(dispatcher.scheduler) { + val (vm, _) = model(card("c1", "a"), card("c2", "b")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + vm.chooseDirection(TypeDirection.TypeBack) + advanceUntilIdle() + assertEquals(2, vm.uiState.value.total) + assertEquals(2, vm.uiState.value.remaining) // 0 cleared + + vm.onInput("a"); vm.onSubmit(); advanceUntilIdle() + assertEquals(2, vm.uiState.value.total) + assertEquals(1, vm.uiState.value.remaining) // 1 cleared → progress 1/2 + } + + @Test + fun wrongAnswer_resetsComboChip_andReveals() = runTest(dispatcher.scheduler) { + val (vm, _) = model(card("c1", "answer")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + vm.chooseDirection(TypeDirection.TypeBack) + advanceUntilIdle() + + vm.onInput("nope"); vm.onSubmit() + assertTrue(vm.uiState.value.revealing) + assertEquals(0, vm.uiState.value.combo) + assertEquals("answer", vm.uiState.value.revealedAnswer) + assertFalse(vm.uiState.value.celebrating) + } + + @Test + fun inputIgnoredWhileCelebrating() = runTest(dispatcher.scheduler) { + val (vm, _) = model(card("c1", "a"), card("c2", "b")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + vm.chooseDirection(TypeDirection.TypeBack) + advanceUntilIdle() + + vm.onInput("a"); vm.onSubmit() + assertTrue(vm.uiState.value.celebrating) + vm.onInput("ignored") // ignored during the flash + assertEquals("a", vm.uiState.value.input) + vm.onSubmit() // no-op during the flash + assertEquals("c1", vm.uiState.value.current!!.id) + } + + @Test + fun lastCardCorrect_finishesAfterCelebrate() = runTest(dispatcher.scheduler) { + val (vm, _) = model(card("c1", "a")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + vm.chooseDirection(TypeDirection.TypeBack) + advanceUntilIdle() + + vm.onInput("a"); vm.onSubmit() + assertTrue(vm.uiState.value.celebrating) + advanceUntilIdle() + assertTrue(vm.uiState.value.finished) + assertEquals(1, vm.uiState.value.report!!.completed) + } + + @Test + fun typeFront_typesTheFront() = runTest(dispatcher.scheduler) { + val (vm, logRepo) = model(card("c1", back = "definition")) // front is "f-c1" + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + vm.chooseDirection(TypeDirection.TypeFront) + advanceUntilIdle() + vm.onInput("f-c1"); vm.onSubmit(); advanceUntilIdle() + assertTrue(vm.uiState.value.finished) + assertTrue(logRepo.observeLogs().first().single().correct) + } +} +``` + +### Step 2: Run to verify failure +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.feature.typepractice.TypePracticeViewModelTest"` +Expected: COMPILE FAILURE / assertion failures (`celebrating`, `combo`, `total` don't exist; no celebrate phase). + +### Step 3: Add the fields to `TypePracticeUiState` +In `TypePracticeViewModel.kt`, add three fields to `TypePracticeUiState` (after `cardTick`): +```kotlin + /** Live combo for the chip (consecutive first-try correct; 0 resets on a miss). */ + val combo: Int = 0, + /** Session pool size, for the progress bar (progress = (total - remaining)/total). */ + val total: Int = 0, + /** True during the brief mint success flash before auto-advancing. */ + val celebrating: Boolean = false, +``` + +### Step 4: Add the celebrate constant + pool-size field +At the top of `TypePracticeViewModel.kt` (below the imports, above `data class TypePracticeUiState`), add: +```kotlin +/** How long the mint success flash holds before auto-advancing. */ +private const val CELEBRATE_MS = 400L +``` +In the `TypePracticeViewModel` class body, add a field next to `baseCards`: +```kotlin + private var poolTotal = 0 +``` + +### Step 5: Set `poolTotal` in `startSession` +In `startSession`, right after `pool` is built (before/after `previouslyMastered`), set it: +```kotlin + poolTotal = pool.size +``` + +### Step 6: Rewrite `onSubmit` for the celebrate phase +Replace the entire `onSubmit` function with: +```kotlin + fun onSubmit() { + if (!::session.isInitialized || _uiState.value.celebrating) return + val typed = _uiState.value.input + val answered = session.current // capture BEFORE submit advances the queue + when (val r = session.submit(typed)) { + SubmitResult.Correct -> { + logManager.track(Event.Answered(true)) + _uiState.value = _uiState.value.copy( + celebrating = true, + current = answered, // keep showing the just-answered card + input = typed, // shown in mint, disabled + combo = session.currentCombo, // popped +1 + revealing = false, + ) + viewModelScope.launch { + kotlinx.coroutines.delay(CELEBRATE_MS) + renderAdvance() + if (session.isFinished) logComplete() + } + } + is SubmitResult.Wrong -> { + logManager.track(Event.Answered(false)) + _uiState.value = _uiState.value.copy( + revealing = true, revealedAnswer = r.expected, lastTyped = typed, + canOverride = session.canOverride, combo = session.currentCombo, + ) + } + } + } +``` + +### Step 7: Guard `onInput` / `onDontKnow` against the flash + reset the chip on Don't-know +Replace `onInput` and `onDontKnow` with: +```kotlin + fun onInput(text: String) { + if (_uiState.value.celebrating) return + _uiState.value = _uiState.value.copy(input = text) + } + + /** "Don't know": reveal the answer without an attempt; only Continue is offered. */ + fun onDontKnow() { + if (!::session.isInitialized || _uiState.value.celebrating) return + if (session.current == null) return + when (val r = session.submit("")) { + is SubmitResult.Wrong -> { + logManager.track(Event.Answered(false)) + _uiState.value = _uiState.value.copy( + revealing = true, revealedAnswer = r.expected, lastTyped = "", + canOverride = false, combo = session.currentCombo, + ) + } + SubmitResult.Correct -> renderAdvance() // unreachable (the typed side is never blank) + } + } +``` + +### Step 8: Surface combo/total/celebrating in `renderAdvance` +In `renderAdvance`, add three fields to the `prev.copy(...)` (alongside the existing ones): +```kotlin + combo = session.currentCombo, + total = poolTotal, + celebrating = false, +``` + +### Step 9: Run tests to verify they pass +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin :app:testDebugUnitTest --tests "nart.simpleanki.feature.typepractice.TypePracticeViewModelTest"` +Expected: BUILD SUCCESSFUL; all 6 tests pass. + +### Step 10: Commit +```bash +git add app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeViewModel.kt \ + app/src/test/java/nart/simpleanki/feature/typepractice/TypePracticeViewModelTest.kt +git commit -m "Add combo, progress total, and the celebrating success phase to Type Practice VM" +``` + +--- + +## Task 4: Gamified screen restructure (full rewrite) + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt` + +No unit test (Compose UI; logic covered by Tasks 2–3). Verified by compile + previews. + +- [ ] **Step 1: Replace the ENTIRE file** `TypePracticeScreen.kt` with: +```kotlin +package nart.simpleanki.feature.typepractice + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.LocalFireDepartment +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +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.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import nart.simpleanki.core.domain.model.Card +import nart.simpleanki.core.domain.model.CardState +import nart.simpleanki.core.domain.typing.AnswerDiff +import nart.simpleanki.core.domain.typing.SessionReport +import nart.simpleanki.core.domain.typing.TypeDirection +import nart.simpleanki.di.StudyArgs +import nart.simpleanki.ui.components.AudioPlayButton +import nart.simpleanki.ui.components.MediaImage +import nart.simpleanki.ui.theme.AzriTheme +import nart.simpleanki.ui.theme.RatingColors +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun TypePracticeScreen( + deckId: String, + onDone: () -> Unit, + viewModel: TypePracticeViewModel = koinViewModel { parametersOf(StudyArgs(deckId = deckId)) }, +) { + val state by viewModel.uiState.collectAsState() + TypePracticeContent( + state = state, + onChooseDirection = viewModel::chooseDirection, + onInput = viewModel::onInput, + onSubmit = viewModel::onSubmit, + onDontKnow = viewModel::onDontKnow, + onContinue = viewModel::onContinue, + onOverride = viewModel::onOverride, + onRestart = viewModel::restart, + onDone = onDone, + ) +} + +/** Stateless gamified Type-Practice UI, decoupled from the ViewModel for previews. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TypePracticeContent( + state: TypePracticeUiState, + onChooseDirection: (TypeDirection) -> Unit, + onInput: (String) -> Unit, + onSubmit: () -> Unit, + onDontKnow: () -> Unit, + onContinue: () -> Unit, + onOverride: () -> Unit, + onRestart: () -> Unit, + onDone: () -> Unit, +) { + val inSession = !state.loading && !state.awaitingDirection && !state.finished + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onDone) { Icon(Icons.Default.Close, contentDescription = "Close") } + }, + title = { + if (inSession) { + val target = if (state.total > 0) (state.total - state.remaining).toFloat() / state.total else 0f + val progress by animateFloatAsState(targetValue = target, animationSpec = tween(300), label = "progress") + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth().padding(end = 12.dp), + color = if (state.celebrating) RatingColors.Easy else MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } else { + Text(if (state.finished) "Done" else "Type Practice") + } + }, + actions = { if (inSession) ComboChip(state.combo) }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.background), + ) + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + when { + state.loading -> CircularProgressIndicator() + state.awaitingDirection -> DirectionChooser(onChooseDirection) + state.finished -> SessionReportView(state.report, onRestart, onDone) + else -> PracticeCard(state, onInput, onSubmit, onDontKnow, onContinue, onOverride) + } + } + } +} + +private val ComboAmber = Color(0xFFFF9500) + +@Composable +private fun ComboChip(combo: Int) { + val active = combo >= 1 + val pop = remember { Animatable(1f) } + LaunchedEffect(combo) { + if (combo >= 1) { pop.snapTo(1.25f); pop.animateTo(1f, tween(180)) } + } + Surface( + shape = RoundedCornerShape(50), + color = if (active) ComboAmber.copy(alpha = 0.18f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.padding(end = 8.dp).graphicsLayer { scaleX = pop.value; scaleY = pop.value }, + ) { + Row(Modifier.padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.LocalFireDepartment, + contentDescription = "Combo", + tint = if (active) ComboAmber else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text( + "$combo", + style = MaterialTheme.typography.labelLarge, + color = if (active) ComboAmber else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun DirectionChooser(onChoose: (TypeDirection) -> Unit) { + Column( + Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("What do you want to type?", style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center) + Spacer(Modifier.height(8.dp)) + Text( + "Pick the side you'll produce from memory.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(28.dp)) + DirectionOption("Type the back", "See the front → type the back (the answer)") { onChoose(TypeDirection.TypeBack) } + Spacer(Modifier.height(12.dp)) + DirectionOption("Type the front", "See the back → type the front") { onChoose(TypeDirection.TypeFront) } + } +} + +@Composable +private fun DirectionOption(title: String, subtitle: String, onClick: () -> Unit) { + OutlinedButton( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp), + ) { + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +private fun PracticeCard( + state: TypePracticeUiState, + onInput: (String) -> Unit, + onSubmit: () -> Unit, + onDontKnow: () -> Unit, + onContinue: () -> Unit, + onOverride: () -> Unit, +) { + val card = state.current ?: return + val typeFront = state.direction == TypeDirection.TypeFront + val focus = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + LaunchedEffect(state.cardTick) { runCatching { focus.requestFocus() } } + LaunchedEffect(state.revealing, state.celebrating) { + if (state.revealing || state.celebrating) keyboard?.hide() + } + + Column(Modifier.fillMaxSize().padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally) { + // Upper zone: prompt hero card — scrolls if long, slides per card. + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + AnimatedContent( + targetState = state.cardTick, + transitionSpec = { + (slideInHorizontally(tween(250)) { it / 3 } + fadeIn(tween(250))) togetherWith + (slideOutHorizontally(tween(200)) { -it / 3 } + fadeOut(tween(200))) + }, + label = "card", + ) { _ -> + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PromptCard(card, typeFront, celebrating = state.celebrating) + } + } + } + Spacer(Modifier.height(16.dp)) + // Lower zone (thumb-rail): answer / celebrate / reveal. + when { + state.celebrating -> CorrectInput(state.input) + state.revealing -> RevealPanel(state, onContinue, onOverride) + else -> AnswerInput(state, focus, onInput, onSubmit, onDontKnow) + } + } +} + +@Composable +private fun PromptCard(card: Card, typeFront: Boolean, celebrating: Boolean) { + val mint = RatingColors.Easy + Surface( + shape = RoundedCornerShape(20.dp), + color = if (celebrating) mint.copy(alpha = 0.08f) else MaterialTheme.colorScheme.surface, + border = if (celebrating) BorderStroke(1.5.dp, mint) else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(24.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + if (celebrating) { + Box(Modifier.size(36.dp).clip(CircleShape).background(mint), contentAlignment = Alignment.Center) { + Icon(Icons.Filled.Check, contentDescription = "Correct", tint = Color.White, modifier = Modifier.size(22.dp)) + } + Spacer(Modifier.height(14.dp)) + } else { + DirectionPill(typeFront) + Spacer(Modifier.height(14.dp)) + } + if (!typeFront) { + card.image?.let { name -> + MediaImage(name, card.imagePath, Modifier.fillMaxWidth().height(160.dp)) + Spacer(Modifier.height(16.dp)) + } + } + Text( + if (typeFront) card.back else card.front, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + card.audioName?.let { name -> + Spacer(Modifier.height(16.dp)) + AudioPlayButton(name, card.audioPath) + } + } + } +} + +@Composable +private fun DirectionPill(typeFront: Boolean) { + Surface(shape = RoundedCornerShape(50), color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)) { + Text( + if (typeFront) "TYPE THE FRONT" else "TYPE THE BACK", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + letterSpacing = 1.sp, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) + } +} + +@Composable +private fun AnswerInput( + state: TypePracticeUiState, + focus: FocusRequester, + onInput: (String) -> Unit, + onSubmit: () -> Unit, + onDontKnow: () -> Unit, +) { + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + OutlinedTextField( + value = state.input, + onValueChange = onInput, + modifier = Modifier.fillMaxWidth().focusRequester(focus), + singleLine = true, + label = { Text("Type the answer") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { onSubmit() }), + ) + Spacer(Modifier.height(12.dp)) + Button( + onClick = onSubmit, + enabled = state.input.isNotBlank(), + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = MaterialTheme.shapes.large, + ) { Text("Check") } + Spacer(Modifier.height(4.dp)) + TextButton(onClick = onDontKnow, modifier = Modifier.fillMaxWidth()) { Text("Don't know") } + } +} + +@Composable +private fun CorrectInput(typed: String) { + val mint = RatingColors.Easy + Surface( + shape = RoundedCornerShape(14.dp), + border = BorderStroke(1.5.dp, mint), + color = mint.copy(alpha = 0.06f), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + typed, + color = mint, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(16.dp).fillMaxWidth(), + ) + } +} + +@Composable +private fun RevealPanel(state: TypePracticeUiState, onContinue: () -> Unit, onOverride: () -> Unit) { + val diff = remember(state.revealedAnswer, state.lastTyped) { + AnswerDiff.diff(typed = state.lastTyped, expected = state.revealedAnswer) + } + val matchColor = RatingColors.Easy // got it right = mint + val missColor = RatingColors.Again // wrong/missing = pink + val typedMatchColor = MaterialTheme.colorScheme.onSurfaceVariant + + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Text("Correct answer", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(4.dp)) + Text( + buildAnnotatedString { + diff.expected.forEach { seg -> + when (seg.kind) { + AnswerDiff.Kind.Match -> withStyle(SpanStyle(color = matchColor)) { append(seg.text) } + AnswerDiff.Kind.Mismatch -> withStyle(SpanStyle(color = missColor, textDecoration = TextDecoration.Underline)) { append(seg.text) } + } + } + }, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + ) + if (state.lastTyped.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + Text("You typed", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(2.dp)) + Text( + buildAnnotatedString { + diff.typed.forEach { seg -> + when (seg.kind) { + AnswerDiff.Kind.Match -> withStyle(SpanStyle(color = typedMatchColor)) { append(seg.text) } + AnswerDiff.Kind.Mismatch -> withStyle(SpanStyle(color = missColor, textDecoration = TextDecoration.LineThrough)) { append(seg.text) } + } + } + }, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + } + Spacer(Modifier.height(16.dp)) + Button(onClick = onContinue, modifier = Modifier.fillMaxWidth().height(50.dp), shape = MaterialTheme.shapes.large) { Text("Continue") } + if (state.canOverride) { + Spacer(Modifier.height(4.dp)) + TextButton(onClick = onOverride, modifier = Modifier.fillMaxWidth()) { Text("I was right") } + } + } +} + +@Composable +private fun SessionReportView(report: SessionReport?, onRestart: () -> Unit, onDone: () -> Unit) { + val r = report ?: SessionReport(0, 0, 0, 0, 0) + Column( + Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("Practice complete", style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(24.dp)) + ReportRow("Cards", r.completed.toString()) + ReportRow("First-try accuracy", "${r.firstTryAccuracy}%") + ReportRow("Best combo", r.bestCombo.toString()) + ReportRow("Newly mastered", r.newlyMastered.toString()) + Spacer(Modifier.height(28.dp)) + Button(onClick = onRestart, modifier = Modifier.fillMaxWidth().height(50.dp), shape = MaterialTheme.shapes.large) { Text("Practice again") } + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = onDone, modifier = Modifier.fillMaxWidth().height(50.dp), shape = MaterialTheme.shapes.large) { Text("Done") } + } +} + +@Composable +private fun ReportRow(label: String, value: String) { + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text(value, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +private val previewCard = Card( + id = "c1", front = "¿Cómo estás?", back = "How are you?", deckId = "d1", + dateCreated = 0, lastModified = 0, fsrsDue = 0, fsrsState = CardState.New.value, +) + +private fun previewState( + current: Card? = previewCard, + input: String = "", + revealing: Boolean = false, + celebrating: Boolean = false, + finished: Boolean = false, + awaitingDirection: Boolean = false, + report: SessionReport? = null, + revealedAnswer: String = "", + lastTyped: String = "", + canOverride: Boolean = false, + direction: TypeDirection? = TypeDirection.TypeBack, +) = TypePracticeUiState( + loading = false, awaitingDirection = awaitingDirection, direction = direction, current = current, + input = input, revealing = revealing, revealedAnswer = revealedAnswer, lastTyped = lastTyped, + canOverride = canOverride, remaining = 3, total = 5, combo = 3, finished = finished, + report = report, celebrating = celebrating, +) + +@Composable +private fun PreviewWrap(state: TypePracticeUiState) { + AzriTheme { + TypePracticeContent( + state = state, + onChooseDirection = {}, onInput = {}, onSubmit = {}, onDontKnow = {}, + onContinue = {}, onOverride = {}, onRestart = {}, onDone = {}, + ) + } +} + +@Preview(name = "Type · prompt", showBackground = true) +@Composable +private fun TypePromptPreview() = PreviewWrap(previewState(input = "How are")) + +@Preview(name = "Type · prompt (type front)", showBackground = true) +@Composable +private fun TypeFrontPromptPreview() = PreviewWrap(previewState(input = "¿Cómo", direction = TypeDirection.TypeFront)) + +@Preview(name = "Type · celebrating", showBackground = true) +@Composable +private fun TypeCelebratingPreview() = PreviewWrap(previewState(input = "How are you?", celebrating = true)) + +@Preview(name = "Type · revealed (wrong)", showBackground = true) +@Composable +private fun TypeRevealPreview() = PreviewWrap( + previewState(revealing = true, revealedAnswer = "How are you?", lastTyped = "how is you", canOverride = true), +) + +@Preview(name = "Type · direction chooser", showBackground = true) +@Composable +private fun TypeDirectionChooserPreview() = PreviewWrap(previewState(awaitingDirection = true, current = null)) + +@Preview(name = "Type · report", showBackground = true) +@Composable +private fun TypeReportPreview() = PreviewWrap( + previewState(current = null, finished = true, report = SessionReport(completed = 12, firstTryCorrect = 9, firstTryAccuracy = 75, bestCombo = 5, newlyMastered = 3)), +) +``` + +- [ ] **Step 2: Verify it compiles + the whole suite + APK** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin :app:compileDebugAndroidTestKotlin :app:testDebugUnitTest :app:assembleDebug` +Expected: BUILD SUCCESSFUL; all unit tests pass. (If `Icons.Filled.LocalFireDepartment` is unresolved, confirm material-icons-extended is on the classpath — it is, since `Icons.Filled.School`/`Style`/`Keyboard` are already used; otherwise it's a typo.) + +- [ ] **Step 3: Commit** +```bash +git add app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt +git commit -m "Restyle Type Practice into the gamified split-zones layout" +``` + +--- + +## Final verification +- [ ] `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest :app:assembleDebug` → BUILD SUCCESSFUL, all unit tests green. +- [ ] No commit message mentions "claude" / carries an attribution trailer: `git log --format='%B' origin/main..HEAD | grep -i -E "claude|co-authored-by"` → no output. +- [ ] `git status --short` still shows only `?? docs/superpowers/plans/2026-06-04-realtime-study-queue.md` (the `.superpowers/` companion dir is gitignored). +- [ ] (Optional, emulator) Run a Type Practice session: progress bar fills as cards clear; the 🔥 combo chip climbs and pops, resets on a miss; a correct answer flashes the card mint with a ✓ then auto-advances with a slide; a wrong answer shows the pink char-diff; reduced-motion (system animations off) degrades gracefully. diff --git a/docs/superpowers/plans/2026-06-06-type-practice-result-card-restyle.md b/docs/superpowers/plans/2026-06-06-type-practice-result-card-restyle.md new file mode 100644 index 0000000..73e183e --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-type-practice-result-card-restyle.md @@ -0,0 +1,200 @@ +# Type Practice Result Card Restyle 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:** Restyle the Type Practice wrong-answer `ResultSheet` from a Duolingo-style edge-to-edge pink banner into Azri's own card aesthetic — a white hairline-bordered card with an `INCORRECT` status pill and color reserved for the diff glyphs. + +**Architecture:** Presentation-only change to `TypePracticeScreen.kt`. Extract the pill body from `DirectionPill` into a shared `StatusPill(text, color)` (so the periwinkle "TYPE THE BACK" pill and the new pink "INCORRECT" pill are the same component), then rewrite `ResultSheet`'s visual treatment. No ViewModel/domain/test/behavior change — same `AnimatedVisibility` rise, same `AnswerDiff` rendering, same Continue / "I was right" callbacks and `isNotBlank()` guard. + +**Tech Stack:** Kotlin, Jetpack Compose (Material3 + animation), Koin. + +**Verification reality:** No Compose UI-test target exists. Per task the gate is: `:app:compileDebugKotlin` compiles, `:app:testDebugUnitTest` stays green (these tests don't touch the screen), and the existing reveal `@Preview`s render. Final confirmation is a user on-device screenshot. + +All Gradle commands run from `/Users/astemirboziev/Developer/SimpleAnkiProject/azri_android` and MUST be prefixed with `export JAVA_HOME=/opt/homebrew/opt/openjdk &&`. + +**Commit rules:** No "claude" in commit messages; no Co-Authored-By/attribution trailer. Never `git add -A`; stage only the named file. Never stage `docs/superpowers/plans/2026-06-04-realtime-study-queue.md` or `.superpowers/`. + +--- + +## File Structure + +- **Modify:** `app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt` — the only file touched. Adds `StatusPill`, rewrites `DirectionPill` to delegate to it, and restyles `ResultSheet`. + +--- + +### Task 1: Restyle the result sheet into a status-pill card + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt` + +No import changes are required: the new code reuses APIs already imported, and the badge removal frees no imports (`Box`, `clip`, `CircleShape`, `background`, `Color`, `Icon`, `Icons.Default.Close` all remain used by `PromptCard`, the Scaffold content `Box`, the `TopAppBar`, and `ComboChip`). Do not add or remove any imports. + +- [ ] **Step 1: Extract `StatusPill` and make `DirectionPill` delegate to it** + +Find the current `DirectionPill` composable: + +```kotlin +@Composable +private fun DirectionPill(typeFront: Boolean) { + Surface(shape = RoundedCornerShape(50), color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)) { + Text( + if (typeFront) "TYPE THE FRONT" else "TYPE THE BACK", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + letterSpacing = 1.sp, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) + } +} +``` + +Replace it with these two composables (a shared `StatusPill` plus a thin `DirectionPill`): + +```kotlin +/** A small uppercase status chip (rounded, tinted by [color]) — used for the direction tag and the + * wrong-answer INCORRECT marker so they read as one component. */ +@Composable +private fun StatusPill(text: String, color: Color) { + Surface(shape = RoundedCornerShape(50), color = color.copy(alpha = 0.14f)) { + Text( + text, + style = MaterialTheme.typography.labelSmall, + color = color, + letterSpacing = 1.sp, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) + } +} + +@Composable +private fun DirectionPill(typeFront: Boolean) { + StatusPill(if (typeFront) "TYPE THE FRONT" else "TYPE THE BACK", MaterialTheme.colorScheme.primary) +} +``` + +- [ ] **Step 2: Restyle `ResultSheet`** + +Replace the entire `ResultSheet` composable (its kdoc comment through its closing brace) with: + +```kotlin +/** Wrong-answer result card: rises into the space freed by the dismissed keyboard, in Azri's card + * style (white surface + hairline border, an INCORRECT status pill, color only in the diff), + * showing the correct answer vs. what the user typed, with Continue / "I was right". */ +@Composable +private fun ResultSheet( + state: TypePracticeUiState, + onContinue: () -> Unit, + onOverride: () -> Unit, + modifier: Modifier = Modifier, +) { + val pink = RatingColors.Again + val mint = RatingColors.Easy + val typedMatch = MaterialTheme.colorScheme.onSurfaceVariant + val diff = remember(state.revealedAnswer, state.lastTyped) { + AnswerDiff.diff(typed = state.lastTyped, expected = state.revealedAnswer) + } + // Starts false then targets true, so the slide-in replays on every wrong answer. This relies on + // ResultSheet being unmounted between reveals (via AnswerBar's early-return), which re-inits the + // remembered state — keep that gating if this is ever refactored, or the entrance won't replay. + val enter = remember { MutableTransitionState(false) }.apply { targetState = true } + AnimatedVisibility( + visibleState = enter, + enter = slideInVertically(tween(250)) { it } + fadeIn(tween(250)), + modifier = modifier.fillMaxWidth(), + ) { + Surface(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.background) { + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp)) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = RoundedCornerShape(20.dp), + ) { + Column(Modifier.fillMaxWidth().padding(16.dp)) { + StatusPill("INCORRECT", pink) + Spacer(Modifier.height(10.dp)) + Text("Correct answer", style = MaterialTheme.typography.labelMedium, color = typedMatch) + Spacer(Modifier.height(4.dp)) + Text( + buildAnnotatedString { + diff.expected.forEach { seg -> + when (seg.kind) { + AnswerDiff.Kind.Match -> withStyle(SpanStyle(color = mint)) { append(seg.text) } + AnswerDiff.Kind.Mismatch -> withStyle(SpanStyle(color = pink, textDecoration = TextDecoration.Underline)) { append(seg.text) } + } + } + }, + style = MaterialTheme.typography.headlineSmall, + ) + if (state.lastTyped.isNotBlank()) { + Spacer(Modifier.height(10.dp)) + Text("You typed", style = MaterialTheme.typography.labelMedium, color = typedMatch) + Spacer(Modifier.height(2.dp)) + Text( + buildAnnotatedString { + diff.typed.forEach { seg -> + when (seg.kind) { + AnswerDiff.Kind.Match -> withStyle(SpanStyle(color = typedMatch)) { append(seg.text) } + AnswerDiff.Kind.Mismatch -> withStyle(SpanStyle(color = pink, textDecoration = TextDecoration.LineThrough)) { append(seg.text) } + } + } + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + Spacer(Modifier.height(12.dp)) + Button( + onClick = onContinue, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = MaterialTheme.shapes.large, + ) { Text("Continue") } + if (state.canOverride) { + Spacer(Modifier.height(4.dp)) + TextButton(onClick = onOverride, modifier = Modifier.fillMaxWidth()) { Text("I was right") } + } + } + } + } +} +``` + +This drops: the pink `0.08f` fill, the `topStart/topEnd`-only rounding, the edge-to-edge banner, the ✕ circle badge (`Row`/`Box`/`clip`/`background`/`Icon(Close)`), and the pink `titleSmall` heading. The diff content now sits in a white hairline-bordered card with an `INCORRECT` pill; Continue / "I was right" sit below the card. + +- [ ] **Step 3: Compile, run tests, assemble** + +Run: + +```bash +export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin :app:testDebugUnitTest :app:assembleDebug +``` + +Expected: `BUILD SUCCESSFUL`. Confirm no unused-import warnings introduced and no unresolved references (`StatusPill` is referenced by both `DirectionPill` and `ResultSheet`). + +- [ ] **Step 4: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt +git commit -m "Restyle Type Practice wrong-answer result into an Azri card" +``` + +--- + +## Self-Review + +**Spec coverage:** +- `StatusPill(text, color)` extracted; `DirectionPill` delegates (DRY) → Step 1. ✓ +- Contained white card: `surface` color + `outlineVariant` hairline border + `RoundedCornerShape(20.dp)`, inset via the outer Column padding → Step 2. ✓ +- `INCORRECT` pink pill (`RatingColors.Again`) at top-left → Step 2. ✓ +- Quiet "Correct answer" `labelMedium`/`onSurfaceVariant` label; expected diff `headlineSmall` (mint/pink-underline), left-aligned → Step 2. ✓ +- "You typed" line gated by `isNotBlank()` (blank-typed omits it) → Step 2. ✓ +- Continue (periwinkle primary) + "I was right" (when `canOverride`) below the card → Step 2. ✓ +- Removed pink fill, top-only rounding, edge-to-edge banner, ✕ badge, loud heading → Step 2. ✓ +- `AnimatedVisibility` rise and `remember`-keyed diff preserved → Step 2. ✓ +- No VM/domain/test change; unit tests stay green → Step 3 runs `:app:testDebugUnitTest`. ✓ +- FSRS-decoupling untouched (only one screen file changed). ✓ + +**Placeholder scan:** No TBD/TODO; every code step shows full code. ✓ + +**Type consistency:** `StatusPill(text: String, color: Color)` defined in Step 1 and called in Step 1 (`DirectionPill`) and Step 2 (`ResultSheet`) with matching argument types. `RatingColors.Again/Easy`, `AnswerDiff.diff(typed=, expected=)`, `AnswerDiff.Kind.Match/Mismatch`, `MaterialTheme.colorScheme.{surface,outlineVariant,onSurfaceVariant,background}`, `state.revealedAnswer/lastTyped/canOverride` all match the current file. No imports added/removed. ✓ diff --git a/docs/superpowers/plans/2026-06-06-type-practice-reveal-sheet.md b/docs/superpowers/plans/2026-06-06-type-practice-reveal-sheet.md new file mode 100644 index 0000000..bcfb9b2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-type-practice-reveal-sheet.md @@ -0,0 +1,319 @@ +# Type Practice Wrong-Answer Reveal Sheet 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:** On a wrong answer in Type Practice, drop the keyboard and raise a roomy pink "result sheet" (correct answer vs. what you typed) into the freed bottom half, so the comparison is fully legible. + +**Architecture:** Presentation-only change to `TypePracticeScreen.kt`. (1) A `LaunchedEffect(state.revealing)` clears the text field's focus when a wrong answer is revealed, which dismisses the IME; the existing `LaunchedEffect(state.cardTick)` re-focuses on advance, bringing the keyboard back. (2) `AnswerBar` branches at the top on `state.revealing`: when revealing it renders a new `ResultSheet` (no text field) holding the char-diff + Continue / "I was right"; otherwise it renders the persistent focused field + Check/Don't-know (or the "Correct!" celebrate text). The inline diff is removed from `PromptArea` and the old `RevealDiff` composable is deleted (its rendering moves into `ResultSheet`). + +**Tech Stack:** Kotlin, Jetpack Compose (Material3 + animation), Koin. No ViewModel/domain/test changes — the VM already exposes `revealing`, `revealedAnswer`, `lastTyped`, `canOverride`, `cardTick`. + +**Verification reality:** This codebase has no Compose UI-test target, so the gate for each task is: `:app:compileDebugKotlin` compiles, `:app:testDebugUnitTest` stays green (regression guard — these tests don't touch the screen), and the `@Preview`s render. Final on-device confirmation is a user screenshot. + +All Gradle commands run from `/Users/astemirboziev/Developer/SimpleAnkiProject/azri_android` and MUST be prefixed with `export JAVA_HOME=/opt/homebrew/opt/openjdk &&`. + +**Commit rules:** No "claude" in commit messages; no Co-Authored-By/attribution trailer. Never `git add -A`; stage only the files named in each task. Never stage `docs/superpowers/plans/2026-06-04-realtime-study-queue.md` or `.superpowers/`. + +--- + +## File Structure + +- **Modify:** `app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt` — the only file touched. Adds a focus-clear effect, restructures `AnswerBar`, adds `ResultSheet`, trims `PromptArea`, deletes `RevealDiff`, adds a preview. + +--- + +### Task 1: Drop the keyboard when a wrong answer is revealed + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt` + +Clearing focus from the `OutlinedTextField` dismisses the IME. We do this whenever `state.revealing` turns true. The existing `LaunchedEffect(state.cardTick)` already re-requests focus on advance, so the keyboard returns automatically on the next card. This task is independently observable: even before the sheet exists, the keyboard now drops on a wrong answer. + +- [ ] **Step 1: Add the `LocalFocusManager` import** + +In the import block (alphabetical, near the other `androidx.compose.ui.platform`/`focus` imports — place it after line 59 `import androidx.compose.ui.focus.focusRequester`), add: + +```kotlin +import androidx.compose.ui.platform.LocalFocusManager +``` + +- [ ] **Step 2: Acquire the focus manager and add the reveal effect** + +In `TypePracticeContent`, find these existing lines (around 126-127): + +```kotlin + val focus = remember { FocusRequester() } + LaunchedEffect(state.cardTick) { if (inSession) runCatching { focus.requestFocus() } } +``` + +Replace them with: + +```kotlin + val focus = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + LaunchedEffect(state.cardTick) { if (inSession) runCatching { focus.requestFocus() } } + // On a wrong answer, release focus so the IME slides away and the result sheet has room. + // On advance, cardTick changes and the effect above re-focuses, bringing the keyboard back. + LaunchedEffect(state.revealing) { if (state.revealing) focusManager.clearFocus(force = true) } +``` + +- [ ] **Step 3: Compile and run the regression test suite** + +Run: + +```bash +export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin :app:testDebugUnitTest +``` + +Expected: `BUILD SUCCESSFUL`. The unit tests are unaffected by this screen change and must remain green. + +- [ ] **Step 4: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt +git commit -m "Drop the keyboard when a Type Practice wrong answer is revealed" +``` + +--- + +### Task 2: Raise a result sheet in the bottom bar on reveal + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt` + +Replace the in-field reveal controls with a dedicated `ResultSheet` that occupies the bottom bar (no text field), and remove the inline diff from `PromptArea`. The diff rendering from the old `RevealDiff` moves into `ResultSheet`; `RevealDiff` is deleted. + +- [ ] **Step 1: Add the animation imports** + +In the import block, add these alongside the existing `androidx.compose.animation.*` imports (after line 3 `import androidx.compose.animation.AnimatedContent`): + +```kotlin +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.slideInVertically +``` + +(`fadeIn` and `tween` are already imported.) + +- [ ] **Step 2: Trim `PromptArea` — remove the inline diff** + +Find the current `PromptArea` body (around lines 250-273). Replace the whole function with this version, which drops the `if (state.revealing) { Spacer; RevealDiff(state) }` block: + +```kotlin +private fun PromptArea(state: TypePracticeUiState) { + val card = state.current ?: return + val typeFront = state.direction == TypeDirection.TypeFront + Column( + Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + AnimatedContent( + targetState = state.cardTick, + transitionSpec = { + (slideInHorizontally(tween(250)) { it / 3 } + fadeIn(tween(250))) togetherWith + (slideOutHorizontally(tween(200)) { -it / 3 } + fadeOut(tween(200))) + }, + label = "card", + ) { _ -> + PromptCard(card, typeFront, celebrating = state.celebrating) + } + } +} +``` + +- [ ] **Step 3: Delete the standalone `RevealDiff` composable** + +Find and delete the entire `RevealDiff` function (the block starting with the comment `/** The char-level diff shown in the upper zone ... */` and the `@Composable private fun RevealDiff(state: TypePracticeUiState) { ... }` that follows — currently lines 327-370). Its rendering is recreated inside `ResultSheet` in Step 5. + +- [ ] **Step 4: Restructure `AnswerBar` to branch on `revealing`** + +Replace the entire `AnswerBar` function (currently lines 372-432) with: + +```kotlin +/** The bottom thumb-rail. While typing/celebrating: a persistent (always-focused) input + actions, + * above the keyboard. On a wrong answer it is replaced by the ResultSheet (no field). */ +@Composable +private fun AnswerBar( + state: TypePracticeUiState, + focus: FocusRequester, + onInput: (String) -> Unit, + onSubmit: () -> Unit, + onDontKnow: () -> Unit, + onContinue: () -> Unit, + onOverride: () -> Unit, + modifier: Modifier = Modifier, +) { + if (state.revealing) { + ResultSheet(state = state, onContinue = onContinue, onOverride = onOverride, modifier = modifier) + return + } + Surface(modifier = modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.background) { + Column( + Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Always mounted + focused while typing/celebrating so the IME stays open; the VM ignores + // input while celebrating, so it is effectively read-only then. + OutlinedTextField( + value = state.input, + onValueChange = onInput, + modifier = Modifier.fillMaxWidth().focusRequester(focus), + singleLine = true, + label = { Text("Type the answer") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { onSubmit() }), + ) + Spacer(Modifier.height(10.dp)) + if (state.celebrating) { + Text("Correct!", style = MaterialTheme.typography.titleMedium, color = RatingColors.Easy) + Spacer(Modifier.height(58.dp)) + } else { + Button( + onClick = onSubmit, + enabled = state.input.isNotBlank(), + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = MaterialTheme.shapes.large, + ) { Text("Check") } + Spacer(Modifier.height(4.dp)) + TextButton(onClick = onDontKnow, modifier = Modifier.fillMaxWidth()) { Text("Don't know") } + } + } + } +} +``` + +- [ ] **Step 5: Add the `ResultSheet` composable** + +Immediately after the `AnswerBar` function, add: + +```kotlin +/** Wrong-answer result sheet: rises into the space freed by the dismissed keyboard, showing the + * correct answer vs. what the user typed (char-diff), with Continue / "I was right". */ +@Composable +private fun ResultSheet( + state: TypePracticeUiState, + onContinue: () -> Unit, + onOverride: () -> Unit, + modifier: Modifier = Modifier, +) { + val pink = RatingColors.Again + val mint = RatingColors.Easy + val typedMatch = MaterialTheme.colorScheme.onSurfaceVariant + val diff = remember(state.revealedAnswer, state.lastTyped) { + AnswerDiff.diff(typed = state.lastTyped, expected = state.revealedAnswer) + } + val enter = remember { MutableTransitionState(false) }.apply { targetState = true } + AnimatedVisibility( + visibleState = enter, + enter = slideInVertically(tween(250)) { it } + fadeIn(tween(250)), + modifier = modifier.fillMaxWidth(), + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = pink.copy(alpha = 0.08f), + border = BorderStroke(1.dp, pink.copy(alpha = 0.5f)), + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), + ) { + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(26.dp).clip(CircleShape).background(pink), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Default.Close, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp)) + } + Spacer(Modifier.width(8.dp)) + Text("Correct answer", style = MaterialTheme.typography.titleSmall, color = pink) + } + Spacer(Modifier.height(10.dp)) + Text( + buildAnnotatedString { + diff.expected.forEach { seg -> + when (seg.kind) { + AnswerDiff.Kind.Match -> withStyle(SpanStyle(color = mint)) { append(seg.text) } + AnswerDiff.Kind.Mismatch -> withStyle(SpanStyle(color = pink, textDecoration = TextDecoration.Underline)) { append(seg.text) } + } + } + }, + style = MaterialTheme.typography.headlineSmall, + ) + if (state.lastTyped.isNotBlank()) { + Spacer(Modifier.height(10.dp)) + Text("You typed", style = MaterialTheme.typography.labelMedium, color = typedMatch) + Spacer(Modifier.height(2.dp)) + Text( + buildAnnotatedString { + diff.typed.forEach { seg -> + when (seg.kind) { + AnswerDiff.Kind.Match -> withStyle(SpanStyle(color = typedMatch)) { append(seg.text) } + AnswerDiff.Kind.Mismatch -> withStyle(SpanStyle(color = pink, textDecoration = TextDecoration.LineThrough)) { append(seg.text) } + } + } + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + Spacer(Modifier.height(16.dp)) + Button( + onClick = onContinue, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = MaterialTheme.shapes.large, + ) { Text("Continue") } + if (state.canOverride) { + Spacer(Modifier.height(4.dp)) + TextButton(onClick = onOverride, modifier = Modifier.fillMaxWidth()) { Text("I was right") } + } + } + } + } +} +``` + +- [ ] **Step 6: Add a blank-typed reveal preview** + +After the existing `TypeRevealPreview` (around line 510-514), add a second reveal preview for the "Don't know" case (blank `lastTyped`, no override), which exercises the omitted "You typed" line: + +```kotlin +@Preview(name = "Type · revealed (blank)", showBackground = true) +@Composable +private fun TypeRevealBlankPreview() = PreviewWrap( + previewState(revealing = true, revealedAnswer = "How are you?", lastTyped = "", canOverride = false), +) +``` + +- [ ] **Step 7: Compile, run tests, assemble** + +Run: + +```bash +export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin :app:testDebugUnitTest :app:assembleDebug +``` + +Expected: `BUILD SUCCESSFUL`. Confirm no unresolved references (the deleted `RevealDiff` is no longer called from `PromptArea`; `ResultSheet` is called from `AnswerBar`). + +- [ ] **Step 8: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/feature/typepractice/TypePracticeScreen.kt +git commit -m "Raise a result sheet for Type Practice wrong answers" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Keyboard drops on reveal → Task 1 (`LaunchedEffect(state.revealing)` clears focus). ✓ +- Keyboard returns on advance → existing `LaunchedEffect(state.cardTick)` re-focus, unchanged. ✓ +- `AnswerBar` two-way branch (revealing → ResultSheet, else → field) → Task 2 Step 4. ✓ +- `ResultSheet`: ✕ badge + "Correct answer", expected diff (mint/pink-underline), "you typed" struck-through, Continue, "I was right" when `canOverride`, blank-typed omits the line → Task 2 Step 5. ✓ +- Diff removed from `PromptArea`; `RevealDiff` deleted → Task 2 Steps 2-3. ✓ +- Sheet entrance `slideInVertically + fadeIn` → Task 2 Step 5 (`AnimatedVisibility`). ✓ +- Previews (reveal with override + blank-typed) → existing `TypeRevealPreview` + Task 2 Step 6. ✓ +- No VM/domain/test change; unit tests stay green → verification steps run `:app:testDebugUnitTest`. ✓ +- FSRS-decoupling untouched (no scheduling/log code touched). ✓ + +**Placeholder scan:** No TBD/TODO; every code step shows full code. ✓ + +**Type consistency:** `ResultSheet(state, onContinue, onOverride, modifier)` defined in Step 5 matches its call in Step 4. `AnswerDiff.diff(typed=, expected=)`, `AnswerDiff.Kind.Match/Mismatch`, `RatingColors.Again/Easy`, `state.revealedAnswer/lastTyped/canOverride` all match the existing code read from the file. `MutableTransitionState`/`AnimatedVisibility`/`slideInVertically` imports added in Step 1. ✓ diff --git a/docs/superpowers/specs/2026-06-05-type-in-the-answer-design.md b/docs/superpowers/specs/2026-06-05-type-in-the-answer-design.md new file mode 100644 index 0000000..05a4b8a --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-type-in-the-answer-design.md @@ -0,0 +1,223 @@ +# Type-in-the-Answer (Type Practice Mode) — Design + +**Date:** 2026-06-05 +**Status:** Approved (design); pending implementation plan +**Branch:** `feature/type-practice-mode` (off `main`). +**Scope:** Phase 1 of 2. Phase 2 (typing diagnostics: trouble-words list + recognition–recall gap) +is a separate, stacked spec built on Phase 1's logs. + +## Goal + +Add a standalone **Type Practice** study mode: the user types each card's answer, it's auto-checked, +and wrong cards are retried until correct. It is **fully decoupled from FSRS** — it writes no +scheduler state and no review logs — and keeps its **own separate progress** (a per-deck mastery ring ++ an end-of-session report) derived from a new typing-log store. + +## Why decoupled (the core product decision) + +Typing produces an **objective** signal (the app *knows* whether you got it right), unlike FSRS +ratings, which are **self-assessed**. Keeping typing logs separate from FSRS is deliberate: it makes +the typing record a more trustworthy account of what the user can actually *produce*, and it is the +foundation for the Phase-2 "recognition–recall gap" (cards FSRS thinks are known but the user can't +type) — a diagnostic that is only possible *because* the two systems are not coupled. + +Trade-off accepted: practicing does **not** advance the FSRS schedule and does **not** count toward +the study streak / daily goal. This mode is positioned as *practice*, not *review*. (An opt-in +"apply this session to my schedule" bridge is explicitly deferred.) + +## Background (existing code this builds on) + +- **Card model** (`core/domain/model/DomainModels.kt`): flat `Card(front, back, image?, audio?, …, + isReverse, pairId)`. No Anki-style note-types, so "type the answer" must be a **study mode**, not a + per-card template. The `back` is always text (the image is the *front* prompt), so every card is + typeable; reverse cards are separate rows with swapped front/back. +- **Decoupled study precedent** (`feature/review/ReviewViewModel.kt`): the cram/Review mode already + snapshots a deck's cards once, applies `deck.reviewFilter` (Originals/Reverses/All) + optional + shuffle, and writes nothing back. Type Practice mirrors this skeleton and adds the typing loop. +- **Deck entry points** (`feature/deckdetail/DeckDetailScreen.kt`): a primary **Study** (FSRS) button + + an outlined **Review** (cram) button. Type Practice slots in as a third action here. +- **Append-only event store precedent** (`ReviewLogRepository` / `ReviewLogDao` / + `ReviewLogEntity`): append-only, `@Insert(onConflict = IGNORE)`, `dirty` flag for push, + `getAllIds()` for pull dedup, synced via `SyncManager` + `RemoteSyncSource` + `FirestoreSyncService`. + Typing logs are also append-only events and mirror this path exactly. +- **Pure-derivation precedent** (`StreakProvider` derives the streak from review logs; + `StreakReconciler` is a pure object). Typing mastery is derived the same way (pure functions over + typing logs), not stored as per-card state. + +## Decisions (from brainstorming) + +- **Separate mode, fully decoupled from FSRS.** No `SchedulingService` calls, no `cardRepository.save`, + no review-log writes. (Camp 2 / Quizlet "Write" model, not Anki's in-review typing.) +- **Card source / loop:** whole deck, **retry-until-correct**. Cards snapshot once, shuffled; respects + `deck.reviewFilter`. Cards with a blank `back` are skipped. +- **Answer matching:** **normalized, accents enforced** — case-insensitive, trims + collapses inner + whitespace, ignores surrounding punctuation, but `café` ≠ `cafe`. A manual **"I was right"** + override on wrong answers is always available (handles legitimate near-misses / synonyms). +- **Wrong-answer behavior:** **reveal, then requeue later.** A wrong (or "Don't know") first attempt + reveals the correct answer + the user's input, then the card returns later in the same session; the + user must type it correctly to clear it. Session ends when every card is cleared. +- **What's recorded:** the card's **first attempt** only (one log row per card per session) — its + correctness drives all stats/mastery. Requeued retries are pure session-loop mechanics and are not + persisted. An "I was right" override on the first attempt stores `correct = true`. +- **Mastery rule:** a card is **mastered** iff its **latest first-try was correct** (latest log per + card wins). Honestly regresses if missed in a later session. +- **Phase-1 progress surfaces:** end-of-session **report** + persistent per-deck **mastery ring**. +- **Entry point:** deck detail, new nav route `typePractice/{deckId}`. Deck-level only for v1. + +## Components + +### Pure domain (Android-free, unit-tested — the testable heart) + +**`core/domain/typing/AnswerMatcher.kt`** +- `normalize(s: String): String` — lowercase, `trim`, collapse internal whitespace to single spaces, + strip surrounding punctuation; **preserve accents/diacritics**. +- `matches(typed: String, expected: String): Boolean` — `normalize(typed) == normalize(expected)`. + +**`core/domain/typing/TypePracticeSession.kt`** — a pure reducer for the loop. Constructed with the +ordered card pool + the set of previously-mastered card ids (for "newly mastered"). Holds the +remaining/requeue queue and per-card first-try outcomes. Operations: +- `current`: the card being prompted (or null when finished). +- `submit(answer): SubmitResult` — on the **first** attempt for a card, records the (provisional, + in-memory) first-try outcome; returns `Correct` (advance) or `Wrong(expected)` (reveal). On a + **requeue** attempt, returns `Correct` (clear) or `Wrong(expected)` (re-reveal, stays queued) + without changing the recorded first-try outcome. +- `override()` — flips the current card's (still-provisional) first-try outcome to correct and clears + it (the "I was right" path). +- `continueAfterWrong()` — finalizes the wrong first-try outcome, requeues the current card to later + in the session, and advances. +- Each first-try outcome is held **in memory** by the session until finalized; the VM appends exactly + one `TypingLog` per card at finalization (see data flow), so the persisted `correct` already + reflects any override — no row updates against the append-only store. +- `report(): SessionReport(completed, firstTryCorrect, firstTryAccuracy, bestCombo, newlyMastered)`. + +**`core/domain/typing/TypingMastery.kt`** +- `latestPerCard(logs): Map` (latest by timestamp). +- `masteredCardIds(logs): Set` (latest first-try correct). +- `deckMastery(logs, deckCardIds: Set): DeckMastery(mastered: Int, total: Int)`. + +### Data layer (mirrors review logs) + +- **Domain:** `TypingLog(id: String = "", cardId: String = "", deckId: String = "", correct: Boolean, + typedText: String, timestamp: Long)` in `DomainModels.kt`. +- **`TypingLogEntity`** (`RoomEntities.kt`, table `typing_log`, `@PrimaryKey id`, indices on `cardId` + and `deckId`, `dirty: Boolean = true`). +- **`TypingLogDao`** (`dao/Daos.kt`): `@Insert(onConflict = IGNORE) insertAll`, `getDirty`, + `clearDirty(id)`, `getAllIds`, `observeAll()` (ORDER BY timestamp), `observeForDeck(deckId)`. +- **Mappers** (`TypingLogMappers.kt`): entity↔domain. +- **`TypingLogRepository(dao)`** (`Repositories.kt`): `append(log)`, `observeLogs()`, + `observeLogsForDeck(deckId)`. +- **`TypingMasteryProvider(typingLogRepository, cardRepository)`**: `observeDeckMastery(deckId): + Flow` = `combine(observeLogsForDeck, cardsForDeck) -> TypingMastery.deckMastery`. +- **`AzriDatabase`**: version **3 → 4**; add `TypingLogEntity` to `entities`, add `typingLogDao()`, + add **`MIGRATION_3_4`** (CREATE TABLE `typing_log` + the two indices; **no SQL `DEFAULT` clauses**; + verified char-for-char against the generated `AzriDatabase_Impl` schema). + +### Firestore sync (append-only, like review logs) + +- **`TypingLogDto`** (`FirestoreDtos.kt`): snake_case `@PropertyName`s; document path + `users/{uid}/typingLogs/{id}`; `fromDomain` / `toDomain`. +- **`RemoteSyncSource`**: `fetchTypingLogs(uid): List`, + `pushTypingLogs(uid, dtos)`. +- **`FirestoreSyncService`**: implement both against the `typingLogs` subcollection. +- **`SyncManager`**: add a `typingLogDao` constructor param (after `reviewLogDao`); push dirty rows + then `clearDirty`, and pull-union with `getAllIds` dedup — identical to the review-log steps. No + LWW (logs are immutable). + +### Feature layer (UI) + +- **`feature/typepractice/TypePracticeViewModel(deckId, cardRepository, deckRepository, + typingLogRepository, now, logManager)`**: in `load()`, snapshot deck cards (respect + `reviewFilter` + shuffle like `ReviewViewModel`), drop blank-back cards, read previously-mastered + ids from typing logs, construct `TypePracticeSession`. Exposes `TypePracticeUiState(loading, + prompt card, input, phase = Typing|Revealed, revealedAnswer, lastTypedText, remaining, finished, + report)`. Methods `onInput`, `onSubmit`, `onContinue`, `onOverride`, `onDontKnow`, `restart`. On + each **first** attempt, `typingLogRepository.append(...)`. Analytics events mirror the existing + `cram_session_start` style. +- **`feature/typepractice/TypePracticeScreen.kt`**: `TypePracticeScreen` (Koin VM) + + stateless `TypePracticeContent` + `SessionReport` composable. Top bar `"Type · N left"` with a + close action. Prompt panel renders the **front** (reusing `MediaImage` / `AudioPlayButton`); an + **auto-focused** `TextField` below with IME "Done" → submit (same autofocus approach as the card + editor). Revealed state shows the correct answer + the user's input with **Continue** and **I was + right** buttons. +- **Deck detail:** add a **Type** action button (next to Study/Review) and a **"X / N mastered"** + ring; `DeckDetailViewModel` reads `TypingMasteryProvider.observeDeckMastery(deckId)`. + +### Wiring + +- **`AzriNavHost`**: route `typePractice/{deckId}` → `TypePracticeScreen`; deck detail wires + `onTypePractice`. +- **Koin `AppModule`**: `single { TypingLogRepository(get()) }`, + `single { TypingMasteryProvider(get(), get()) }`, `viewModel { TypePracticeViewModel(...) }`; + expose `typingLogDao` from the database and add it to the `SyncManager` registration. + +## Data flow + +Launch `typePractice/{deckId}` → VM snapshots deck cards (filter + shuffle, drop blank backs) + reads +previously-mastered ids → builds `TypePracticeSession`. User types → `onSubmit` → `AnswerMatcher`: +- **Correct (first try)** → outcome finalized as correct → VM appends `TypingLog(correct = true)` → + advance. +- **Wrong (first try)** → reveal (no log yet; outcome held provisionally) → user taps **Continue** + (finalize wrong → VM appends `TypingLog(correct = false)` → requeue) or **I was right** (override → + finalize correct → VM appends `TypingLog(correct = true)` → card clears). +- Requeued card returns later → correct clears it; wrong re-reveals. Neither writes a new log (the + first-try outcome is already finalized and persisted). +Session end → `SessionReport`. Separately, deck detail's ring observes `TypingMasteryProvider`, which +recomputes mastered/total whenever typing logs change. + +**Logging invariant:** exactly **one** `TypingLog` per card per session, appended by the VM at the +moment the card's first attempt is *finalized* (correct-on-first-try, or after **Continue** / +**I was right** on a wrong first try). Because the write is deferred to finalization, the persisted +`correct` already reflects any override — there are no updates to the append-only store. + +## Error handling / edge cases + +- **Empty pool** (deck has no typeable cards / all blank backs) → immediately show an empty-session + state with a Done action; write nothing. +- **Blank input** submit → treated as a wrong first attempt (reveal path), not a crash. +- **"I was right" override** → authoritative; records `correct = true`. (No abuse mitigation in v1; an + `overridden` flag for honesty analytics is deferred to Phase 2 if wanted.) +- **Sync** → append-only, `@Insert IGNORE` + `getAllIds` dedup make append and pull idempotent; + offline appends flush on next sync via the `dirty` flag, exactly like review logs. +- **Deck deleted / card removed mid-life** → logs are historical events keyed by id; mastery + derivation intersects logs with *current* deck card ids, so removed cards drop out of the ring + naturally. + +## Testing + +All Gradle commands prefixed with `export JAVA_HOME=/opt/homebrew/opt/openjdk &&`, run from +`/Users/astemirboziev/Developer/SimpleAnkiProject/azri_android`. + +- **`AnswerMatcherTest`** (JVM): case-insensitivity; whitespace trim/collapse; surrounding punctuation + stripped; **accents enforced** (`café` ≠ `cafe`); blank/empty; exact match. +- **`TypePracticeSessionTest`** (JVM): first-try correct advances; wrong → reveal → requeue → card + returns; clearing needs a correct retype; **override** clears + records correct; combo tracking; + **newly-mastered** reporting; end-of-session stats (completed, first-try accuracy, best combo). +- **`TypingMasteryTest`** (JVM): latest-log-per-card wins (mastery **regresses** when the latest + first-try is wrong); deck mastered/total counts; empty-logs. +- **`TypingLogMappersTest`**: entity↔domain round-trip. +- **`TypePracticeViewModelTest`**: with a new `FakeTypingLogRepository` + existing fake card/deck + repos — `load` builds the session; a first attempt appends exactly one log; override path; finish + produces the report. Mirrors `StudyViewModelTest`. +- **Sync**: extend existing `SyncManager` coverage with typing-log push-dirty / pull-union dedup (or + compile-verified, matching how streak-state sync was validated). +- **Build gate:** `:app:compileDebugKotlin :app:testDebugUnitTest :app:assembleDebug` + + `:app:compileDebugAndroidTestKotlin` (since composable signatures change). + +## Out of scope (Phase 1) + +- **Phase 2 diagnostics** — trouble-words list, recognition–recall gap shelf (separate stacked spec). +- Char-level colored diff (wrong answers show the full correct answer + the user's input only). +- Folder / "all decks" Type entry (deck-level only; extendable later like Review). +- Home / deck-list mastery ring (deck-detail only for v1). +- Opt-in "apply this session to my FSRS schedule" bridge. +- Time-to-answer / WPM / closeness fields in the log (store just `correct` + `typedText`). +- Any streak / daily-goal credit for practice (deliberately none). +- A Settings toggle or selectable strictness (fixed: normalized + accents, with the manual override). +- Audio-dictation / TTS prompts. + +## Commit / process rules + +- No "claude" mention in commit messages; **no Co-Authored-By / attribution trailer**. +- Do **not** `git add` the unrelated untracked `docs/superpowers/plans/2026-06-04-realtime-study-queue.md`. +- PR body (when opened) includes the `🤖 Generated with [Claude Code](https://claude.com/claude-code)` + footer. diff --git a/docs/superpowers/specs/2026-06-06-type-practice-answer-diff-design.md b/docs/superpowers/specs/2026-06-06-type-practice-answer-diff-design.md new file mode 100644 index 0000000..cdce25a --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-type-practice-answer-diff-design.md @@ -0,0 +1,124 @@ +# Type Practice — Char-Level Answer Diff — Design + +**Date:** 2026-06-06 +**Status:** Approved (design); pending implementation plan +**Branch:** `feature/type-practice-mode` (extends the shipped-on-this-branch Type Practice feature). +**Parent spec:** `docs/superpowers/specs/2026-06-05-type-in-the-answer-design.md` (this pulls the +"char-level colored diff" item out of that spec's Phase-1 out-of-scope list). + +## Goal + +On a wrong typed answer, replace the plain reveal ("Correct answer: X" + "You typed: Y") with a +**character-level colored diff**: show the full correct answer with the characters the user missed +marked, and the user's input with the wrong/extra characters marked. This gives precise, at-a-glance +feedback on exactly what differed — the single most-requested polish for a type-the-answer mode. + +## Background (existing code this builds on) + +- **`feature/typepractice/TypePracticeScreen.kt` → `RevealPanel`**: currently renders the correct + answer (`state.revealedAnswer`, in the primary color) and, when the user typed something, + `"You typed: ${state.lastTyped}"` (in the error color). It also hosts the **Continue** and + **"I was right"** buttons. +- **`TypePracticeUiState`** already carries both strings the diff needs: `revealedAnswer` (the + direction-correct expected answer the VM fills on a wrong submit) and `lastTyped` (the user's + input; empty for the "Don't know" path). +- **`core/domain/typing/AnswerMatcher`**: the answer check is **case-insensitive** (lowercases, + trims, collapses whitespace, strips leading/trailing punctuation) but **accent-sensitive** + (`café` ≠ `cafe`). The reveal only appears when this check fails. +- The codebase has a precedent for small pure domain units in `core/domain/typing/` (`AnswerMatcher`, + `TypePracticeSession`, `TypingMastery`, `TypeDirection`), each Android-free and unit-tested. + +## Decision (from brainstorming) + +- **Diff matching mirrors the answer check: case-insensitive, accent-sensitive.** A character counts + as matching when it is equal ignoring case (`Char.lowercaseChar()` equality), so `H` vs `h` is NOT + flagged, but `é` vs `e` IS. The diff therefore highlights exactly what made the answer wrong. + (Punctuation/whitespace are not specially aligned — they are rare in answers that reach the reveal, + because the matcher already accepts edge-punctuation/whitespace-only differences.) +- **Two displays only** (matching the request): the full correct answer (diff-colored) + the user's + input (diff-colored, hidden when empty). + +## Components + +### 1. `core/domain/typing/AnswerDiff.kt` (new, pure) + +The single unit holding all diff logic. Android-free, unit-tested. + +```kotlin +object AnswerDiff { + enum class Kind { Match, Mismatch } + data class Segment(val text: String, val kind: Kind) + data class Result(val expected: List, val typed: List) + + fun diff(typed: String, expected: String): Result +} +``` + +- Computes a **longest-common-subsequence (LCS)** alignment over the two character sequences, using + **case-insensitive** character equality (`this == other || lowercaseChar() == other.lowercaseChar()`). +- From the LCS, marks each character of `expected` and of `typed` as on-the-subsequence (`Match`) or + not (`Mismatch`), then **coalesces** consecutive same-kind characters into `Segment`s. +- Semantics: in `expected`, `Mismatch` = a character the user **missed**; in `typed`, `Mismatch` = + a **wrong/extra** character the user typed. +- Complexity is `O(n·m)` over the two answer strings — negligible for flashcard answers. + +### 2. `feature/typepractice/TypePracticeScreen.kt` → `RevealPanel` (modified) + +- Compute the diff once per reveal: `val diff = remember(state.revealedAnswer, state.lastTyped) { AnswerDiff.diff(typed = state.lastTyped, expected = state.revealedAnswer) }`. +- Read theme colors in composable scope (not inside the builder), then render two `Text`s built with + `buildAnnotatedString` + `withStyle(SpanStyle(...))`: + - **Correct answer** (`titleLarge`, centered): `Match` runs in `colorScheme.primary`; `Mismatch` + runs in `colorScheme.error` with `TextDecoration.Underline` (the chars you missed). + - **Your input** (`bodyMedium`, centered, shown only when `state.lastTyped.isNotBlank()`): `Match` + runs in `colorScheme.onSurfaceVariant`; `Mismatch` runs in `colorScheme.error` with + `TextDecoration.LineThrough` (the chars that were wrong/extra). +- A small `"Correct answer"` / `"You typed"` label precedes each (as today). The **Continue** and + **"I was right"** buttons are unchanged. + +**No other change.** `TypePracticeUiState`, `TypePracticeViewModel`, the session, the matcher, the +typing log, and mastery are all untouched. The diff is pure presentation computed from existing state. + +## Data flow + +Wrong submit (already implemented) → VM sets `revealedAnswer` (direction-correct expected) + +`lastTyped` in `TypePracticeUiState` → `RevealPanel` computes `AnswerDiff.diff(lastTyped, +revealedAnswer)` and renders the two annotated strings. No new state, no new event. + +## Error handling + +- **Empty typed** ("Don't know"): `typed` is empty → `expected` renders entirely as `Mismatch` + (whole answer underlined as missed); the "your input" line is hidden by the existing + `isNotBlank()` guard. No crash. +- **Identical strings** can't reach the reveal (the matcher would have accepted them), so the diff is + always over a genuine mismatch. +- Pure and bounded: no nullability, no I/O, no Android dependency — no new failure modes. + +## Testing + +Gradle commands prefixed with `export JAVA_HOME=/opt/homebrew/opt/openjdk &&`, run from +`/Users/astemirboziev/Developer/SimpleAnkiProject/azri_android`. + +- **`AnswerDiffTest`** (JVM, the bulk of coverage): + - **missing char** — `diff("helo", "hello")` → `expected` = `["hel"=Match, "l"=Mismatch, "o"=Match]`, + `typed` = `["helo"=Match]`. + - **extra char** — `diff("helllo", "hello")` → `expected` fully `Match`, `typed` has a `Mismatch`. + - **empty typed** — `diff("", "cat")` → `expected` = `["cat"=Mismatch]`, `typed` empty. + - **case-insensitive match** — `diff("HELLO", "hello")` → both fully `Match` (no false flag). + - **accent mismatch** — `diff("cafe", "café")` → `expected` = `["caf"=Match, "é"=Mismatch]`, + `typed` = `["caf"=Match, "e"=Mismatch]`. + - **no common chars** — `diff("xyz", "abc")` → both entirely `Mismatch`. +- **`RevealPanel`**: verified by compile; the existing `TypeRevealPreview` (which supplies a wrong + typed string) now renders the diff. No new preview required, though a preview already exercises it. +- **Build gate:** `:app:compileDebugKotlin :app:testDebugUnitTest` → BUILD SUCCESSFUL. + +## Out of scope + +- Word-level diffing or "did you mean" typo suggestions. +- Diffing/annotating the prompt side or the report. +- Any change to `AnswerMatcher` normalization, the session, the typing log, mastery, or the VM. +- Special punctuation/whitespace alignment in the diff (only case is ignored; accents are kept). + +## Commit / process rules + +- No "claude" mention in commit messages; no Co-Authored-By / attribution trailer. +- Do not `git add` the unrelated untracked `docs/superpowers/plans/2026-06-04-realtime-study-queue.md`. diff --git a/docs/superpowers/specs/2026-06-06-type-practice-gamified-redesign-design.md b/docs/superpowers/specs/2026-06-06-type-practice-gamified-redesign-design.md new file mode 100644 index 0000000..e109419 --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-type-practice-gamified-redesign-design.md @@ -0,0 +1,189 @@ +# Type Practice — Gamified Redesign — Design + +**Date:** 2026-06-06 +**Status:** Approved (design); pending implementation plan +**Branch:** `feature/type-practice-mode` (extends the Type Practice feature on this branch). +**Parent specs:** `2026-06-05-type-in-the-answer-design.md`, `2026-06-06-type-practice-answer-diff-design.md`. + +## Goal + +Redesign the in-session Type Practice screen into a focused, "Duolingo-style" gamified layout: a +top progress bar + live combo chip, a prompt hero card pinned up top, a bottom-anchored answer zone, +a mint **success flash** on correct answers, and per-card motion. It fixes the current screen's +dead-space/balance problem and adds lightweight game feel, while staying fully **FSRS-decoupled** and +reusing the app's existing rating-color palette. + +## Background (current state this builds on) + +- **`feature/typepractice/TypePracticeScreen.kt`**: today a single top-aligned `Column` — "PROMPT" + label, prompt text, audio button, an `OutlinedTextField`, a "Check" button, "Don't know" — leaving + the bottom ~half of the screen empty. The wrong-answer `RevealPanel` shows the char-diff (mint/pink + per this redesign) + Continue + "I was right". A direction chooser precedes card 1; a session + report follows. +- **`TypePracticeViewModel` / `TypePracticeUiState`**: drives the pure `TypePracticeSession`; on a + **correct** submit it currently calls `renderAdvance()` immediately. `UiState` has `current`, + `input`, `revealing`, `revealedAnswer`, `lastTyped`, `canOverride`, `remaining`, `finished`, + `report`, `cardTick`, `awaitingDirection`, `direction`. +- **`TypePracticeSession`**: tracks `combo` (consecutive first-try correct, resets to 0 on a wrong + submit) and `bestCombo`, but only `bestCombo` surfaces (via `report()`); `remaining` is exposed. +- **Rating colors** (currently inline literals in `StudyScreen.kt`, iOS-derived): Again = + `0xFFFF2D55` (pink), Hard = `0xFFFF9500` (orange), Good = `0xFF5856D6` (indigo), Easy = + `0xFF00C7BE` (mint). + +## Decisions (from brainstorming, incl. the visual companion) + +- **Layout = "split zones"** (prompt pinned upper, answer pinned to the bottom thumb-rail). +- **Reward chip = live combo** (🔥 N), reusing the session's `combo`; resets to 0 on a miss. +- **Correct-answer feedback = "quick flash, auto-advance"**: mint glow + ✓ on the card, the input + shown in mint, the combo pops +1, then **auto-advance after ~400ms** (no tap). +- **Colors reuse the rating palette, no new token**: **correct/success = mint `0xFF00C7BE`** (Easy), + **wrong/incorrect = pink `0xFFFF2D55`** (Again, replacing the diff's generic `error` color). The + 🔥 combo flame stays amber. Extract the 4 rating colors into a shared `RatingColors`. +- **Per-card motion = slide-in + fade** (~250ms); reduced-motion respected via the system animation + scale (Compose honors it automatically). +- **Scope = in-session states only** (prompt + reveal). Chooser & report keep their layout. + +## Components + +### 1. `ui/theme/RatingColors.kt` (new — DRY the palette) + +A single source of truth for the iOS-derived rating colors: +```kotlin +object RatingColors { + val Again = Color(0xFFFF2D55) // wrong / incorrect + val Hard = Color(0xFFFF9500) + val Good = Color(0xFF5856D6) + val Easy = Color(0xFF00C7BE) // correct / success +} +``` +`StudyScreen.kt`'s `RatingButton` literals are replaced with `RatingColors.*`. Type Practice uses +`RatingColors.Easy` (success) and `RatingColors.Again` (wrong/diff). + +### 2. `core/domain/typing/TypePracticeSession.kt` (small) + +Expose the **live combo** for the chip (currently only `bestCombo` surfaces): +```kotlin +val currentCombo: Int get() = combo +``` +No behavior change — `combo` is already maintained (incremented on first-try correct, zeroed on any +wrong submit). + +### 3. `feature/typepractice/TypePracticeViewModel.kt` + `TypePracticeUiState` + +Add to `TypePracticeUiState`: +- `combo: Int = 0` — the live combo for the chip. +- `total: Int = 0` — the session pool size, for the progress bar. +- `celebrating: Boolean = false` — true during the ~400ms mint success flash. + +`startSession(...)`: record `total = pool.size`; seed `combo`/`total` into the first render. + +`renderAdvance()`: also set `combo = session.currentCombo`, `total = total`, `celebrating = false`. +Progress is derived in the UI as `if (total > 0) (total - remaining).toFloat() / total else 0f`. + +**Correct submit — the celebrating phase** (the one real behavior change): `session.submit` clears +the card immediately, so `onSubmit` captures `session.current` **before** calling `submit`, then on +`Correct` shows the celebrating state, waits, and advances: +```kotlin +fun onSubmit() { + if (_uiState.value.celebrating) return // ignore taps during the flash + val typed = _uiState.value.input + val answered = session.current // capture BEFORE submit advances the queue + when (val r = session.submit(typed)) { + SubmitResult.Correct -> { + logManager.track(Event.Answered(true)) + _uiState.value = _uiState.value.copy( + celebrating = true, + current = answered, // keep showing the just-answered card + input = typed, // shown in mint, disabled + combo = session.currentCombo, // popped +1 + revealing = false, + ) + viewModelScope.launch { + delay(CELEBRATE_MS) // ~400ms + renderAdvance() // shows session.current (next) or finishes + if (session.isFinished) logComplete() + } + } + is SubmitResult.Wrong -> { /* unchanged: reveal, combo already 0 in session */ } + } +} +``` +`CELEBRATE_MS` is a tunable constant (~400). `onInput`/`onSubmit`/`onDontKnow` early-return while +`celebrating` (mirroring the existing `revealing` / `isInitialized` guards). Wrong / `onContinue` / `onOverride` / `onDontKnow` +paths are unchanged except they also refresh `combo` (which the session has zeroed on a wrong +first-try). `now`/dispatcher stays injectable so tests drive virtual time. + +### 4. `feature/typepractice/TypePracticeScreen.kt` (major restructure, presentation only) + +- **Top bar:** `✕` close (left) · a thin **progress bar** (`LinearProgressIndicator`, animated fill, + mint while celebrating else primary) · a **combo chip** (right): muted at 0 (space reserved, no + layout shift), amber 🔥 N with a scale-pop on increment at ≥1. +- **Prompt hero card** (upper): a rounded surface card with the **direction pill** ("TYPE THE BACK" + / "TYPE THE FRONT" from `state.direction`), the **large prompt text** (non-typed side), and a + **big circular audio button** when the card has audio (both directions), reusing `AudioPlayButton`; + the front image shows on the prompt only in `TypeBack`. While `celebrating`: mint border/glow + a + ✓ badge. +- **Bottom-anchored answer zone:** the `OutlinedTextField` (auto-focused on `cardTick`; mint + the + typed text + disabled while celebrating), the primary **Check** (enabled on non-blank, hidden + during celebrate/reveal), and **Don't know**. When `state.revealing`: the existing `RevealPanel` + (char-diff now in `RatingColors.Again`/mint + Continue + "I was right"). +- **Per-card transition:** wrap the prompt card in `AnimatedContent` keyed on `state.cardTick` + (slide-in + fade); Compose honors the system animation scale, so reduced-motion is respected. +- **Previews:** prompt (TypeBack + TypeFront), celebrating, reveal, report, direction chooser. + +## Data flow + +Correct submit → VM captures the answered card → `celebrating` UiState (mint glow, ✓, combo +1, +progress unchanged until advance) → `delay(~400ms)` → `renderAdvance()` shows the next card (slide-in) +or the report; progress fills by `(total − remaining)/total`. Wrong submit → reveal (pink diff) in +the bottom zone, combo chip shows 0. Combo and progress are read from the session on every render. + +## Error handling / edge cases + +- **Last card correct:** after the celebrate delay, `session.isFinished` → `renderAdvance()` shows + the report and `logComplete()` fires (once, after the delay). +- **Close (✕) mid-celebrate:** `onDone` pops back; the pending `delay` coroutine is cancelled with + `viewModelScope`. No double advance. +- **Input gating:** `onSubmit`/`onInput`/`onDontKnow` no-op while `celebrating` (mirrors the existing + `revealing`/`isInitialized` guards), so a double-submit during the flash can't skip a card. +- **Combo at 0:** chip is muted and reserves its width (no layout shift when it appears). +- **Reduced motion:** the slide/pop/progress animations collapse automatically when the system + animation scale is 0; the ~400ms success hold is functional feedback (kept) — it's a pause, not a + decorative animation. +- **`total == 0`** (empty pool): progress = 0; the session finishes immediately (existing behavior). + +## Testing + +Gradle prefixed with `export JAVA_HOME=/opt/homebrew/opt/openjdk &&`, from +`/Users/astemirboziev/Developer/SimpleAnkiProject/azri_android`. + +- **`TypePracticeSessionTest`**: `currentCombo` reflects live consecutive first-try correct and + resets to 0 on a wrong submit. +- **`TypePracticeViewModelTest`** (virtual time via `StandardTestDispatcher` + `advanceTimeBy`): + - a correct submit enters `celebrating = true` with the just-answered card, `combo` popped, and + one log appended; after `advanceTimeBy(CELEBRATE_MS)` it advances to the next card with + `celebrating = false`. + - `total` is the pool size; progress derivation `(total - remaining)/total` is correct after a + clear. + - the last-card-correct path lands on `finished`/report after the delay. + - input is ignored while `celebrating`. + - (existing correct-path tests updated to drive virtual time.) +- **Screen**: compile + previews (prompt both directions, celebrating, reveal, report, chooser). + Reduced-motion / one-handed reach validated on the emulator (optional). +- **`StudyScreen` regression**: still compiles after switching to `RatingColors`; rating buttons + render the same colors. + +## Out of scope + +- The direction chooser & session-report **layouts** (report may reference `RatingColors`, no + restructure). +- The char-diff **algorithm** (only its colors change to mint/pink). +- Any persisted state, points/score economy, global-streak display, FSRS coupling, sync, matcher, + logs, or mastery change. +- Haptics / sound effects. + +## Commit / process rules + +- No "claude" mention in commit messages; no Co-Authored-By / attribution trailer. +- Do not `git add` the unrelated untracked `docs/superpowers/plans/2026-06-04-realtime-study-queue.md`, + nor the gitignored `.superpowers/`. diff --git a/docs/superpowers/specs/2026-06-06-type-practice-result-card-restyle-design.md b/docs/superpowers/specs/2026-06-06-type-practice-result-card-restyle-design.md new file mode 100644 index 0000000..4eb4528 --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-type-practice-result-card-restyle-design.md @@ -0,0 +1,137 @@ +# Type Practice — Result Card Restyle — Design + +**Date:** 2026-06-06 +**Status:** Approved (design); pending implementation plan +**Branch:** `feature/type-practice-mode`. +**Parent specs:** `2026-06-06-type-practice-reveal-sheet-design.md`, +`2026-06-06-type-practice-gamified-redesign-design.md`. + +## Goal + +Restyle the wrong-answer `ResultSheet` from the Duolingo-style edge-to-edge pink banner into Azri's +own restrained card aesthetic. Same content and behavior — only the visual treatment changes. + +## Background (current state this builds on) + +`ResultSheet` in `TypePracticeScreen.kt` (added by the reveal-sheet work) currently renders, inside +the Scaffold `bottomBar` while `state.revealing`: + +- an `AnimatedVisibility` (`slideInVertically { it } + fadeIn`, 250ms) wrapping +- a `Surface` with `RatingColors.Again.copy(alpha = 0.08f)` fill, a `BorderStroke(1.dp, Again 0.5f)`, + `RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)`, edge-to-edge `fillMaxWidth`, +- a header `Row`: a 26dp **✕ circle badge** (`Again` background, white `Icons.Default.Close`) + + "Correct answer" `titleSmall` in pink, +- the **expected** char-diff (`headlineSmall`; matches `RatingColors.Easy`, mismatches `Again` + + underline), +- when `state.lastTyped.isNotBlank()`: a "You typed" label + the **typed** diff (matches + `onSurfaceVariant`, mismatches `Again` + line-through), +- a full-width **Continue** `Button` and, when `state.canOverride`, an **"I was right"** `TextButton`. + +The app's design language (from `ui/theme/Color.kt` / `Theme.kt`): periwinkle primary `SAPrimary` +`#8299E6`; white card surfaces (`surface`) with ~12% hairline `outlineVariant` borders; elevated +`surfaceVariant` `#F2F2F4`; soft periwinkle container `#E6EAFB`; text `#262626`/`#666666`. The +**prompt hero card** (`PromptCard`) is the canonical card: `Surface(color = surface, +border = BorderStroke(1.dp, outlineVariant), shape = RoundedCornerShape(20.dp))`. The **`DirectionPill`** +is a soft periwinkle pill: `Surface(shape = RoundedCornerShape(50), color = primary.copy(alpha = 0.14f))` +holding `labelSmall` text in `primary`, `letterSpacing = 1.sp`, padding `h = 10.dp, v = 4.dp`. + +## Decision (from brainstorming, incl. the visual companion) + +**Option B — "status-pill card".** The result lives in a contained white hairline-bordered card (the +prompt-card look), color reserved for meaning (the diff glyphs) plus one small **pink `INCORRECT` +pill** — the same pill component as the periwinkle `DirectionPill`, in the wrong-answer color. +Rejected: A (no status accent — risks reading ambiguous) and C (left-edge stripe — weaker tie to an +existing pattern). + +## Components (presentation-only — `TypePracticeScreen.kt` only) + +### 1. `StatusPill(text: String, color: Color)` — extracted, shared (DRY) + +Extract the pill body currently inlined in `DirectionPill` into a reusable composable: + +```kotlin +@Composable +private fun StatusPill(text: String, color: Color) { + Surface(shape = RoundedCornerShape(50), color = color.copy(alpha = 0.14f)) { + Text( + text, + style = MaterialTheme.typography.labelSmall, + color = color, + letterSpacing = 1.sp, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) + } +} +``` + +`DirectionPill` is rewritten to delegate (behavior-identical): + +```kotlin +@Composable +private fun DirectionPill(typeFront: Boolean) = + StatusPill(if (typeFront) "TYPE THE FRONT" else "TYPE THE BACK", MaterialTheme.colorScheme.primary) +``` + +### 2. `ResultSheet` — restyled + +Keep the `AnimatedVisibility(slideInVertically { it } + fadeIn, 250ms)` rise and the +`remember(revealedAnswer, lastTyped)` diff. Replace the banner `Surface` + header with: + +- An outer `Surface(color = colorScheme.background)` (the bar background), `Column` padded + `horizontal = 20.dp, vertical = 12.dp`, containing: + - **The card:** `Surface(color = colorScheme.surface, border = BorderStroke(1.dp, + colorScheme.outlineVariant), shape = RoundedCornerShape(20.dp), fillMaxWidth)`, inner `Column` + padded `16.dp`, left-aligned: + - `StatusPill("INCORRECT", RatingColors.Again)`, + - `Spacer(10.dp)`, "Correct answer" `labelMedium` in `onSurfaceVariant`, + - `Spacer(4.dp)`, the **expected** diff `headlineSmall` (matches `RatingColors.Easy`, mismatches + `RatingColors.Again` + `TextDecoration.Underline`) — left-aligned (no `textAlign`), + - when `state.lastTyped.isNotBlank()`: `Spacer(10.dp)`, "You typed" `labelMedium` + `onSurfaceVariant`, `Spacer(2.dp)`, the **typed** diff `bodyMedium` (matches `onSurfaceVariant`, + mismatches `RatingColors.Again` + `TextDecoration.LineThrough`). + - `Spacer(12.dp)`, the full-width **Continue** `Button` (default = periwinkle primary, + `height = 50.dp`, `shape = shapes.large`, `onContinue`), + - when `state.canOverride`: `Spacer(4.dp)`, **"I was right"** `TextButton` (`onOverride`). + +**Removed:** the pink `0.08f` fill, the `topStart/topEnd`-only rounding, the edge-to-edge banner, the +✕ circle badge (`Box`/`clip`/`background`/`Icon(Close)`), and the pink `titleSmall` heading. + +## Data flow + +Unchanged. `state.revealing` → `ResultSheet` mounts → slide-in → user reads → Continue/"I was right" +→ VM advances. Color now derives only from the diff glyphs and the `INCORRECT` pill. + +## Error handling / edge cases + +- **`lastTyped` blank (Don't know):** card omits the "You typed" line (existing `isNotBlank()` guard). +- **Long answers:** left-aligned diff in a padded card wraps naturally (the banner centered it). +- **`canOverride` false:** no "I was right" button (existing guard). +- All keyboard-drop / advance / celebrate behavior is untouched (separate code paths). + +## Testing + +Gradle prefixed with `export JAVA_HOME=/opt/homebrew/opt/openjdk &&`, from +`/Users/astemirboziev/Developer/SimpleAnkiProject/azri_android`. + +- **Unit tests:** unchanged (no VM/domain change). `:app:testDebugUnitTest` stays green. +- **Compile + previews:** `:app:compileDebugKotlin`; the existing `TypeRevealPreview` (override, + non-blank typed) and `TypeRevealBlankPreview` (blank typed) now render the restyled card. +- **On-device:** user screenshot confirms the result reads as an Azri card (white, hairline border, + `INCORRECT` pill, periwinkle Continue), not the pink banner. +- **Regression:** `DirectionPill` still renders the periwinkle "TYPE THE BACK"/"TYPE THE FRONT" pill + identically (now via `StatusPill`); the prompt/celebrate paths are unchanged. + +## Out of scope + +- The correct/celebrate flow, the prompt card, the keyboard-drop behavior, the combo chip, progress. +- The `AnswerMatcher` and `AnswerDiff` algorithm (only where its output is styled). +- The session report and direction chooser. +- Any persisted state, FSRS coupling, sync, logs, or mastery change (Type Practice stays + FSRS-decoupled). + +## Commit / process rules + +- No "claude" in commit messages; no Co-Authored-By / attribution trailer. +- Never `git add -A`. Do not stage `docs/superpowers/plans/2026-06-04-realtime-study-queue.md` nor + `.superpowers/`. +- Gradle commands prefixed with `export JAVA_HOME=/opt/homebrew/opt/openjdk &&`. diff --git a/docs/superpowers/specs/2026-06-06-type-practice-reveal-sheet-design.md b/docs/superpowers/specs/2026-06-06-type-practice-reveal-sheet-design.md new file mode 100644 index 0000000..a4ec7e3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-type-practice-reveal-sheet-design.md @@ -0,0 +1,138 @@ +# Type Practice — Wrong-Answer Reveal Sheet — Design + +**Date:** 2026-06-06 +**Status:** Approved (design); pending implementation plan +**Branch:** `feature/type-practice-mode` (extends the Type Practice gamified redesign on this branch). +**Parent specs:** `2026-06-06-type-practice-gamified-redesign-design.md`, `2026-06-06-type-practice-answer-diff-design.md`. + +## Goal + +Make the wrong-answer **reveal** state legible. Today the keyboard is pinned up for the whole +session, so the content zone between the top bar and the bottom answer bar is short; the prompt hero +card consumes it and the char-diff comparison (correct answer vs. what you typed) gets crammed or +pushed out of view. Redesign the reveal so that on a wrong answer the keyboard **drops** and a +bottom **result sheet** rises into the freed space, giving the comparison room to read. + +## Background (current state this builds on) + +- **`TypePracticeScreen.kt`** (after the gamified redesign): a `Scaffold` with `topBar` (✕ + progress + + combo chip), a `content` `PromptArea` (centered prompt hero card via `AnimatedContent(cardTick)`, + with `RevealDiff` appended below it while `state.revealing`), and a `bottomBar` `AnswerBar` + (`Modifier.imePadding()`) holding a **persistent, always-focused** `OutlinedTextField` plus a + contextual block: `revealing` → Continue (+ "I was right" when `canOverride`); `celebrating` → + "Correct!" text; else → Check + Don't know. A single `FocusRequester` is re-requested via + `LaunchedEffect(state.cardTick)`. +- **The persistent-field invariant** (from the keyboard-flicker fix): the field stays mounted and + focused so the IME never collapses between answers. This was needed because the field previously + unmounted on *every* answer — including correct ones — causing the keyboard to drop and bounce + back. The invariant only matters while the keyboard *should* stay up. +- **`TypePracticeViewModel` / `TypePracticeUiState`** already expose everything this redesign needs: + `revealing`, `revealedAnswer`, `lastTyped`, `canOverride`, `cardTick`, `current`, `celebrating`, + `direction`. **No VM or domain change is required.** +- **`AnswerDiff`** renders the char-level diff; **`RatingColors`**: `Easy` = mint `0xFF00C7BE` + (matches), `Again` = pink `0xFFFF2D55` (misses / wrong). + +## Decision (from brainstorming, incl. the visual companion) + +**Option B — "keyboard drops, result sheet rises".** On a wrong answer, release the field's focus so +the IME slides away; the bottom bar transforms into a pink **result sheet** that occupies the freed +bottom half of the screen. The keyboard returns on advance to the next card. Rejected: A (card morphs +in place — keeps the keyboard but the comparison stays cramped) and C (compact banner above the +keyboard — keyboard frozen but smallest text). B wins because legibility is the entire purpose of the +reveal, and the keyboard only ever moves on a *wrong* answer — the moment the user slows down to +study. Correct answers keep the keyboard pinned for a fast typing rhythm. + +## Components (presentation-only — `TypePracticeScreen.kt` only) + +### 1. `AnswerBar` — two-way branch + +Replace the "persistent field + contextual block" structure with a top-level branch on +`state.revealing`: + +- **`revealing` → `ResultSheet(state)`** — a pink-tinted `Surface` (full bottom-bar width), **no text + field**. Contents: + - a small circular ✕ badge (`RatingColors.Again` background) + "Correct answer" heading. + - the **expected** diff: `AnswerDiff.diff(typed = state.lastTyped, expected = state.revealedAnswer)` + rendered with matches in `RatingColors.Easy`, misses in `RatingColors.Again` + underline + (the existing `RevealDiff` expected styling, moved here). + - when `state.lastTyped.isNotBlank()`: a "you typed" line, misses struck-through in + `RatingColors.Again` (existing `RevealDiff` typed styling). + - a full-width primary **Continue** (`onContinue`); when `state.canOverride`, an **"I was right"** + text/secondary button (`onOverride`) — same callbacks the current reveal branch uses. +- **else (typing or celebrating) → the persistent focused `OutlinedTextField`** + the existing + contextual block (`celebrating` → "Correct!" + spacer; else → Check enabled-on-non-blank + + Don't know). Unchanged from today. + +The field unmounting while `revealing` is intentional and consistent with the persistent-field +invariant: it is *what* dismisses the IME, and the field only needs to persist while the keyboard +should stay up (typing / celebrating), not during the reveal pause. + +### 2. `PromptArea` — drop the inline diff + +Remove the `if (state.revealing) { Spacer; RevealDiff(state) }` block from `PromptArea`; the diff now +lives in `ResultSheet`. `PromptArea` just renders the prompt hero card (`AnimatedContent(cardTick)`) +as today. The old `RevealDiff` composable is removed (its rendering is reused inside `ResultSheet`). + +### 3. Keyboard drive + +- `LaunchedEffect(state.revealing)`: when it becomes `true`, dismiss the IME via + `focusManager.clearFocus(force = true)` (and/or `keyboardController?.hide()`); when it becomes + `false`, do nothing here — the existing `LaunchedEffect(state.cardTick)` re-requests focus as the + next card's field remounts, bringing the keyboard back. +- Keep the existing `runCatching { focus.requestFocus() }` guard for the re-focus on advance (the + field remounts the same frame `cardTick` changes; `runCatching` tolerates a not-yet-attached + requester). If on-device testing shows the keyboard fails to return, add a one-frame + `withFrameNanos`/short `delay` before `requestFocus()`. + +### 4. Motion + +The `ResultSheet` enters with `slideInVertically { it } + fadeIn` (rising from the bottom) so it +reads as a sheet coming up as the keyboard leaves. Compose honors the system animation scale, so +reduced-motion is respected automatically. + +## Data flow + +Wrong submit → VM sets `revealing = true`, `revealedAnswer`, `lastTyped`, `canOverride` (unchanged) → +`LaunchedEffect(revealing)` clears focus → IME slides down, `imePadding()` collapses, the bottom bar +settles to the screen edge and swaps to `ResultSheet` (slide-in) → user reads the comparison → +Continue/"I was right" → VM advances, `cardTick` ticks, `revealing = false` → field remounts, +`LaunchedEffect(cardTick)` re-focuses → keyboard slides back up for the next card. + +## Error handling / edge cases + +- **`lastTyped` blank (Don't know):** `ResultSheet` shows the correct answer and omits the "you typed" + line (existing `isNotBlank()` guard). +- **Last card wrong → Continue:** advance lands on `finished`/report; no field in the tree, keyboard + stays down. No re-focus. +- **Re-focus race on advance:** mitigated by the existing `runCatching` guard; fallback is a + one-frame delay (see §3). +- **Close (✕) during reveal:** `onDone` pops back; nothing pending to cancel. +- **Correct path:** untouched — focus retained, keyboard stays, mint flash + auto-advance. + +## Testing + +Gradle prefixed with `export JAVA_HOME=/opt/homebrew/opt/openjdk &&`, from +`/Users/astemirboziev/Developer/SimpleAnkiProject/azri_android`. + +- **Unit tests:** unchanged — there is no VM/domain change. `:app:testDebugUnitTest` must stay green. +- **Compile + previews:** `:app:compileDebugKotlin`; add/keep a reveal preview that renders + `ResultSheet` (TypeBack, `canOverride = true`, non-blank `lastTyped`) and a blank-typed variant. +- **On-device:** user screenshot confirms the keyboard drops on a wrong answer, the result sheet is + fully legible (correct answer + you-typed both visible), and the keyboard returns on the next card. +- **Regression:** the correct/celebrate flow still keeps the keyboard pinned (no flicker); the report + and direction chooser are unchanged. + +## Out of scope + +- The correct/celebrate flow, the combo chip, the progress bar, the prompt card layout. +- The `AnswerMatcher`, the `AnswerDiff` algorithm (only where its output is rendered moves). +- The session report and direction chooser layouts. +- Any persisted state, FSRS coupling, sync, logs, or mastery change. (Type Practice stays + FSRS-decoupled: zero scheduling/review-log writes.) + +## Commit / process rules + +- No "claude" mention in commit messages; no Co-Authored-By / attribution trailer. +- Never `git add -A`. Do not `git add` the untracked + `docs/superpowers/plans/2026-06-04-realtime-study-queue.md` nor the gitignored `.superpowers/`. +- Gradle commands prefixed with `export JAVA_HOME=/opt/homebrew/opt/openjdk &&`.