Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -12,21 +12,30 @@ import org.junit.Rule
import org.junit.Test

class PaywallContentTest {
@get:Rule val rule = createComposeRule()
@get:Rule
val rule = createComposeRule()

// FakeEntitlementRepository lives in unit-test sources (src/test/) and is not visible
// from androidTest sources. Plans are inlined here to match DEFAULT_PLANS exactly.
private val loaded = PaywallUiState(
plans = listOf(
PlanOption(PremiumTier.Annual, "azri_premium", "annual", "tokA", "$19.99", 19_990_000L),
PlanOption(PremiumTier.Monthly, "azri_premium", "monthly", "tokM", "$2.99", 2_990_000L),
PlanOption(PremiumTier.Lifetime, "azri_premium_lifetime", null, null, "$49.99", 49_990_000L),
PlanOption(
PremiumTier.Lifetime,
"azri_premium_lifetime",
null,
null,
"$99.99",
49_990_000L
),
),
selected = PremiumTier.Annual,
loading = false,
)

@Test fun showsPlans_andTitle() {
@Test
fun showsPlans_andTitle() {
rule.setContent { PaywallContent(state = loaded) }
rule.onNodeWithText("Unlock Cloud Sync").assertIsDisplayed()
rule.onNodeWithText("Annual").assertIsDisplayed()
Expand All @@ -35,22 +44,31 @@ class PaywallContentTest {
rule.onNodeWithText("Continue").assertIsDisplayed()
}

@Test fun tappingPlan_selectsIt() {
@Test
fun tappingPlan_selectsIt() {
var picked: PremiumTier? = null
rule.setContent { PaywallContent(state = loaded, onSelect = { picked = it }) }
rule.onNodeWithText("Lifetime").performClick()
assertEquals(PremiumTier.Lifetime, picked)
}

@Test fun continueAndRestore_fireCallbacks() {
var bought = false; var restored = false
rule.setContent { PaywallContent(state = loaded, onPurchase = { bought = true }, onRestore = { restored = true }) }
@Test
fun continueAndRestore_fireCallbacks() {
var bought = false
var restored = false
rule.setContent {
PaywallContent(
state = loaded,
onPurchase = { bought = true },
onRestore = { restored = true })
}
rule.onNodeWithText("Continue").performClick()
rule.onNodeWithText("Restore purchase").performClick()
assertTrue(bought && restored)
}

@Test fun plansUnavailable_showsRetry_andFiresCallback() {
@Test
fun plansUnavailable_showsRetry_andFiresCallback() {
var retried = false
rule.setContent {
PaywallContent(
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.first
import kotlinx.coroutines.flow.map
import nart.simpleanki.core.domain.streak.Streak
import nart.simpleanki.core.domain.streak.StreakCalculator
import nart.simpleanki.core.domain.streak.localEpochDay
import java.util.TimeZone

/** Derives the study [Streak] from the review logs. Stateless — pure derivation, nothing stored. */
class StreakProvider(
private val reviewLogRepository: ReviewLogRepository,
private val now: () -> Long = { System.currentTimeMillis() },
private val timeZone: TimeZone = TimeZone.getDefault(),
) {
/** Live streak for the home header — reacts to new review logs. */
fun observeStreak(): Flow<Streak> =
reviewLogRepository.observeLogs().map { logs ->
val days = logs.mapTo(mutableSetOf()) { localEpochDay(it.review, timeZone) }
StreakCalculator.compute(days, localEpochDay(now(), timeZone))
}

/**
* Streak treating today as studied — for the post-session summary, so it's correct even though
* the per-rating log append is fire-and-forget and may not have landed yet.
*/
suspend fun streakIncludingToday(): Streak {
val today = localEpochDay(now(), timeZone)
val days = reviewLogRepository.observeLogs().first()
.mapTo(mutableSetOf()) { localEpochDay(it.review, timeZone) }
.apply { add(today) }
return StreakCalculator.compute(days, today)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package nart.simpleanki.core.domain.streak

import java.util.Calendar
import java.util.TimeZone

/**
* Civil-day number for [millis] in [timeZone] — a DST-safe day index where consecutive calendar
* dates always differ by exactly 1. Reads the local Y/M/D, then rebuilds them as a UTC midnight and
* divides by a day, so 23h/25h DST days don't shift the index. (minSdk 24 rules out java.time.)
*/
fun localEpochDay(millis: Long, timeZone: TimeZone = TimeZone.getDefault()): Long {
val local = Calendar.getInstance(timeZone).apply { timeInMillis = millis }
val utcMidnight = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply {
clear()
set(local.get(Calendar.YEAR), local.get(Calendar.MONTH), local.get(Calendar.DAY_OF_MONTH))
}
return utcMidnight.timeInMillis / 86_400_000L
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package nart.simpleanki.core.domain.streak

/** A study streak in days. */
data class Streak(val current: Int, val longest: Int)

object StreakCalculator {
/**
* [reviewDays] = civil-day indices on which >=1 review happened; [today] = today's civil-day index.
* Pure (no timezone logic — bucket with [localEpochDay] first).
* - current: if the user studied today, or studied yesterday (the streak is still alive through
* today), the length of the consecutive run ending there; otherwise 0 (hard reset).
* - longest: the longest consecutive run anywhere in the set.
*/
fun compute(reviewDays: Set<Long>, today: Long): Streak {
if (reviewDays.isEmpty()) return Streak(0, 0)

var longest = 1
var run = 1
var prev: Long? = null
for (day in reviewDays.toSortedSet()) {
run = if (prev != null && day == prev + 1) run + 1 else 1
if (run > longest) longest = run
prev = day
}

val anchor = when {
reviewDays.contains(today) -> today
reviewDays.contains(today - 1) -> today - 1
else -> return Streak(0, longest)
}
var current = 0
var day = anchor
while (reviewDays.contains(day)) {
current++
day--
}
return Streak(current, longest)
}
}
6 changes: 5 additions & 1 deletion app/src/main/java/nart/simpleanki/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import nart.simpleanki.core.data.repository.CardRepository
import nart.simpleanki.core.data.repository.DeckRepository
import nart.simpleanki.core.data.repository.FolderRepository
import nart.simpleanki.core.data.repository.ReviewLogRepository
import nart.simpleanki.core.data.repository.StreakProvider
import nart.simpleanki.core.data.settings.DataStoreSettingsRepository
import nart.simpleanki.core.data.settings.SettingsRepository
import nart.simpleanki.core.data.sync.FirestoreSyncService
Expand Down Expand Up @@ -148,6 +149,7 @@ val appModule = module {
single { DeckRepository(get()) }
single { CardRepository(get()) }
single { ReviewLogRepository(get()) }
single { StreakProvider(get()) }

// Sync
single<RemoteSyncSource> { FirestoreSyncService(get()) }
Expand Down Expand Up @@ -197,6 +199,7 @@ val appModule = module {
deckRepository = get(),
settingsRepository = get(),
reviewLogRepository = get(),
streakProvider = get(),
logManager = get(),
)
}
Expand All @@ -216,7 +219,8 @@ val appModule = module {
deckRepository = get(),
folderRepository = get(),
settingsRepository = get(),
entitlementRepository = get()
entitlementRepository = get(),
streakProvider = get()
)
}
viewModel { DailyGoalViewModel(settingsRepository = get()) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import nart.simpleanki.core.domain.fsrs.QueueSortOrder
import nart.simpleanki.core.domain.model.ColorOption
import nart.simpleanki.ui.components.AzriCard
Expand Down Expand Up @@ -124,6 +125,22 @@ fun StudyQueueContent(
topBar = {
TopAppBar(
title = { Text("Today", fontWeight = FontWeight.Bold) },
actions = {
if (state.currentStreak > 0) {
Row(
modifier = Modifier.padding(end = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("🔥", fontSize = 18.sp)
Text(
state.currentStreak.toString(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
Expand Down Expand Up @@ -653,6 +670,7 @@ private fun StudyQueuePreview() {
QueueCardItem("c2", "mitochondria", "Biology", null),
),
goalTotal = 30, studiedToday = 7,
currentStreak = 7,
),
onStudyAll = {},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ package nart.simpleanki.feature.queue

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import nart.simpleanki.core.data.repository.CardRepository
import nart.simpleanki.core.data.repository.DeckRepository
import nart.simpleanki.core.data.repository.FolderRepository
import nart.simpleanki.core.data.repository.StreakProvider
import nart.simpleanki.core.billing.EntitlementRepository
import nart.simpleanki.core.billing.Entitlements
import nart.simpleanki.core.data.settings.SettingsRepository
Expand Down Expand Up @@ -68,6 +70,8 @@ data class StudyQueueUiState(
val dailyGoalEnabled: Boolean = false,
val goalTotal: Int = 0,
val studiedToday: Int = 0,
val currentStreak: Int = 0,
val longestStreak: Int = 0,
val sortOrder: QueueSortOrder = QueueSortOrder.DueDate,
/**
* Whether the user owns any (non-deleted) card at all — a *lifetime* signal, not a "today"
Expand All @@ -93,10 +97,11 @@ class StudyQueueViewModel(
folderRepository: FolderRepository,
private val settingsRepository: SettingsRepository,
private val entitlementRepository: EntitlementRepository,
private val streakProvider: StreakProvider,
private val now: () -> Long = { System.currentTimeMillis() },
) : ViewModel() {

val uiState: StateFlow<StudyQueueUiState> =
private val baseState: Flow<StudyQueueUiState> =
combine(
cardRepository.observeAllCards().withDueTicks(now),
deckRepository.observeDecks(),
Expand Down Expand Up @@ -176,6 +181,11 @@ class StudyQueueViewModel(
hasAnyCards = hasAnyCards,
),
)
}

val uiState: StateFlow<StudyQueueUiState> =
combine(baseState, streakProvider.observeStreak()) { base, streak ->
base.copy(currentStreak = streak.current, longestStreak = streak.longest)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ private val RatingColors = mapOf(
Rating.Easy to Color(0xFF00C7BE),
)

/** Study "session complete" summary — mirrors iOS SessionSummaryView (streak omitted). */
/** Study "session complete" summary — mirrors iOS SessionSummaryView (with streak badge). */
@Composable
fun SessionSummary(state: StudyUiState, onDone: () -> Unit) {
val accuracy = sessionAccuracy(state.ratingCounts)
Expand Down Expand Up @@ -107,6 +107,11 @@ fun SessionSummary(state: StudyUiState, onDone: () -> Unit) {
RatingDistributionBar(state.ratingCounts)
}

if (state.currentStreak > 0) {
Spacer(Modifier.height(24.dp))
StreakBadge(current = state.currentStreak, longest = state.longestStreak)
}

Spacer(Modifier.weight(1f))

Button(
Expand All @@ -132,6 +137,26 @@ private fun SessionStatsRow(reviewed: Int, accuracy: Int, durationLabel: String)
}
}

@Composable
private fun StreakBadge(current: Int, longest: Int) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"🔥 $current day${if (current == 1) "" else "s"} streak",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color(0xFFFF9500),
)
if (longest > current) {
Spacer(Modifier.height(4.dp))
Text(
"Longest: $longest",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

@Composable
private fun StatItem(icon: ImageVector, iconColor: Color, value: String, label: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Expand Down Expand Up @@ -198,6 +223,7 @@ private fun SessionSummaryGoodPreview() {
state = StudyUiState(
loading = false, finished = true, completed = 25, durationMillis = 312_000,
ratingCounts = mapOf(Rating.Again to 4, Rating.Hard to 6, Rating.Good to 10, Rating.Easy to 5),
currentStreak = 7, longestStreak = 12,
),
onDone = {},
)
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/nart/simpleanki/feature/study/StudyViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ 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.ReviewLogRepository
import nart.simpleanki.core.data.repository.StreakProvider
import nart.simpleanki.core.data.settings.SettingsRepository
import nart.simpleanki.core.data.settings.fsrsParameters
import nart.simpleanki.core.domain.fsrs.IntervalFormatter
Expand All @@ -34,6 +35,8 @@ data class StudyUiState(
val finished: Boolean = false,
/** Wall-clock millis from session start to finish; 0 until the session finishes, then stamped once. */
val durationMillis: Long = 0,
val currentStreak: Int = 0,
val longestStreak: Int = 0,
)

/**
Expand All @@ -51,6 +54,7 @@ class StudyViewModel(
private val settingsRepository: SettingsRepository,
private val reviewLogRepository: ReviewLogRepository,
private val now: () -> Long = { System.currentTimeMillis() },
private val streakProvider: StreakProvider = StreakProvider(reviewLogRepository, now),
private val logManager: LogManager = LogManager(emptyList()),
) : ViewModel() {

Expand Down Expand Up @@ -95,6 +99,7 @@ class StudyViewModel(
finished = queue.isEmpty(),
durationMillis = if (queue.isEmpty()) now() - sessionStartMillis else 0,
)
if (queue.isEmpty()) refreshSummaryStreak(includingToday = false)
logManager.track(Event.ReviewSessionStart(deckId, folderId))
}

Expand All @@ -106,6 +111,11 @@ class StudyViewModel(
.mapValues { (_, dueMillis) -> IntervalFormatter.format(dueMillis - nowMillis) }
}

private fun refreshSummaryStreak(includingToday: Boolean) = viewModelScope.launch {
val s = if (includingToday) streakProvider.streakIncludingToday() else streakProvider.observeStreak().first()
_uiState.value = _uiState.value.copy(currentStreak = s.current, longestStreak = s.longest)
}

fun onReveal() {
if (_uiState.value.current != null) {
_uiState.value = _uiState.value.copy(isRevealed = true, showFlipHint = false)
Expand Down Expand Up @@ -133,6 +143,7 @@ class StudyViewModel(
finished = next == null,
durationMillis = if (next == null) ratedAt - sessionStartMillis else prev.durationMillis,
)
if (next == null) refreshSummaryStreak(includingToday = true)
logManager.track(Event.CardRated(rating))
if (next == null) logManager.track(Event.ReviewSessionComplete(prev.completed + 1))
}
Expand Down
Loading