feat: pin down which day, and let the reminder be any time
Two specification gaps that were filed as deferred and turned out to be mostly affordable now. §30 asks the follow-up reminder to pin down the day: Yes Today, Yes Yesterday, Choose Date, Not Yet. It offers three of those. Android reliably renders three notification actions, and a fourth that appears on some devices and not others is worse than one that never appears -- a feature the user learns and then loses. The one left out is Choose Date, which was never really a button: it needs a screen, and logging an older start in the app is the route to that today. "Yes, yesterday" is cheap only because an answer now carries the day it was about. It means the day before the QUESTION, never the day before the tap, or it drifts exactly like the defect dated actions fixed. §29 lists a custom reminder time beside the three presets. The preference has always stored an arbitrary minute; only the screen insisted on one of three, and "around dinner" is not the same hour in every house. The keyboard-entry toggle stays on: a dial is quick with a thumb and miserable with a screen reader, and this is a control somebody may set once. One test had pinned the follow-up's exact button list, so adding a third broke it. It asserts the property now -- a question about a start never offers an ending -- which is what it was there to protect and does not have to be edited every time the copy grows. closes #75 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5f6412856e
commit
f99197ffd5
|
|
@ -1,6 +1,14 @@
|
||||||
package dev.privacyllc.period.feature.settings
|
package dev.privacyllc.period.feature.settings
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
|
import androidx.compose.material3.TimePicker
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import android.text.format.DateFormat
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
|
|
@ -114,6 +122,16 @@ private fun NotificationSettingsContent(
|
||||||
blocked: Boolean = false,
|
blocked: Boolean = false,
|
||||||
onOpenSystemSettings: () -> Unit = {},
|
onOpenSystemSettings: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
var showTimePicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showTimePicker) {
|
||||||
|
ReminderTimePicker(
|
||||||
|
initial = prefs.reminderTime,
|
||||||
|
onDismiss = { showTimePicker = false },
|
||||||
|
onPicked = { viewModel?.setReminderTime(it); showTimePicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -186,6 +204,10 @@ private fun NotificationSettingsContent(
|
||||||
).forEach { (label, time) ->
|
).forEach { (label, time) ->
|
||||||
TextButton(onClick = { viewModel?.setReminderTime(time) }) { Text(label) }
|
TextButton(onClick = { viewModel?.setReminderTime(time) }) { Text(label) }
|
||||||
}
|
}
|
||||||
|
// §29 lists a custom time beside the three presets. The preference
|
||||||
|
// has always stored an arbitrary minute; only the screen insisted on
|
||||||
|
// one of three. "Around dinner" is not the same hour in every house.
|
||||||
|
TextButton(onClick = { showTimePicker = true }) { Text("Custom") }
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
"Currently ${prefs.reminderTime.format(timeFormat)}",
|
"Currently ${prefs.reminderTime.format(timeFormat)}",
|
||||||
|
|
@ -283,3 +305,36 @@ private fun openAppNotificationSettings(context: Context) {
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
runCatching { context.startActivity(intent) }
|
runCatching { context.startActivity(intent) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick any minute of the day.
|
||||||
|
*
|
||||||
|
* A dialog rather than an inline picker: this is one decision, made rarely, and
|
||||||
|
* the settings screen is a list rather than a form. The keyboard-entry toggle
|
||||||
|
* that `TimePicker` offers is left on — a dial is quick with a thumb and
|
||||||
|
* miserable with a screen reader, and this is one of the few controls somebody
|
||||||
|
* might set once and never touch again.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ReminderTimePicker(
|
||||||
|
initial: LocalTime,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onPicked: (LocalTime) -> Unit,
|
||||||
|
) {
|
||||||
|
val state = rememberTimePickerState(
|
||||||
|
initialHour = initial.hour,
|
||||||
|
initialMinute = initial.minute,
|
||||||
|
is24Hour = DateFormat.is24HourFormat(LocalContext.current),
|
||||||
|
)
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Remind me at") },
|
||||||
|
text = { TimePicker(state = state) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onPicked(LocalTime.of(state.hour, state.minute)) }) { Text("Set") }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,15 @@ class NotificationActionHandler @Inject constructor(
|
||||||
PeriodRecordSource.NOTIFICATION_CONFIRMATION,
|
PeriodRecordSource.NOTIFICATION_CONFIRMATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The day before the one asked about — not the day before
|
||||||
|
// the tap, which would drift exactly like the bug the dated
|
||||||
|
// actions fixed.
|
||||||
|
ReminderAction.STARTED_YESTERDAY ->
|
||||||
|
repository.confirmPeriodStart(
|
||||||
|
request.date!!.minusDays(1),
|
||||||
|
PeriodRecordSource.NOTIFICATION_CONFIRMATION,
|
||||||
|
)
|
||||||
|
|
||||||
// Exactly what the Today screen's "Not yet" does — same
|
// Exactly what the Today screen's "Not yet" does — same
|
||||||
// call, same censoring observation, same re-conditioned
|
// call, same censoring observation, same re-conditioned
|
||||||
// forecast — but dated to the day that was asked about.
|
// forecast — but dated to the day that was asked about.
|
||||||
|
|
|
||||||
|
|
@ -108,9 +108,17 @@ class NotificationActionHandlerTest {
|
||||||
val end = NotificationCopy.buttons(ReminderKind.PERIOD_END_CHECK_IN, privacy)
|
val end = NotificationCopy.buttons(ReminderKind.PERIOD_END_CHECK_IN, privacy)
|
||||||
assertEquals(listOf(ReminderAction.ENDED, ReminderAction.STILL_GOING), end.map { it.action })
|
assertEquals(listOf(ReminderAction.ENDED, ReminderAction.STILL_GOING), end.map { it.action })
|
||||||
|
|
||||||
|
// The property, not the exact list: the follow-up gained a third
|
||||||
|
// button (§30's "Yes, yesterday") and the invariant is unchanged —
|
||||||
|
// a question about a start never offers an end.
|
||||||
listOf(ReminderKind.DID_IT_START, ReminderKind.PERIOD_EXPECTED_TODAY).forEach { kind ->
|
listOf(ReminderKind.DID_IT_START, ReminderKind.PERIOD_EXPECTED_TODAY).forEach { kind ->
|
||||||
val start = NotificationCopy.buttons(kind, privacy)
|
val start = NotificationCopy.buttons(kind, privacy).map { it.action }
|
||||||
assertEquals(listOf(ReminderAction.STARTED, ReminderAction.NOT_YET), start.map { it.action })
|
assertTrue("$kind offered no way to say it started", ReminderAction.STARTED in start)
|
||||||
|
assertTrue("$kind offered no way to say not yet", ReminderAction.NOT_YET in start)
|
||||||
|
assertTrue(
|
||||||
|
"$kind offered an ending for a question about a start",
|
||||||
|
start.none { it == ReminderAction.ENDED || it == ReminderAction.STILL_GOING },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
(end + NotificationCopy.buttons(ReminderKind.DID_IT_START, privacy)).forEach {
|
(end + NotificationCopy.buttons(ReminderKind.DID_IT_START, privacy)).forEach {
|
||||||
|
|
@ -244,4 +252,16 @@ class NotificationActionHandlerTest {
|
||||||
|
|
||||||
assertTrue(repo.confirmedPeriods.first().isEmpty())
|
assertTrue(repo.confirmedPeriods.first().isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `yes-yesterday records the day before the one asked about`() = runTest {
|
||||||
|
// Not the day before the tap. The reminder asked about yesterday and is
|
||||||
|
// answered this morning: "yesterday" means the day before the question,
|
||||||
|
// or it drifts exactly like the bug dated actions fixed.
|
||||||
|
assertTrue(
|
||||||
|
handler.handle(ReminderActionRequest(ReminderAction.STARTED_YESTERDAY, today.minusDays(1))),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(today.minusDays(2), repo.confirmedPeriods.first().single().startDate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -157,12 +157,25 @@ object NotificationCopy {
|
||||||
fun buttons(kind: ReminderKind, privacy: NotificationPrivacy): List<ReminderButton> {
|
fun buttons(kind: ReminderKind, privacy: NotificationPrivacy): List<ReminderButton> {
|
||||||
val direct = privacy == NotificationPrivacy.DIRECT
|
val direct = privacy == NotificationPrivacy.DIRECT
|
||||||
return when (kind) {
|
return when (kind) {
|
||||||
// "Has it started?" — a start, or a censoring observation.
|
// The day it was expected: it either has or it has not.
|
||||||
ReminderKind.DID_IT_START, ReminderKind.PERIOD_EXPECTED_TODAY -> listOf(
|
ReminderKind.PERIOD_EXPECTED_TODAY -> listOf(
|
||||||
ReminderButton(ReminderAction.STARTED, if (direct) "Started" else "Yes"),
|
ReminderButton(ReminderAction.STARTED, if (direct) "Started" else "Yes"),
|
||||||
ReminderButton(ReminderAction.NOT_YET, "Not yet"),
|
ReminderButton(ReminderAction.NOT_YET, "Not yet"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The follow-up, a couple of days later, where "yes" is ambiguous —
|
||||||
|
// §30 asks for the day to be pinned down. Three buttons, not §30's
|
||||||
|
// four: Android reliably renders three, and a fourth that only
|
||||||
|
// appears on some devices is worse than one that never does. The
|
||||||
|
// missing one is "Choose Date", which needs a screen rather than a
|
||||||
|
// button; logging an older start in the app is the way to that
|
||||||
|
// today.
|
||||||
|
ReminderKind.DID_IT_START -> listOf(
|
||||||
|
ReminderButton(ReminderAction.STARTED, if (direct) "Today" else "Yes, today"),
|
||||||
|
ReminderButton(ReminderAction.STARTED_YESTERDAY, if (direct) "Yesterday" else "Yes, yesterday"),
|
||||||
|
ReminderButton(ReminderAction.NOT_YET, "Not yet"),
|
||||||
|
)
|
||||||
|
|
||||||
// "Is it over?" — an end, or nothing at all. Never a start.
|
// "Is it over?" — an end, or nothing at all. Never a start.
|
||||||
ReminderKind.PERIOD_END_CHECK_IN -> listOf(
|
ReminderKind.PERIOD_END_CHECK_IN -> listOf(
|
||||||
ReminderButton(ReminderAction.ENDED, if (direct) "Ended" else "Done"),
|
ReminderButton(ReminderAction.ENDED, if (direct) "Ended" else "Done"),
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,19 @@ package dev.privacyllc.period.core.notifications
|
||||||
* nothing" rather than guessing.
|
* nothing" rather than guessing.
|
||||||
*/
|
*/
|
||||||
enum class ReminderAction {
|
enum class ReminderAction {
|
||||||
/** The period began. Confirms a start. */
|
/** The period began on the day the reminder was asking about. */
|
||||||
STARTED,
|
STARTED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The period began the day *before* the one the reminder asked about.
|
||||||
|
*
|
||||||
|
* §30's "Yes, Yesterday". Cheap only because the answer already carries the
|
||||||
|
* day it was about: this is that day minus one, not "yesterday" measured from
|
||||||
|
* whenever the tap happened, which would drift exactly like the bug dated
|
||||||
|
* actions fixed.
|
||||||
|
*/
|
||||||
|
STARTED_YESTERDAY,
|
||||||
|
|
||||||
/** It has not begun yet. Censors the forecast — §13. */
|
/** It has not begun yet. Censors the forecast — §13. */
|
||||||
NOT_YET,
|
NOT_YET,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ object ReminderActionRules {
|
||||||
// Already answered. She logged a start on or after the day the
|
// Already answered. She logged a start on or after the day the
|
||||||
// notification was asking about, so the question is settled and a
|
// notification was asking about, so the question is settled and a
|
||||||
// second write would either duplicate it or contradict it.
|
// second write would either duplicate it or contradict it.
|
||||||
ReminderAction.STARTED, ReminderAction.NOT_YET ->
|
ReminderAction.STARTED, ReminderAction.STARTED_YESTERDAY, ReminderAction.NOT_YET ->
|
||||||
if (latest != null && !latest.startDate.isBefore(date)) Verdict.Stale else Verdict.Apply
|
if (latest != null && !latest.startDate.isBefore(date)) Verdict.Stale else Verdict.Apply
|
||||||
|
|
||||||
// Ending needs something open that began on or before the day in
|
// Ending needs something open that began on or before the day in
|
||||||
|
|
|
||||||
|
|
@ -190,4 +190,33 @@ class NotificationCopyTest {
|
||||||
assertTrue(NotificationCopy.stopAskingText(it).publicTitle.isNotBlank())
|
assertTrue(NotificationCopy.stopAskingText(it).publicTitle.isNotBlank())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// §30's follow-up, within the platform's limit
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test fun `the follow-up pins down which day, without a fourth button`() {
|
||||||
|
NotificationPrivacy.entries.forEach { privacy ->
|
||||||
|
val buttons = NotificationCopy.buttons(ReminderKind.DID_IT_START, privacy)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(ReminderAction.STARTED, ReminderAction.STARTED_YESTERDAY, ReminderAction.NOT_YET),
|
||||||
|
buttons.map { it.action },
|
||||||
|
)
|
||||||
|
// Three, not §30's four: Android reliably renders three, and a
|
||||||
|
// button that appears on some devices is worse than one that never
|
||||||
|
// does.
|
||||||
|
assertTrue("Android shows three actions", buttons.size <= 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `the follow-up's day labels say nothing outside Direct`() {
|
||||||
|
NotificationPrivacy.entries.filter { it != NotificationPrivacy.DIRECT }.forEach { privacy ->
|
||||||
|
val labels = NotificationCopy.buttons(ReminderKind.DID_IT_START, privacy)
|
||||||
|
.joinToString(" ") { it.label }.lowercase()
|
||||||
|
NotificationCopy.SENSITIVE_WORDS.forEach {
|
||||||
|
assertFalse("$privacy leaked \"$it\" in a button", labels.contains(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,21 @@ have replaced it.
|
||||||
|
|
||||||
## The lock screen is the one semi-public surface
|
## The lock screen is the one semi-public surface
|
||||||
|
|
||||||
|
### Three buttons, because Android shows three
|
||||||
|
|
||||||
|
§30 asks the follow-up reminder for four: *Yes, Today*, *Yes, Yesterday*,
|
||||||
|
*Choose Date*, *Not Yet*. Android reliably renders three notification actions,
|
||||||
|
and a button that appears on some devices and not others is worse than one that
|
||||||
|
never appears at all — it is a feature the user learns and then loses.
|
||||||
|
|
||||||
|
So the follow-up offers the three that fit, and the one left out is *Choose
|
||||||
|
Date*, which was never really a button: it needs a screen. Logging an older start
|
||||||
|
in the app is the route to that today, and the notification opens the app.
|
||||||
|
|
||||||
|
"Yes, yesterday" is cheap only because the answer already carries the day it was
|
||||||
|
about. It means the day before the *question*, never the day before the tap —
|
||||||
|
otherwise it drifts exactly like the defect dated actions fixed.
|
||||||
|
|
||||||
### When the copy and the platform disagree, the copy moves
|
### When the copy and the platform disagree, the copy moves
|
||||||
|
|
||||||
The reminders screen promised delivery "a few minutes either side of this time".
|
The reminders screen promised delivery "a few minutes either side of this time".
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue