Privacy-Period-Tracker/app/src/main/kotlin/dev/privacyllc/period/di/DataModule.kt

73 lines
2.6 KiB
Kotlin
Raw Normal View History

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
2026-08-18 02:52:35 -05:00
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()
}