Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
caec69f
Add type-in-the-answer (Type Practice mode) design spec
Jun 5, 2026
26d3a03
Add implementation plan for Type Practice mode (Phase 1)
Jun 5, 2026
1500f4c
Add AnswerMatcher for typed-answer comparison
Jun 5, 2026
c343fd7
Add typing_logs table, entity, DAO, mappers, and v4 migration
Jun 5, 2026
b433602
Add TypingMastery derivation from typing logs
Jun 5, 2026
4b4915d
Add TypingLogRepository and TypingMasteryProvider
Jun 5, 2026
c693c74
Add TypePracticeSession retry-until-correct state machine
Jun 5, 2026
e0d9eae
Correct bestCombo expectation in Type Practice plan (Task 5)
Jun 5, 2026
d03427c
Sync typing logs to Firestore (append-only union)
Jun 5, 2026
434106a
Add TypePracticeViewModel driving the session and logging
Jun 5, 2026
3a7f7a0
Add Type Practice screen with reveal and session report
Jun 5, 2026
e66c771
Add Type Practice entry point and mastery ring to deck detail
Jun 5, 2026
db28e01
Match mastery ring denominator to the Type Practice study pool
Jun 6, 2026
77cc92c
Add type-direction choice to the Type Practice session and view model
Jun 6, 2026
c2f080f
Add direction chooser and dual-direction prompt to the Type Practice …
Jun 6, 2026
839bcad
Add char-level answer-diff design spec for Type Practice
Jun 6, 2026
3fde8f1
Add implementation plan for Type Practice char-level answer diff
Jun 6, 2026
16c1d80
Add AnswerDiff for character-level answer comparison
Jun 6, 2026
920a5cb
Render char-level diff in the Type Practice wrong-answer reveal
Jun 6, 2026
e5e0825
Add gamified Type Practice redesign design spec
Jun 6, 2026
3471d8d
Add implementation plan for the gamified Type Practice redesign
Jun 6, 2026
45edf2c
Extract shared RatingColors palette
Jun 6, 2026
41829e1
Expose live combo from TypePracticeSession
Jun 6, 2026
f389844
Add combo, progress total, and the celebrating success phase to Type …
Jun 6, 2026
ca2d7eb
Restyle Type Practice into the gamified split-zones layout
Jun 6, 2026
6c43b98
Lengthen Type Practice success flash to 700ms
Jun 6, 2026
2647be6
Lengthen Type Practice success flash to 900ms
Jun 6, 2026
724c8dc
Keep keyboard persistent and pin the answer bar above it in Type Prac…
Jun 6, 2026
8aa3a11
Add design spec for Type Practice wrong-answer reveal sheet
Jun 6, 2026
ce2d26a
Add implementation plan for Type Practice reveal sheet
Jun 6, 2026
71b23e1
Drop the keyboard when a Type Practice wrong answer is revealed
Jun 6, 2026
e39c0db
Raise a result sheet for Type Practice wrong answers
Jun 6, 2026
4574c9d
Add design spec for Type Practice result card restyle
Jun 6, 2026
8b15d44
Add implementation plan for Type Practice result card restyle
Jun 6, 2026
da2bbb0
Restyle Type Practice wrong-answer result into an Azri card
Jun 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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(),
)
}
}
19 changes: 17 additions & 2 deletions app/src/main/java/nart/simpleanki/core/data/local/AzriDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -21,6 +22,7 @@ abstract class AzriDatabase : RoomDatabase() {
abstract fun folderDao(): FolderDao
abstract fun reviewLogDao(): ReviewLogDao
abstract fun streakStateDao(): StreakStateDao
abstract fun typingLogDao(): TypingLogDao
}

/**
Expand Down Expand Up @@ -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`)")
}
}
14 changes: 14 additions & 0 deletions app/src/main/java/nart/simpleanki/core/data/local/RoomEntities.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */

Expand Down Expand Up @@ -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,
)
23 changes: 23 additions & 0 deletions app/src/main/java/nart/simpleanki/core/data/local/dao/Daos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -99,6 +100,28 @@ interface ReviewLogDao {
fun observeAll(): Flow<List<ReviewLogEntity>>
}

@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<TypingLogEntity>)

@Query("SELECT * FROM typing_logs WHERE dirty = 1")
suspend fun getDirty(): List<TypingLogEntity>

@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<String>

@Query("SELECT * FROM typing_logs ORDER BY timestamp")
fun observeAll(): Flow<List<TypingLogEntity>>

@Query("SELECT * FROM typing_logs WHERE deckId = :deckId ORDER BY timestamp")
fun observeForDeck(deckId: String): Flow<List<TypingLogEntity>>
}

@Dao
interface StreakStateDao {
@Query("SELECT * FROM streak_state WHERE id = 'current'")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -128,6 +130,22 @@ class ReviewLogRepository(
fun observeLogs(): Flow<List<ReviewLog>> = 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<List<TypingLog>> = dao.observeAll().map { rows -> rows.map { it.toDomain() } }

fun observeLogsForDeck(deckId: String): Flow<List<TypingLog>> =
dao.observeForDeck(deckId).map { rows -> rows.map { it.toDomain() } }
}

class StreakStateRepository(
private val dao: StreakStateDao,
private val now: () -> Long = { System.currentTimeMillis() },
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DeckMastery> =
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -43,6 +44,12 @@ class FirestoreSyncService(
override suspend fun pushReviewLogs(uid: String, dtos: List<ReviewLogDto>) =
push(uid, "reviewLogs", dtos) { it.id }

override suspend fun fetchTypingLogs(uid: String): List<TypingLogDto> =
col(uid, "typingLogs").get().await().toObjects(TypingLogDto::class.java)

override suspend fun pushTypingLogs(uid: String, dtos: List<TypingLogDto>) =
push(uid, "typingLogs", dtos) { it.id }

override suspend fun fetchStreakState(uid: String): StreakStateDto? =
col(uid, "streakState").document("current").get().await().toObject(StreakStateDto::class.java)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -23,6 +24,9 @@ interface RemoteSyncSource {
suspend fun fetchReviewLogs(uid: String): List<ReviewLogDto>
suspend fun pushReviewLogs(uid: String, dtos: List<ReviewLogDto>)

suspend fun fetchTypingLogs(uid: String): List<TypingLogDto>
suspend fun pushTypingLogs(uid: String, dtos: List<TypingLogDto>)

suspend fun fetchStreakState(uid: String): StreakStateDto?
suspend fun pushStreakState(uid: String, dto: StreakStateDto)
}
17 changes: 16 additions & 1 deletion app/src/main/java/nart/simpleanki/core/data/sync/SyncManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,14 +23,15 @@ 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(
private val folderDao: FolderDao,
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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading