Privacy-Period-Tracker/app/src/main/kotlin/dev/privacyllc/period/navigation/RootViewModel.kt

56 lines
2.2 KiB
Kotlin
Raw Normal View History

feat: onboarding, and the Surface that dark mode was missing Seven screens, §19 and §56 verbatim: welcome, last period, period end, previous history, the privacy promise, notification privacy, first forecast. Verified end to end on a device — the flow produces a forecast, the record persists, and a relaunch goes straight to Today. Three decisions with tests behind them: - Nothing is written until the final step. Somebody who abandons onboarding halfway has not asked this app to remember anything about them. - Notification privacy is Discreet before the user touches anything (§28), and Direct is last and never pre-selected. Checked on the device, not only in a unit test. - "Still going" and "I'm not sure" both mean no end date. §24: never invent one. The date picker refuses future dates by not offering them rather than by rejecting a tap it allowed. DARK MODE WAS BROKEN FOR ALL OF BATCH 01 PeriodTheme never wrapped its content in a Surface, so every Text without an explicit colour inherited Material's default — black — and the app background never painted. In light mode that looked correct by accident, because dark text on cream is what was wanted anyway. In dark mode the onboarding headings rendered near-black on charcoal. No test caught it and no test easily would have. It was found by opening the app on a device and looking at it. The Surface now lives in the theme, so a screen without a Scaffold cannot forget, and every illustration has a light/dark preview pair. A preview is not a test, but it is the cheapest thing that puts the failure in front of whoever is editing the screen. closes #16
2026-08-18 03:44:46 -05:00
package dev.privacyllc.period.navigation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
feat: reminders that stay quiet on a lock screen §28, §29, §30 and §31. NotificationCopy is a pure function — privacy mode plus kind plus day count in, two versions of the text out — so every combination is tested exhaustively without an emulator. This is the one surface whose mistakes are visible to somebody who is not the user, so the tests are exhaustive rather than representative: every kind × every mode asserts that no health word reaches a lock screen outside Direct, and that includes the ACTION LABELS, which §31 points out are visible text too. A perfectly discreet body under a button reading "Started my period" leaks anyway. TWO ANDROID BEHAVIOURS THAT LEAK IF YOU TRUST THE DOCS A private notification with no public version does not blank the lock screen — it shows the private text. NotificationText therefore has no nullable title and an instrumented test asserts every kind attaches one. And a notification channel is IMMUTABLE after creation: importance and lock-screen visibility cannot be changed. One shared channel would have kept whatever the user's first privacy mode set, forever — switching from Direct to Maximum privacy would have appeared to work and changed nothing. There is now one channel per mode. Found by an instrumented test on a device; nothing in the unit tests could have seen it. §30's stopping rule is a test of its own: the app asks a bounded number of times, says "We'll stop checking for now. Log your period whenever it begins.", and then says nothing more — while the engine keeps learning, which is the sentence §30 puts right after it. WorkManager, and no exact alarms. §31 rules them out and the new checkPermissions task fails the build if one ever appears in the merged manifest — from here or from a dependency. That guard also failed its own first proof, reading a stale manifest because it did not depend on the task that writes one. ReminderCoordinator reschedules whenever the forecast moves, which §31 asks for and is the requirement most likely to be missed: a "Not yet" moves the forecast, so work queued against the old one is aimed at a day that no longer means anything. 188 unit tests and 6 instrumented, all passing. ./gradlew check green. closes #24 closes #25 closes #26 closes #27
2026-08-18 15:26:59 -05:00
import kotlinx.coroutines.launch
feat: onboarding, and the Surface that dark mode was missing Seven screens, §19 and §56 verbatim: welcome, last period, period end, previous history, the privacy promise, notification privacy, first forecast. Verified end to end on a device — the flow produces a forecast, the record persists, and a relaunch goes straight to Today. Three decisions with tests behind them: - Nothing is written until the final step. Somebody who abandons onboarding halfway has not asked this app to remember anything about them. - Notification privacy is Discreet before the user touches anything (§28), and Direct is last and never pre-selected. Checked on the device, not only in a unit test. - "Still going" and "I'm not sure" both mean no end date. §24: never invent one. The date picker refuses future dates by not offering them rather than by rejecting a tap it allowed. DARK MODE WAS BROKEN FOR ALL OF BATCH 01 PeriodTheme never wrapped its content in a Surface, so every Text without an explicit colour inherited Material's default — black — and the app background never painted. In light mode that looked correct by accident, because dark text on cream is what was wanted anyway. In dark mode the onboarding headings rendered near-black on charcoal. No test caught it and no test easily would have. It was found by opening the app on a device and looking at it. The Surface now lives in the theme, so a screen without a Scaffold cannot forget, and every illustration has a light/dark preview pair. A preview is not a test, but it is the cheapest thing that puts the failure in front of whoever is editing the screen. closes #16
2026-08-18 03:44:46 -05:00
import javax.inject.Inject
enum class RootState { Loading, Onboarding, Ready }
@HiltViewModel
class RootViewModel @Inject constructor(
preferences: UserPreferencesRepository,
feat: reminders that stay quiet on a lock screen §28, §29, §30 and §31. NotificationCopy is a pure function — privacy mode plus kind plus day count in, two versions of the text out — so every combination is tested exhaustively without an emulator. This is the one surface whose mistakes are visible to somebody who is not the user, so the tests are exhaustive rather than representative: every kind × every mode asserts that no health word reaches a lock screen outside Direct, and that includes the ACTION LABELS, which §31 points out are visible text too. A perfectly discreet body under a button reading "Started my period" leaks anyway. TWO ANDROID BEHAVIOURS THAT LEAK IF YOU TRUST THE DOCS A private notification with no public version does not blank the lock screen — it shows the private text. NotificationText therefore has no nullable title and an instrumented test asserts every kind attaches one. And a notification channel is IMMUTABLE after creation: importance and lock-screen visibility cannot be changed. One shared channel would have kept whatever the user's first privacy mode set, forever — switching from Direct to Maximum privacy would have appeared to work and changed nothing. There is now one channel per mode. Found by an instrumented test on a device; nothing in the unit tests could have seen it. §30's stopping rule is a test of its own: the app asks a bounded number of times, says "We'll stop checking for now. Log your period whenever it begins.", and then says nothing more — while the engine keeps learning, which is the sentence §30 puts right after it. WorkManager, and no exact alarms. §31 rules them out and the new checkPermissions task fails the build if one ever appears in the merged manifest — from here or from a dependency. That guard also failed its own first proof, reading a stale manifest because it did not depend on the task that writes one. ReminderCoordinator reschedules whenever the forecast moves, which §31 asks for and is the requirement most likely to be missed: a "Not yet" moves the forecast, so work queued against the old one is aimed at a day that no longer means anything. 188 unit tests and 6 instrumented, all passing. ./gradlew check green. closes #24 closes #25 closes #26 closes #27
2026-08-18 15:26:59 -05:00
private val notificationActions: dev.privacyllc.period.notifications.NotificationActionHandler,
feat: onboarding, and the Surface that dark mode was missing Seven screens, §19 and §56 verbatim: welcome, last period, period end, previous history, the privacy promise, notification privacy, first forecast. Verified end to end on a device — the flow produces a forecast, the record persists, and a relaunch goes straight to Today. Three decisions with tests behind them: - Nothing is written until the final step. Somebody who abandons onboarding halfway has not asked this app to remember anything about them. - Notification privacy is Discreet before the user touches anything (§28), and Direct is last and never pre-selected. Checked on the device, not only in a unit test. - "Still going" and "I'm not sure" both mean no end date. §24: never invent one. The date picker refuses future dates by not offering them rather than by rejecting a tap it allowed. DARK MODE WAS BROKEN FOR ALL OF BATCH 01 PeriodTheme never wrapped its content in a Surface, so every Text without an explicit colour inherited Material's default — black — and the app background never painted. In light mode that looked correct by accident, because dark text on cream is what was wanted anyway. In dark mode the onboarding headings rendered near-black on charcoal. No test caught it and no test easily would have. It was found by opening the app on a device and looking at it. The Surface now lives in the theme, so a screen without a Scaffold cannot forget, and every illustration has a light/dark preview pair. A preview is not a test, but it is the cheapest thing that puts the failure in front of whoever is editing the screen. closes #16
2026-08-18 03:44:46 -05:00
) : ViewModel() {
feat: reminders that stay quiet on a lock screen §28, §29, §30 and §31. NotificationCopy is a pure function — privacy mode plus kind plus day count in, two versions of the text out — so every combination is tested exhaustively without an emulator. This is the one surface whose mistakes are visible to somebody who is not the user, so the tests are exhaustive rather than representative: every kind × every mode asserts that no health word reaches a lock screen outside Direct, and that includes the ACTION LABELS, which §31 points out are visible text too. A perfectly discreet body under a button reading "Started my period" leaks anyway. TWO ANDROID BEHAVIOURS THAT LEAK IF YOU TRUST THE DOCS A private notification with no public version does not blank the lock screen — it shows the private text. NotificationText therefore has no nullable title and an instrumented test asserts every kind attaches one. And a notification channel is IMMUTABLE after creation: importance and lock-screen visibility cannot be changed. One shared channel would have kept whatever the user's first privacy mode set, forever — switching from Direct to Maximum privacy would have appeared to work and changed nothing. There is now one channel per mode. Found by an instrumented test on a device; nothing in the unit tests could have seen it. §30's stopping rule is a test of its own: the app asks a bounded number of times, says "We'll stop checking for now. Log your period whenever it begins.", and then says nothing more — while the engine keeps learning, which is the sentence §30 puts right after it. WorkManager, and no exact alarms. §31 rules them out and the new checkPermissions task fails the build if one ever appears in the merged manifest — from here or from a dependency. That guard also failed its own first proof, reading a stale manifest because it did not depend on the task that writes one. ReminderCoordinator reschedules whenever the forecast moves, which §31 asks for and is the requirement most likely to be missed: a "Not yet" moves the forecast, so work queued against the old one is aimed at a day that no longer means anything. 188 unit tests and 6 instrumented, all passing. ./gradlew check green. closes #24 closes #25 closes #26 closes #27
2026-08-18 15:26:59 -05:00
/**
* Apply an action tapped on a notification.
*
* Handled once per intent by the caller. Doing it here rather than in the
* activity keeps it off the main thread and, more importantly, routes it
* through the same repository call the in-app button uses two paths into
* one piece of state is how a lock-screen "Not yet" and an in-app "Not yet"
* come to mean slightly different things.
*/
fun onNotificationAction(action: String?) {
if (action == null) return
viewModelScope.launch { notificationActions.handle(action) }
}
feat: onboarding, and the Surface that dark mode was missing Seven screens, §19 and §56 verbatim: welcome, last period, period end, previous history, the privacy promise, notification privacy, first forecast. Verified end to end on a device — the flow produces a forecast, the record persists, and a relaunch goes straight to Today. Three decisions with tests behind them: - Nothing is written until the final step. Somebody who abandons onboarding halfway has not asked this app to remember anything about them. - Notification privacy is Discreet before the user touches anything (§28), and Direct is last and never pre-selected. Checked on the device, not only in a unit test. - "Still going" and "I'm not sure" both mean no end date. §24: never invent one. The date picker refuses future dates by not offering them rather than by rejecting a tap it allowed. DARK MODE WAS BROKEN FOR ALL OF BATCH 01 PeriodTheme never wrapped its content in a Surface, so every Text without an explicit colour inherited Material's default — black — and the app background never painted. In light mode that looked correct by accident, because dark text on cream is what was wanted anyway. In dark mode the onboarding headings rendered near-black on charcoal. No test caught it and no test easily would have. It was found by opening the app on a device and looking at it. The Surface now lives in the theme, so a screen without a Scaffold cannot forget, and every illustration has a light/dark preview pair. A preview is not a test, but it is the cheapest thing that puts the failure in front of whoever is editing the screen. closes #16
2026-08-18 03:44:46 -05:00
/**
* Set when onboarding finishes, so the switch happens immediately rather
* than waiting for the preference write to travel back through DataStore
* which is fast, but not instant, and a visible flicker on the last tap of
* onboarding is a poor first impression.
*/
private val finishedThisSession = MutableStateFlow(false)
val state: StateFlow<RootState> =
combine(
preferences.preferences.map { it.onboardingCompleted },
finishedThisSession,
) { completed, finished ->
if (completed || finished) RootState.Ready else RootState.Onboarding
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), RootState.Loading)
fun onboardingFinished() { finishedThisSession.value = true }
}