feat: period CRUD end to end, and stop a double tap killing the app
The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device: launch, log a period, it is stored, the forecast recalculates, edit or delete it and the forecast moves again. Hilt wiring, a TodayViewModel exposing one immutable state, and a working surface that says "Batch 01 · working surface" at the top so nobody mistakes it for the designed Today screen, which is Batch 03. THE DEFECT THIS FOUND, ON A DEVICE Tapping "Started today" twice on the same day killed the app: FATAL EXCEPTION: main android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed: period_records.startDate Not a hypothetical — the crash was reproduced on emulator-5580, the fix applied, and the same two taps then produced "That day is already logged." with the process still alive and zero FATAL lines in logcat. The constraint is right: a duplicate must not overwrite the original row and lose its createdAt and source. The API around it was wrong. Repeating a tap when you are not sure the first one registered is an ordinary thing for a person to do, not a fault, and it must never be an exception. So the period writes return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound — and only genuine faults still throw. editPeriod had the same hole: moving a record onto a date another record holds. That is refused rather than merged, because merging would delete a period the user entered and only they can settle it. The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a health app a crash mid-write is adjacent to losing what was just entered, and a message somebody can read beats a process that vanished. The message carries the exception type and never a record's contents (§45). Four regression tests pin all of it, plus two instrumented tests on a real file-backed database that close and reopen it — what a force-stop actually does, and something an in-memory database cannot fail. 70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB. closes #6
This commit is contained in:
parent
6d4592467f
commit
adc50751d8
|
|
@ -52,12 +52,15 @@ android {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":core:designsystem"))
|
implementation(project(":core:designsystem"))
|
||||||
|
implementation(project(":core:data"))
|
||||||
|
implementation(project(":core:datastore"))
|
||||||
implementation(project(":domain:cycle"))
|
implementation(project(":domain:cycle"))
|
||||||
implementation(project(":domain:prediction"))
|
implementation(project(":domain:prediction"))
|
||||||
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
|
||||||
|
|
@ -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<Preferences> =
|
||||||
|
PreferenceDataStoreFactory.create {
|
||||||
|
context.preferencesDataStoreFile("user_preferences")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun userPreferencesRepository(store: DataStore<Preferences>) = 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()
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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<PeriodRecord> = emptyList(),
|
||||||
|
val cycles: List<CycleRecord> = 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<TodayUiState> =
|
||||||
|
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<String?>(null)
|
||||||
|
|
||||||
|
/** A transient line for the user. Null when there is nothing to say. */
|
||||||
|
val message: StateFlow<String?> = _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)) }
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import dev.privacyllc.period.R
|
import dev.privacyllc.period.R
|
||||||
import dev.privacyllc.period.designsystem.PeriodTheme
|
import dev.privacyllc.period.designsystem.PeriodTheme
|
||||||
|
import dev.privacyllc.period.feature.today.TodayScreen
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The four tabs from docs/planning/PRODUCT_PLAN.md §20.
|
* The four tabs from docs/planning/PRODUCT_PLAN.md §20.
|
||||||
|
|
@ -83,7 +84,13 @@ fun PeriodApp() {
|
||||||
startDestination = PeriodDestination.TODAY.route,
|
startDestination = PeriodDestination.TODAY.route,
|
||||||
modifier = Modifier.padding(innerPadding),
|
modifier = Modifier.padding(innerPadding),
|
||||||
) {
|
) {
|
||||||
PeriodDestination.entries.forEach { destination ->
|
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) {
|
composable(destination.route) {
|
||||||
PlaceholderScreen(stringResource(destination.labelRes))
|
PlaceholderScreen(stringResource(destination.labelRes))
|
||||||
}
|
}
|
||||||
|
|
@ -125,6 +132,6 @@ private fun PlaceholderScreen(title: String) {
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun PeriodAppPreview() {
|
private fun PlaceholderPreview() {
|
||||||
PeriodTheme { PeriodApp() }
|
PeriodTheme { PlaceholderScreen("Calendar") }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<LocalDate> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ android {
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|
@ -39,4 +40,10 @@ dependencies {
|
||||||
testImplementation(libs.androidx.room.runtime)
|
testImplementation(libs.androidx.room.runtime)
|
||||||
testImplementation(libs.robolectric)
|
testImplementation(libs.robolectric)
|
||||||
testImplementation(libs.androidx.test.core)
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -124,7 +124,15 @@ class CycleRepository internal constructor(
|
||||||
suspend fun confirmPeriodStart(
|
suspend fun confirmPeriodStart(
|
||||||
startDate: LocalDate,
|
startDate: LocalDate,
|
||||||
source: PeriodRecordSource = PeriodRecordSource.MANUAL,
|
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 now = clock.instant()
|
||||||
val id = periodDao.insert(
|
val id = periodDao.insert(
|
||||||
PeriodRecord(id = 0, startDate = startDate, source = source).toEntity(now, now),
|
PeriodRecord(id = 0, startDate = startDate, source = source).toEntity(now, now),
|
||||||
|
|
@ -133,16 +141,17 @@ class CycleRepository internal constructor(
|
||||||
scoreOutstanding(startDate)
|
scoreOutstanding(startDate)
|
||||||
notYetDao.deleteBefore(startDate.plusDays(1))
|
notYetDao.deleteBefore(startDate.plusDays(1))
|
||||||
snapshotForecast(basedOnPeriodId = id)
|
snapshotForecast(basedOnPeriodId = id)
|
||||||
id
|
PeriodWriteResult.Added(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Still going" answered, or a date chosen. Never invents an end date. */
|
/** "Still going" answered, or a date chosen. Never invents an end date. */
|
||||||
suspend fun setPeriodEnd(id: Long, endDate: LocalDate?) = db.inTransaction {
|
suspend fun setPeriodEnd(id: Long, endDate: LocalDate?): PeriodWriteResult = db.inTransaction {
|
||||||
val existing = periodDao.byId(id) ?: return@inTransaction
|
val existing = periodDao.byId(id) ?: return@inTransaction PeriodWriteResult.NotFound
|
||||||
require(endDate == null || !endDate.isBefore(existing.startDate)) {
|
require(endDate == null || !endDate.isBefore(existing.startDate)) {
|
||||||
"a period cannot end ($endDate) before it started (${existing.startDate})"
|
"a period cannot end ($endDate) before it started (${existing.startDate})"
|
||||||
}
|
}
|
||||||
periodDao.update(existing.copy(endDate = endDate, updatedAt = clock.instant()))
|
periodDao.update(existing.copy(endDate = endDate, updatedAt = clock.instant()))
|
||||||
|
PeriodWriteResult.Updated
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -152,11 +161,21 @@ class CycleRepository internal constructor(
|
||||||
* §14: health history is never modified silently — even by its owner, the
|
* §14: health history is never modified silently — even by its owner, the
|
||||||
* change is recorded as a change.
|
* change is recorded as a change.
|
||||||
*/
|
*/
|
||||||
suspend fun editPeriod(id: Long, startDate: LocalDate, endDate: LocalDate?) = db.inTransaction {
|
suspend fun editPeriod(id: Long, startDate: LocalDate, endDate: LocalDate?): PeriodWriteResult =
|
||||||
val existing = periodDao.byId(id) ?: return@inTransaction
|
db.inTransaction {
|
||||||
|
val existing = periodDao.byId(id) ?: return@inTransaction PeriodWriteResult.NotFound
|
||||||
require(endDate == null || !endDate.isBefore(startDate)) {
|
require(endDate == null || !endDate.isBefore(startDate)) {
|
||||||
"a period cannot end ($endDate) before it started ($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(
|
periodDao.update(
|
||||||
existing.copy(
|
existing.copy(
|
||||||
startDate = startDate,
|
startDate = startDate,
|
||||||
|
|
@ -166,6 +185,7 @@ class CycleRepository internal constructor(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
snapshotForecast(basedOnPeriodId = id)
|
snapshotForecast(basedOnPeriodId = id)
|
||||||
|
PeriodWriteResult.Updated
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deletePeriod(id: Long) = db.inTransaction {
|
suspend fun deletePeriod(id: Long) = db.inTransaction {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -168,7 +168,7 @@ class CycleRepositoryTest {
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
@Test fun `editing a record marks it as edited rather than changing it silently`() = runTest {
|
@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)
|
repo.editPeriod(id, LocalDate.of(2026, 8, 3), null)
|
||||||
|
|
||||||
val p = repo.confirmedPeriods.first().single()
|
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 {
|
@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
|
var refused = false
|
||||||
try {
|
try {
|
||||||
repo.setPeriodEnd(id, LocalDate.of(2026, 8, 1))
|
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 {
|
@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)
|
assertNull(repo.confirmedPeriods.first().single().endDate)
|
||||||
|
|
||||||
repo.setPeriodEnd(id, LocalDate.of(2026, 8, 14))
|
repo.setPeriodEnd(id, LocalDate.of(2026, 8, 14))
|
||||||
|
|
@ -296,6 +296,59 @@ class CycleRepositoryTest {
|
||||||
assertEquals(1, db.predictionRecordDao().unscored().size)
|
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`() {
|
@Test fun `accuracy arithmetic is signed for the user and absolute for the average`() {
|
||||||
// predicted − actual: negative is early, positive is late.
|
// predicted − actual: negative is early, positive is late.
|
||||||
val a = PredictionAccuracy.from(listOf(-1, 2, 0, -3))
|
val a = PredictionAccuracy.from(listOf(-1, 2, 0, -3))
|
||||||
|
|
|
||||||
|
|
@ -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
|
error and no way back. A missing migration must be a crash in testing rather
|
||||||
than a wipe in production.
|
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
|
### 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
|
Two rules about `prediction_records` that are easy to get wrong and expensive to
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,14 @@ room = "2.8.4"
|
||||||
sqlite = "2.7.0"
|
sqlite = "2.7.0"
|
||||||
robolectric = "4.16.1"
|
robolectric = "4.16.1"
|
||||||
androidxTestCore = "1.7.0"
|
androidxTestCore = "1.7.0"
|
||||||
|
testRunner = "1.7.0"
|
||||||
datastore = "1.2.1"
|
datastore = "1.2.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-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-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-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" }
|
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" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestJunit" }
|
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" }
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue