diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3fb6a37..39e8b7a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -52,12 +52,15 @@ android { dependencies { implementation(project(":core:designsystem")) + implementation(project(":core:data")) + implementation(project(":core:datastore")) implementation(project(":domain:cycle")) implementation(project(":domain:prediction")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.activity.compose) implementation(libs.androidx.navigation.compose) implementation(libs.kotlinx.coroutines.core) diff --git a/app/src/main/kotlin/dev/privacyllc/period/di/DataModule.kt b/app/src/main/kotlin/dev/privacyllc/period/di/DataModule.kt new file mode 100644 index 0000000..e2abb3a --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/di/DataModule.kt @@ -0,0 +1,72 @@ +package dev.privacyllc.period.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dev.privacyllc.period.core.data.CycleData +import dev.privacyllc.period.core.data.CycleRepository +import dev.privacyllc.period.core.datastore.UserPreferencesRepository +import dev.privacyllc.period.domain.prediction.BaselinePredictionEngine +import dev.privacyllc.period.domain.prediction.PredictionEngine +import java.time.Clock +import javax.inject.Singleton + +/** + * Where the app learns what a file path is, and the only place it does. + * + * Note what is absent: no `PeriodDatabase`, no DAO, no Room import anywhere in + * this module or anywhere above it. `CycleData.repository` hands back a + * repository and keeps the storage to itself — see + * docs/architecture/README.md. + */ +@Module +@InstallIn(SingletonComponent::class) +object DataModule { + + /** + * Singleton because Room and DataStore both are: two instances over one + * file is a corruption bug that only shows up under concurrency. + */ + @Provides + @Singleton + fun cycleRepository( + @ApplicationContext context: Context, + engine: PredictionEngine, + clock: Clock, + ): CycleRepository = CycleData.repository(context, engine, clock) + + @Provides + @Singleton + fun preferencesDataStore(@ApplicationContext context: Context): DataStore = + PreferenceDataStoreFactory.create { + context.preferencesDataStoreFile("user_preferences") + } + + @Provides + @Singleton + fun userPreferencesRepository(store: DataStore) = UserPreferencesRepository(store) + + /** + * Batch 02 replaces this binding, and only this binding. + * + * The rest of the app depends on [PredictionEngine], never on an + * implementation — so swapping in the real engine is one line here, and the + * §51 acceptance tests can run against both to show the replacement is + * better rather than merely different. + */ + @Provides + @Singleton + fun predictionEngine(): PredictionEngine = BaselinePredictionEngine() + + /** Injected rather than read from the environment, so §50's date edge cases stay testable. */ + @Provides + @Singleton + fun clock(): Clock = Clock.systemDefaultZone() +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt new file mode 100644 index 0000000..810bbf3 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt @@ -0,0 +1,235 @@ +package dev.privacyllc.period.feature.today + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.privacyllc.period.domain.cycle.PeriodRecord +import dev.privacyllc.period.domain.prediction.ConfidenceLabel +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +/** + * A working surface, and deliberately not the designed one. + * + * PRODUCT_PLAN.md §21–§22 specify the Today screen: a hero number, six dynamic + * states, restrained motion. That is Batch 03. This exists to prove the loop in + * §58 end to end — enter starts, store them, recalculate, see it change — and + * it says "Batch 01 · working surface" at the top so nobody mistakes it for the + * real thing. A convincing mock is how a screen comes to be believed finished. + */ +@Composable +fun TodayScreen(viewModel: TodayViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val message by viewModel.message.collectAsStateWithLifecycle() + + LazyColumn( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Text( + "Batch 01 · working surface — the designed Today screen is Batch 03", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + item { ForecastCard(state, viewModel) } + + message?.let { text -> + item { + Card(Modifier.fillMaxWidth()) { + Row( + Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text, style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = viewModel::messageShown) { Text("OK") } + } + } + } + } + + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { viewModel.confirmStart(state.today) }) { Text("Started today") } + OutlinedButton(onClick = { viewModel.notYet() }) { Text("Not yet") } + } + } + + item { + Text("History", style = MaterialTheme.typography.titleMedium) + if (state.periods.isEmpty() && !state.loading) { + Text( + "No periods logged yet. Log one above, or add an earlier one to " + + "help the app learn your cycle faster.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + items(state.periods, key = { it.id }) { record -> + PeriodRow( + record = record, + onShiftStart = { days -> viewModel.edit(record.id, record.startDate.plusDays(days), record.endDate) }, + onEndToday = { viewModel.setEnd(record.id, state.today) }, + onStillGoing = { viewModel.setEnd(record.id, null) }, + onDelete = { viewModel.delete(record.id) }, + ) + } + + if (state.cycles.isNotEmpty()) { + item { + HorizontalDivider() + Text("Cycle lengths", style = MaterialTheme.typography.titleMedium) + Text( + state.cycles.joinToString(" · ") { "${it.cycleLengthDays}d" }, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + if (state.accuracy.hasEnoughToShow) { + item { AccuracyCard(state) } + } + } +} + +@Composable +private fun ForecastCard(state: TodayUiState, viewModel: TodayViewModel) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + val forecast = state.forecast + if (forecast == null) { + Text("No forecast yet", style = MaterialTheme.typography.titleLarge) + Text( + "Log a period and the app will start learning your cycle.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return@Column + } + + state.cycleDay?.let { Text("Cycle day $it", style = MaterialTheme.typography.labelLarge) } + + val days = state.daysUntil + // The window and the confidence travel with the date, always. + // §8 and §15: never a bare exact date presented as fact. + Text( + when { + days == null -> "Period expected" + days > 1L -> "Period likely in $days days" + days == 1L -> "Period likely tomorrow" + days == 0L -> "Your period may start today" + else -> "Not yet?" + }, + style = MaterialTheme.typography.titleLarge, + ) + Text("Most likely ${forecast.mostLikelyStartDate.pretty()}") + Text("Expected ${forecast.windowStart.pretty()} – ${forecast.windowEnd.pretty()}") + Text( + "Confidence ${forecast.confidenceLabel.label()}", + modifier = Modifier.clearAndSetSemantics { + contentDescription = "Prediction confidence: ${forecast.confidenceLabel.label()}" + }, + ) + } + } +} + +@Composable +private fun AccuracyCard(state: TodayUiState) { + val a = state.accuracy + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Prediction accuracy", style = MaterialTheme.typography.titleMedium) + a.lastSignedErrorDays?.let { + Text( + when { + it == 0 -> "Last prediction: exact" + it < 0 -> "Last prediction: ${-it} day(s) early" + else -> "Last prediction: $it day(s) late" + }, + ) + } + a.meanAbsoluteErrorDays?.let { Text("Average error: ${"%.1f".format(it)} days") } + Text("${a.withinOneDay} of your last ${a.scoredCount} were within one day") + } + } +} + +@Composable +private fun PeriodRow( + record: PeriodRecord, + onShiftStart: (Long) -> Unit, + onEndToday: () -> Unit, + onStillGoing: () -> Unit, + onDelete: () -> Unit, +) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text(record.startDate.pretty(), style = MaterialTheme.typography.titleSmall) + Text( + record.endDate?.let { "ended ${it.pretty()}" } ?: "still going", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + record.source.name.lowercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton(onClick = { onShiftStart(-1) }) { Text("−1d") } + TextButton(onClick = { onShiftStart(1) }) { Text("+1d") } + if (record.endDate == null) { + TextButton(onClick = onEndToday) { Text("Ended") } + } else { + TextButton(onClick = onStillGoing) { Text("Still going") } + } + TextButton(onClick = onDelete) { Text("Delete") } + } + } + } +} + +/** "Low", not "LOW" — the user-facing labels in §15 are words, not enum names. */ +private fun ConfidenceLabel.label() = when (this) { + ConfidenceLabel.LOW -> "Low" + ConfidenceLabel.MEDIUM -> "Medium" + ConfidenceLabel.HIGH -> "High" +} + +private val formatter = DateTimeFormatter.ofPattern("d MMM yyyy") +private fun LocalDate.pretty(): String = format(formatter) diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayViewModel.kt new file mode 100644 index 0000000..1789d8a --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayViewModel.kt @@ -0,0 +1,129 @@ +package dev.privacyllc.period.feature.today + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.privacyllc.period.core.data.CycleRepository +import dev.privacyllc.period.core.data.PeriodWriteResult +import dev.privacyllc.period.core.data.PredictionAccuracy +import dev.privacyllc.period.domain.cycle.CycleRecord +import dev.privacyllc.period.domain.cycle.PeriodRecord +import dev.privacyllc.period.domain.prediction.Prediction +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.time.Clock +import java.time.LocalDate +import javax.inject.Inject + +/** + * Screen state, as one immutable object. + * + * [daysUntil] is derived here rather than in the composable so the arithmetic + * has a place it can be tested. It is null when there is no forecast, which is + * a different thing from zero. + */ +data class TodayUiState( + val loading: Boolean = true, + val periods: List = emptyList(), + val cycles: List = emptyList(), + val forecast: Prediction? = null, + val accuracy: PredictionAccuracy = PredictionAccuracy.Empty, + val today: LocalDate = LocalDate.EPOCH, +) { + val daysUntil: Long? + get() = forecast?.let { it.mostLikelyStartDate.toEpochDay() - today.toEpochDay() } + + val cycleDay: Long? + get() = periods.maxByOrNull { it.startDate } + ?.let { today.toEpochDay() - it.startDate.toEpochDay() + 1 } + ?.takeIf { it > 0 } +} + +@HiltViewModel +class TodayViewModel @Inject constructor( + private val repository: CycleRepository, + private val clock: Clock, +) : ViewModel() { + + val state: StateFlow = + combine( + repository.confirmedPeriods, + repository.cycles, + repository.forecast, + repository.accuracy, + ) { periods, cycles, forecast, accuracy -> + TodayUiState( + loading = false, + periods = periods.sortedByDescending { it.startDate }, + cycles = cycles, + forecast = forecast, + accuracy = accuracy, + today = LocalDate.now(clock), + ) + }.stateIn( + scope = viewModelScope, + // Keeps the database subscription alive briefly across a rotation + // rather than tearing it down and re-reading, which is what makes + // the forecast look like it flickers. + started = SharingStarted.WhileSubscribed(5_000), + initialValue = TodayUiState(today = LocalDate.now(clock)), + ) + + private val _message = MutableStateFlow(null) + + /** A transient line for the user. Null when there is nothing to say. */ + val message: StateFlow = _message.asStateFlow() + + fun messageShown() { _message.value = null } + + /** + * Nothing the user can tap may take the app down. + * + * `viewModelScope.launch` has no handler by default, so any exception from a + * repository write reaches the default handler and kills the process. That + * is how tapping **Started today** twice crashed the app on a device: the + * UNIQUE constraint on `startDate` threw straight through. + * + * The ordinary outcomes are now values rather than exceptions + * ([PeriodWriteResult]), which is the real fix. This is the backstop for + * everything else — a full disk, a corrupt database — because in a health + * app a crash mid-write is adjacent to losing what the user just entered, + * and a message they can read beats a process that vanished. + */ + private val handler = CoroutineExceptionHandler { _, e -> + // No cycle date, no record contents — §45. The exception's type is + // enough to act on and carries nothing sensitive. + _message.value = "Could not save that (${e::class.simpleName}). Nothing was changed." + } + + private fun write(block: suspend () -> Unit) = viewModelScope.launch(handler) { block() } + + fun confirmStart(date: LocalDate) = write { + when (val r = repository.confirmPeriodStart(date)) { + is PeriodWriteResult.AlreadyRecorded -> _message.value = "That day is already logged." + is PeriodWriteResult.Added -> _message.value = null + else -> Unit.also { check(r !is PeriodWriteResult.Conflict) } + } + } + + fun edit(id: Long, start: LocalDate, end: LocalDate?) = write { + when (repository.editPeriod(id, start, end)) { + is PeriodWriteResult.Conflict -> + _message.value = "There is already a period logged on that date." + PeriodWriteResult.NotFound -> _message.value = "That record is no longer there." + else -> _message.value = null + } + } + + fun setEnd(id: Long, end: LocalDate?) = write { repository.setPeriodEnd(id, end) } + + fun delete(id: Long) = write { repository.deletePeriod(id) } + + fun notYet() = write { repository.recordNotYet(LocalDate.now(clock)) } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt index 2126de0..16c067a 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt @@ -33,6 +33,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import dev.privacyllc.period.R import dev.privacyllc.period.designsystem.PeriodTheme +import dev.privacyllc.period.feature.today.TodayScreen /** * The four tabs from docs/planning/PRODUCT_PLAN.md §20. @@ -83,11 +84,17 @@ fun PeriodApp() { startDestination = PeriodDestination.TODAY.route, modifier = Modifier.padding(innerPadding), ) { - PeriodDestination.entries.forEach { destination -> - composable(destination.route) { - PlaceholderScreen(stringResource(destination.labelRes)) + composable(PeriodDestination.TODAY.route) { TodayScreen() } + + // Calendar, Insights and Settings arrive in Batches 03 and 06. They + // say "not built yet" rather than showing a convincing mock. + PeriodDestination.entries + .filter { it != PeriodDestination.TODAY } + .forEach { destination -> + composable(destination.route) { + PlaceholderScreen(stringResource(destination.labelRes)) + } } - } } } } @@ -125,6 +132,6 @@ private fun PlaceholderScreen(title: String) { @Preview(showBackground = true) @Composable -private fun PeriodAppPreview() { - PeriodTheme { PeriodApp() } +private fun PlaceholderPreview() { + PeriodTheme { PlaceholderScreen("Calendar") } } diff --git a/app/src/test/kotlin/dev/privacyllc/period/feature/today/TodayUiStateTest.kt b/app/src/test/kotlin/dev/privacyllc/period/feature/today/TodayUiStateTest.kt new file mode 100644 index 0000000..f76e68a --- /dev/null +++ b/app/src/test/kotlin/dev/privacyllc/period/feature/today/TodayUiStateTest.kt @@ -0,0 +1,103 @@ +package dev.privacyllc.period.feature.today + +import dev.privacyllc.period.domain.cycle.PeriodRecord +import dev.privacyllc.period.domain.prediction.ConfidenceLabel +import dev.privacyllc.period.domain.prediction.Prediction +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.LocalDate + +/** + * The two pieces of arithmetic the Today screen shows as its largest text. + * + * Derived on [TodayUiState] rather than inside a composable precisely so they + * can be tested here — a countdown that is off by one is the kind of defect a + * user notices immediately and a screenshot review never does. + */ +class TodayUiStateTest { + + private val today = LocalDate.of(2026, 8, 18) + + private fun forecastOn(date: LocalDate) = Prediction( + mostLikelyStartDate = date, + windowStart = date.minusDays(1), + windowEnd = date.plusDays(1), + confidenceScore = 0.7, + confidenceLabel = ConfidenceLabel.HIGH, + modelVersion = "test", + ) + + private fun state( + forecast: Prediction? = null, + periodStarts: List = emptyList(), + ) = TodayUiState( + loading = false, + periods = periodStarts.mapIndexed { i, d -> PeriodRecord(id = i.toLong(), startDate = d) }, + forecast = forecast, + today = today, + ) + + @Test fun `no forecast means no countdown rather than zero`() { + // Zero would render as "your period may start today", which is a claim. + assertNull(state().daysUntil) + } + + @Test fun `the countdown is whole days from today`() { + assertEquals(4L, state(forecastOn(LocalDate.of(2026, 8, 22))).daysUntil) + assertEquals(1L, state(forecastOn(LocalDate.of(2026, 8, 19))).daysUntil) + assertEquals(0L, state(forecastOn(today)).daysUntil) + } + + @Test fun `a forecast already passed counts negative rather than clamping to zero`() { + // The screen says "Not yet?" here. Clamping to 0 would keep saying "may + // start today" for days, which §22 explicitly calls out as the wrong + // tone — and would hide that the forecast needs re-conditioning. + assertEquals(-3L, state(forecastOn(LocalDate.of(2026, 8, 15))).daysUntil) + } + + @Test fun `cycle day counts from the most recent start, inclusive`() { + // The day a period starts is cycle day 1, not day 0. + assertEquals(1L, state(periodStarts = listOf(today)).cycleDay) + assertEquals(18L, state(periodStarts = listOf(LocalDate.of(2026, 8, 1))).cycleDay) + } + + @Test fun `cycle day uses the latest start regardless of list order`() { + assertEquals( + 18L, + state( + periodStarts = listOf( + LocalDate.of(2026, 6, 1), + LocalDate.of(2026, 8, 1), + LocalDate.of(2026, 7, 1), + ), + ).cycleDay, + ) + } + + @Test fun `no history means no cycle day`() { + assertNull(state().cycleDay) + } + + @Test fun `a start date in the future does not produce a zero or negative cycle day`() { + // Reachable by editing a record forward. "Cycle day 0" or "-2" is + // nonsense on screen; showing nothing is honest. + assertNull(state(periodStarts = listOf(today.plusDays(2))).cycleDay) + } + + @Test fun `a countdown spans month and year boundaries correctly`() { + val newYear = TodayUiState( + loading = false, + forecast = forecastOn(LocalDate.of(2027, 1, 2)), + today = LocalDate.of(2026, 12, 28), + ) + assertEquals(5L, newYear.daysUntil) + + val leap = TodayUiState( + loading = false, + forecast = forecastOn(LocalDate.of(2028, 3, 1)), + today = LocalDate.of(2028, 2, 27), + ) + assertEquals(3L, leap.daysUntil) // 2028 is a leap year: 27th, 28th, 29th, 1st + } +} diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 8f859f7..963af19 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -8,6 +8,7 @@ android { defaultConfig { minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } compileOptions { @@ -39,4 +40,10 @@ dependencies { testImplementation(libs.androidx.room.runtime) testImplementation(libs.robolectric) testImplementation(libs.androidx.test.core) + + androidTestImplementation(project(":core:database")) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.room.runtime) + androidTestImplementation(libs.kotlinx.coroutines.test) } diff --git a/core/data/src/androidTest/kotlin/dev/privacyllc/period/core/data/PeriodCrudTest.kt b/core/data/src/androidTest/kotlin/dev/privacyllc/period/core/data/PeriodCrudTest.kt new file mode 100644 index 0000000..8909902 --- /dev/null +++ b/core/data/src/androidTest/kotlin/dev/privacyllc/period/core/data/PeriodCrudTest.kt @@ -0,0 +1,132 @@ +package dev.privacyllc.period.core.data + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.privacyllc.period.core.database.PeriodDatabase +import dev.privacyllc.period.domain.prediction.BaselinePredictionEngine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.time.Clock +import java.time.LocalDate +import java.time.ZoneOffset + +/** + * The acceptance check for the Batch 01 vertical slice, on a real device, + * against a real file-backed database. + * + * The unit tests use an in-memory database, which proves the logic and proves + * nothing about persistence: an in-memory database cannot fail to survive a + * restart because it was never expected to. This one closes the database and + * reopens it from disk, which is what a force-stop actually does to the app. + */ +@RunWith(AndroidJUnit4::class) +class PeriodCrudTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val dbName = "crud-test.db" + private val clock = Clock.fixed( + LocalDate.of(2026, 8, 18).atStartOfDay(ZoneOffset.UTC).toInstant(), + ZoneOffset.UTC, + ) + + private lateinit var db: PeriodDatabase + private lateinit var repo: CycleRepository + + private fun open() { + db = Room.databaseBuilder(context, PeriodDatabase::class.java, dbName).build() + repo = CycleRepository(db, BaselinePredictionEngine(), clock) + } + + @Before fun setUp() { + context.deleteDatabase(dbName) + open() + } + + @After fun tearDown() { + db.close() + context.deleteDatabase(dbName) + } + + @Test + fun addEditDeleteAndSurviveRestart() = runTest { + // --- add three starts ------------------------------------------------- + repo.confirmPeriodStart(LocalDate.of(2026, 6, 1)) + repo.confirmPeriodStart(LocalDate.of(2026, 6, 30)) + repo.confirmPeriodStart(LocalDate.of(2026, 7, 29)) + + // Regression: a repeat of a date already logged must be a value, not a + // crash. This is the interaction that killed the app on a device. + assertTrue( + repo.confirmPeriodStart(LocalDate.of(2026, 7, 29)) is PeriodWriteResult.AlreadyRecorded, + ) + + assertEquals(3, repo.confirmedPeriods.first().size) + assertEquals(listOf(29, 29), repo.cycles.first().map { it.cycleLengthDays }) + + val afterAdd = repo.forecast.first() + assertNotNull("three confirmed starts must produce a forecast", afterAdd) + assertEquals(LocalDate.of(2026, 8, 27), afterAdd!!.mostLikelyStartDate) + + // --- editing changes the forecast ------------------------------------ + val latest = repo.confirmedPeriods.first().last() + repo.editPeriod(latest.id, LocalDate.of(2026, 8, 2), null) + + val afterEdit = repo.forecast.first()!! + assertNotEquals( + "editing a record must move the forecast, not leave a stale one", + afterAdd.mostLikelyStartDate, + afterEdit.mostLikelyStartDate, + ) + assertEquals(listOf(29, 33), repo.cycles.first().map { it.cycleLengthDays }) + + // --- deleting changes it again --------------------------------------- + val middle = repo.confirmedPeriods.first()[1] + repo.deletePeriod(middle.id) + + assertEquals(2, repo.confirmedPeriods.first().size) + val afterDelete = repo.forecast.first()!! + assertNotEquals(afterEdit.mostLikelyStartDate, afterDelete.mostLikelyStartDate) + + // --- close and reopen: what a force-stop does ------------------------- + db.close() + open() + + val reopened = repo.confirmedPeriods.first() + assertEquals("records must survive the process dying", 2, reopened.size) + assertEquals( + listOf(LocalDate.of(2026, 6, 1), LocalDate.of(2026, 8, 2)), + reopened.map { it.startDate }, + ) + assertEquals( + "the forecast must be identical after a restart — it is derived, not cached", + afterDelete.mostLikelyStartDate, + repo.forecast.first()!!.mostLikelyStartDate, + ) + } + + @Test + fun deletingAllHealthDataSurvivesRestart() = runTest { + repo.confirmPeriodStart(LocalDate.of(2026, 6, 1)) + repo.confirmPeriodStart(LocalDate.of(2026, 6, 30)) + repo.recordSpotting(LocalDate.of(2026, 6, 14)) + + repo.deleteAllHealthData() + db.close() + open() + + // Delete My Data is irreversible after confirmation — §45. If anything + // came back after a restart, it was never deleted, only hidden. + assertEquals(0, repo.confirmedPeriods.first().size) + assertEquals(0, repo.spotting.first().size) + assertEquals(null, repo.forecast.first()) + } +} diff --git a/core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleRepository.kt b/core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleRepository.kt index 214cfda..c8288b5 100644 --- a/core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleRepository.kt +++ b/core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleRepository.kt @@ -124,7 +124,15 @@ class CycleRepository internal constructor( suspend fun confirmPeriodStart( startDate: LocalDate, source: PeriodRecordSource = PeriodRecordSource.MANUAL, - ): Long = db.inTransaction { + ): PeriodWriteResult = db.inTransaction { + // Checked inside the transaction, before inserting. Tapping the primary + // button twice is an ordinary thing for a person to do when they are not + // sure the first tap registered — it must not be an error, and it + // certainly must not be a crash. See PeriodWriteResult. + periodDao.byStartDate(startDate)?.let { + return@inTransaction PeriodWriteResult.AlreadyRecorded(it.id) + } + val now = clock.instant() val id = periodDao.insert( PeriodRecord(id = 0, startDate = startDate, source = source).toEntity(now, now), @@ -133,16 +141,17 @@ class CycleRepository internal constructor( scoreOutstanding(startDate) notYetDao.deleteBefore(startDate.plusDays(1)) snapshotForecast(basedOnPeriodId = id) - id + PeriodWriteResult.Added(id) } /** "Still going" answered, or a date chosen. Never invents an end date. */ - suspend fun setPeriodEnd(id: Long, endDate: LocalDate?) = db.inTransaction { - val existing = periodDao.byId(id) ?: return@inTransaction + suspend fun setPeriodEnd(id: Long, endDate: LocalDate?): PeriodWriteResult = db.inTransaction { + val existing = periodDao.byId(id) ?: return@inTransaction PeriodWriteResult.NotFound require(endDate == null || !endDate.isBefore(existing.startDate)) { "a period cannot end ($endDate) before it started (${existing.startDate})" } periodDao.update(existing.copy(endDate = endDate, updatedAt = clock.instant())) + PeriodWriteResult.Updated } /** @@ -152,21 +161,32 @@ class CycleRepository internal constructor( * §14: health history is never modified silently — even by its owner, the * change is recorded as a change. */ - suspend fun editPeriod(id: Long, startDate: LocalDate, endDate: LocalDate?) = db.inTransaction { - val existing = periodDao.byId(id) ?: return@inTransaction - require(endDate == null || !endDate.isBefore(startDate)) { - "a period cannot end ($endDate) before it started ($startDate)" + suspend fun editPeriod(id: Long, startDate: LocalDate, endDate: LocalDate?): PeriodWriteResult = + db.inTransaction { + val existing = periodDao.byId(id) ?: return@inTransaction PeriodWriteResult.NotFound + require(endDate == null || !endDate.isBefore(startDate)) { + "a period cannot end ($endDate) before it started ($startDate)" + } + + // Same UNIQUE constraint, same reason to answer with a value: moving + // a record onto a date another record already holds is a question + // only the user can settle. Merging would delete a period they + // entered, and throwing would take the app down. + periodDao.byStartDate(startDate)?.let { clash -> + if (clash.id != id) return@inTransaction PeriodWriteResult.Conflict(clash.id) + } + + periodDao.update( + existing.copy( + startDate = startDate, + endDate = endDate, + source = PeriodRecordSource.EDITED.name, + updatedAt = clock.instant(), + ), + ) + snapshotForecast(basedOnPeriodId = id) + PeriodWriteResult.Updated } - periodDao.update( - existing.copy( - startDate = startDate, - endDate = endDate, - source = PeriodRecordSource.EDITED.name, - updatedAt = clock.instant(), - ), - ) - snapshotForecast(basedOnPeriodId = id) - } suspend fun deletePeriod(id: Long) = db.inTransaction { periodDao.deleteById(id) diff --git a/core/data/src/main/kotlin/dev/privacyllc/period/core/data/PeriodWriteResult.kt b/core/data/src/main/kotlin/dev/privacyllc/period/core/data/PeriodWriteResult.kt new file mode 100644 index 0000000..b6ff6ad --- /dev/null +++ b/core/data/src/main/kotlin/dev/privacyllc/period/core/data/PeriodWriteResult.kt @@ -0,0 +1,45 @@ +package dev.privacyllc.period.core.data + +/** + * What a write to the period history did. + * + * ## Why this exists rather than an exception + * + * `period_records.startDate` is UNIQUE, and inserts ABORT rather than REPLACE — + * both deliberate, so a duplicate cannot silently destroy the original row. + * The consequence was a defect found on a device: tapping **Started today** + * twice threw `SQLiteConstraintException` out of `viewModelScope.launch`, which + * has no handler, and the app died on the most ordinary interaction it has. + * + * The mistake was treating "already recorded" as an error. It is not one — it + * is a completely reasonable thing for a person to do, twice, when they are not + * sure the first tap registered. The database constraint is right; the API + * around it was wrong to make an exception the only way to say so. + * + * So the ordinary outcomes are values. A genuine fault — a full disk, a corrupt + * database — still throws, because that is not something a caller can sensibly + * carry on from. + */ +sealed interface PeriodWriteResult { + + /** A new record was written. */ + data class Added(val id: Long) : PeriodWriteResult + + /** That date was already recorded. Nothing changed, and nothing is wrong. */ + data class AlreadyRecorded(val id: Long) : PeriodWriteResult + + /** An existing record was changed. */ + data object Updated : PeriodWriteResult + + /** + * The edit would have collided with a different record on that date. + * + * Refused rather than merged: two records on one day is a data question + * only the user can answer, and picking for them would delete a period they + * entered. + */ + data class Conflict(val existingId: Long) : PeriodWriteResult + + /** The record was gone by the time the write ran. */ + data object NotFound : PeriodWriteResult +} diff --git a/core/data/src/test/kotlin/dev/privacyllc/period/core/data/CycleRepositoryTest.kt b/core/data/src/test/kotlin/dev/privacyllc/period/core/data/CycleRepositoryTest.kt index 1552b81..d32d322 100644 --- a/core/data/src/test/kotlin/dev/privacyllc/period/core/data/CycleRepositoryTest.kt +++ b/core/data/src/test/kotlin/dev/privacyllc/period/core/data/CycleRepositoryTest.kt @@ -168,7 +168,7 @@ class CycleRepositoryTest { // ----------------------------------------------------------------------- @Test fun `editing a record marks it as edited rather than changing it silently`() = runTest { - val id = repo.confirmPeriodStart(LocalDate.of(2026, 8, 1)) + val id = (repo.confirmPeriodStart(LocalDate.of(2026, 8, 1)) as PeriodWriteResult.Added).id repo.editPeriod(id, LocalDate.of(2026, 8, 3), null) val p = repo.confirmedPeriods.first().single() @@ -178,7 +178,7 @@ class CycleRepositoryTest { } @Test fun `a period cannot be made to end before it started`() = runTest { - val id = repo.confirmPeriodStart(LocalDate.of(2026, 8, 10)) + val id = (repo.confirmPeriodStart(LocalDate.of(2026, 8, 10)) as PeriodWriteResult.Added).id var refused = false try { repo.setPeriodEnd(id, LocalDate.of(2026, 8, 1)) @@ -190,7 +190,7 @@ class CycleRepositoryTest { } @Test fun `an ongoing period has a null end rather than an invented one`() = runTest { - val id = repo.confirmPeriodStart(LocalDate.of(2026, 8, 10)) + val id = (repo.confirmPeriodStart(LocalDate.of(2026, 8, 10)) as PeriodWriteResult.Added).id assertNull(repo.confirmedPeriods.first().single().endDate) repo.setPeriodEnd(id, LocalDate.of(2026, 8, 14)) @@ -296,6 +296,59 @@ class CycleRepositoryTest { assertEquals(1, db.predictionRecordDao().unscored().size) } + // ----------------------------------------------------------------------- + // Regression: logging the same day twice crashed the app on a device + // ----------------------------------------------------------------------- + + @Test fun `logging the same day twice is not an error and does not duplicate`() = runTest { + // Found by tapping "Started today" twice on an emulator: + // SQLiteConstraintException out of viewModelScope, process dead. The + // constraint is right — a duplicate must not overwrite the original — + // but "already recorded" is an ordinary thing for a person to do, not + // a fault, and it must never be an exception. + val first = repo.confirmPeriodStart(LocalDate.of(2026, 8, 18)) + assertTrue(first is PeriodWriteResult.Added) + + val second = repo.confirmPeriodStart(LocalDate.of(2026, 8, 18)) + assertTrue("a repeat tap must not throw", second is PeriodWriteResult.AlreadyRecorded) + assertEquals((first as PeriodWriteResult.Added).id, (second as PeriodWriteResult.AlreadyRecorded).id) + + assertEquals("and must not duplicate the record", 1, repo.confirmedPeriods.first().size) + } + + @Test fun `editing onto an occupied date is refused rather than crashing or merging`() = runTest { + seed("2026-06-01", "2026-06-30") + val first = repo.confirmedPeriods.first().first() + + val result = repo.editPeriod(first.id, LocalDate.of(2026, 6, 30), null) + + // Merging would delete a period the user entered; throwing would take + // the app down. Neither is ours to choose — it is refused and reported. + assertTrue(result is PeriodWriteResult.Conflict) + assertEquals(2, repo.confirmedPeriods.first().size) + assertEquals( + "the record must be untouched after a refused edit", + LocalDate.of(2026, 6, 1), + repo.confirmedPeriods.first().first().startDate, + ) + } + + @Test fun `editing a record onto its own date is allowed`() = runTest { + val id = (repo.confirmPeriodStart(LocalDate.of(2026, 8, 1)) as PeriodWriteResult.Added).id + // Changing only the end date must not read as a collision with itself. + assertTrue(repo.editPeriod(id, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 5)) + is PeriodWriteResult.Updated) + assertEquals(LocalDate.of(2026, 8, 5), repo.confirmedPeriods.first().single().endDate) + } + + @Test fun `writing to a record that is gone reports it rather than throwing`() = runTest { + val id = (repo.confirmPeriodStart(LocalDate.of(2026, 8, 1)) as PeriodWriteResult.Added).id + repo.deletePeriod(id) + + assertEquals(PeriodWriteResult.NotFound, repo.editPeriod(id, LocalDate.of(2026, 8, 2), null)) + assertEquals(PeriodWriteResult.NotFound, repo.setPeriodEnd(id, LocalDate.of(2026, 8, 5))) + } + @Test fun `accuracy arithmetic is signed for the user and absolute for the average`() { // predicted − actual: negative is early, positive is late. val a = PredictionAccuracy.from(listOf(-1, 2, 0, -3)) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 376870a..85e1b23 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -86,6 +86,30 @@ silent data loss on update — here, a user's entire cycle history gone with no error and no way back. A missing migration must be a crash in testing rather than a wipe in production. +### Ordinary outcomes are values; only faults are exceptions + +`period_records.startDate` is UNIQUE and inserts ABORT rather than REPLACE, so a +duplicate cannot destroy the original row. Both are right. The API around them +was not: `confirmPeriodStart` let `SQLiteConstraintException` out, and +`viewModelScope.launch` has no handler, so **tapping "Started today" twice +killed the app** — found on a device, not in a test. + +The mistake was treating *already recorded* as an error. It is a completely +reasonable thing for a person to do twice when they are unsure the first tap +registered. So the period writes return `PeriodWriteResult` — `Added`, +`AlreadyRecorded`, `Updated`, `Conflict`, `NotFound` — and a genuine fault (full +disk, corrupt database) still throws, because that is not something a caller can +carry on from. + +`Conflict` is refused rather than resolved: moving a record onto a date another +record already holds is a question only the user can settle, and merging would +delete a period they entered. + +The ViewModel also installs a `CoroutineExceptionHandler` as a backstop. In a +health app a crash mid-write is adjacent to losing what the user just entered, +and a message they can read beats a process that vanished. The message carries +the exception *type* and never a record's contents — §45. + ### One forecast stands at a time, and backfill is not a prediction Two rules about `prediction_records` that are easy to get wrong and expensive to diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d480d2e..ae5a5cc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,12 +20,14 @@ room = "2.8.4" sqlite = "2.7.0" robolectric = "4.16.1" androidxTestCore = "1.7.0" +testRunner = "1.7.0" datastore = "1.2.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } @@ -56,6 +58,7 @@ androidx-test-core = { group = "androidx.test", name = "core", version.ref = "an junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestJunit" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "testRunner" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } [plugins]