feat(todo): a place reminds once a day, from the task's day on (ADR 0033, D32)
A task for tomorrow stays quiet today; an overdue task or one without a day reminds on each day the phone arrives; a daily task once a day, not again after it was ticked off in the morning. Todoist's location reminders ignore the due date, which r/todoist's reports show reminding of a weekly task on every arrival and of a daily one up to twenty times a day; once until the task changed had both faults and lost the shopping list left open to remind on every visit. The device keeps which tasks reminded today, and an inexact alarm at midnight lets the new day's places wait. A place waiting again on a new day reminds only after the phone crosses its circle, so a task whose place is home doesn't remind just after midnight. Whats-New: A task's place reminds once a day, and not before the task's day. Whats-New-De: Der Ort einer Aufgabe erinnert einmal am Tag und nicht vor ihrem Tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
c55e94dd99
commit
30ee2a9ca3
9 changed files with 242 additions and 31 deletions
|
|
@ -0,0 +1,33 @@
|
||||||
|
package app.halkyon.todo
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.time.format.DateTimeParseException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a place reminds (ADR 0033, D32's amendment of 7 Nov 2026): once a day,
|
||||||
|
* from the task's day on. A task for tomorrow stays quiet today; an overdue
|
||||||
|
* one, or one without a day, reminds on each day the phone arrives; a daily
|
||||||
|
* one once a day, not again after it was ticked off in the morning.
|
||||||
|
*/
|
||||||
|
object LocationDays {
|
||||||
|
/** Whether a task for [from] (`YYYY-MM-DD`, null without a day) reminds [today]. */
|
||||||
|
fun due(from: String?, today: LocalDate): Boolean {
|
||||||
|
val day = from?.let {
|
||||||
|
try {
|
||||||
|
LocalDate.parse(it)
|
||||||
|
} catch (_: DateTimeParseException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} ?: return true
|
||||||
|
return !day.isAfter(today)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What marks the task [taskId], as [stamp] says it is, as having reminded on [day]. */
|
||||||
|
fun done(taskId: String, stamp: Int, day: LocalDate): String = "$taskId:$stamp:$day"
|
||||||
|
|
||||||
|
/** When the day after [now]'s starts, where [now] is. */
|
||||||
|
fun nextDay(now: ZonedDateTime): Instant =
|
||||||
|
now.toLocalDate().plusDays(1).atStartOfDay(now.zone).toInstant()
|
||||||
|
}
|
||||||
|
|
@ -52,8 +52,14 @@ object LocationDwell {
|
||||||
fun insideAfter(before: Boolean?, distance: Float): Boolean =
|
fun insideAfter(before: Boolean?, distance: Float): Boolean =
|
||||||
distance <= (if (before == true) INSIDE_METRES else RADIUS_METRES)
|
distance <= (if (before == true) INSIDE_METRES else RADIUS_METRES)
|
||||||
|
|
||||||
/** Whether that crossed the circle; a first fix outside crossed nothing. */
|
/**
|
||||||
fun crossed(before: Boolean?, inside: Boolean): Boolean = (before ?: false) != inside
|
* Whether that crossed the circle. A first fix outside crossed nothing; one
|
||||||
|
* inside did only for a place [fresh]ly planned (a task made or changed
|
||||||
|
* there), not for one waiting again on a new day: at home over midnight
|
||||||
|
* is no arriving.
|
||||||
|
*/
|
||||||
|
fun crossed(before: Boolean?, inside: Boolean, fresh: Boolean): Boolean =
|
||||||
|
if (before == null) fresh && inside else before != inside
|
||||||
|
|
||||||
/** Whether the phone, [moved] metres since the last fix, lies still. */
|
/** Whether the phone, [moved] metres since the last fix, lies still. */
|
||||||
fun still(moved: Float): Boolean = moved < STILL_METRES
|
fun still(moved: Float): Boolean = moved < STILL_METRES
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,19 @@ import android.content.Intent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the system tells of the places watched (ADR 0033, D32): the check
|
* What the system tells of the places watched (ADR 0033, D32): the check
|
||||||
* after the phone crossed a circle, and a restart or an update, after which
|
* after the phone crossed a circle, midnight, and a restart or an update,
|
||||||
* the watching starts anew.
|
* after which the watching starts anew.
|
||||||
*/
|
*/
|
||||||
class LocationReminderReceiver : BroadcastReceiver() {
|
class LocationReminderReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> {
|
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> {
|
||||||
LocationReminders.log(context, "watching anew: ${intent.action}")
|
LocationReminders.log(context, "watching anew: ${intent.action}")
|
||||||
|
// A restart forgets alarms.
|
||||||
|
LocationReminders.awaitDay(context)
|
||||||
LocationWatchService.sync(context)
|
LocationWatchService.sync(context)
|
||||||
}
|
}
|
||||||
|
DAY -> LocationReminders.newDay(context)
|
||||||
CHECK -> {
|
CHECK -> {
|
||||||
val id = intent.getIntExtra(ID, 0)
|
val id = intent.getIntExtra(ID, 0)
|
||||||
val watch = LocationReminders.watches(context).find { it.id == id } ?: return
|
val watch = LocationReminders.watches(context).find { it.id == id } ?: return
|
||||||
|
|
@ -26,6 +29,7 @@ class LocationReminderReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val CHECK = "app.halkyon.todo.LOCATION_CHECK"
|
const val CHECK = "app.halkyon.todo.LOCATION_CHECK"
|
||||||
|
const val DAY = "app.halkyon.todo.LOCATION_DAY"
|
||||||
const val ID = "id"
|
const val ID = "id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ import android.os.Build
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZonedDateTime
|
||||||
import io.flutter.plugin.common.BinaryMessenger
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
|
|
@ -34,14 +36,20 @@ data class LocationWatch(
|
||||||
val lat: Double,
|
val lat: Double,
|
||||||
val lon: Double,
|
val lon: Double,
|
||||||
val arrive: Boolean,
|
val arrive: Boolean,
|
||||||
|
/** The task's day, `YYYY-MM-DD`: the first it reminds on. Null without one. */
|
||||||
|
val from: String?,
|
||||||
) {
|
) {
|
||||||
fun json(): JSONObject = JSONObject()
|
fun json(): JSONObject = JSONObject()
|
||||||
.put("id", id).put("taskId", taskId).put("stamp", stamp)
|
.put("id", id).put("taskId", taskId).put("stamp", stamp)
|
||||||
.put("title", title).put("body", body)
|
.put("title", title).put("body", body)
|
||||||
.put("lat", lat).put("lon", lon).put("arrive", arrive)
|
.put("lat", lat).put("lon", lon).put("arrive", arrive)
|
||||||
|
.apply { if (from != null) put("from", from) }
|
||||||
|
|
||||||
/** What marks this task as having reminded, until it changes. */
|
/** The task as it is: a change makes it a place just planned. */
|
||||||
val done get() = "$taskId:$stamp"
|
val version get() = "$taskId:$stamp"
|
||||||
|
|
||||||
|
/** What marks this task as having reminded on [day], until it changes. */
|
||||||
|
fun doneOn(day: LocalDate) = LocationDays.done(taskId, stamp, day)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun of(json: JSONObject) = LocationWatch(
|
fun of(json: JSONObject) = LocationWatch(
|
||||||
|
|
@ -53,6 +61,7 @@ data class LocationWatch(
|
||||||
lat = json.getDouble("lat"),
|
lat = json.getDouble("lat"),
|
||||||
lon = json.getDouble("lon"),
|
lon = json.getDouble("lon"),
|
||||||
arrive = json.getBoolean("arrive"),
|
arrive = json.getBoolean("arrive"),
|
||||||
|
from = if (json.isNull("from")) null else json.getString("from"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +71,9 @@ data class LocationWatch(
|
||||||
* need no Google services and stay on the device, as the instance reads no
|
* need no Google services and stay on the device, as the instance reads no
|
||||||
* task. What they need is kept in the app's private preferences, unsealed as
|
* task. What they need is kept in the app's private preferences, unsealed as
|
||||||
* the reminders scheduled are (`allowBackup` is off): the places, which tasks
|
* the reminders scheduled are (`allowBackup` is off): the places, which tasks
|
||||||
* have reminded, and whether the phone is inside each.
|
* have reminded today, which places were just planned, and whether the phone
|
||||||
|
* is inside each. A place reminds once a day, from its task's day on
|
||||||
|
* ([LocationDays]); an alarm at midnight lets the new day's places wait.
|
||||||
*/
|
*/
|
||||||
object LocationReminders {
|
object LocationReminders {
|
||||||
private const val CHANNEL = "app.halkyon.todo/location_reminders"
|
private const val CHANNEL = "app.halkyon.todo/location_reminders"
|
||||||
|
|
@ -161,12 +172,21 @@ object LocationReminders {
|
||||||
private fun fired(context: Context): Set<String> =
|
private fun fired(context: Context): Set<String> =
|
||||||
store(context).getStringSet("fired", emptySet()).orEmpty()
|
store(context).getStringSet("fired", emptySet()).orEmpty()
|
||||||
|
|
||||||
private fun registered(context: Context): Set<String> =
|
/** The IDs of places just planned, whose first fix inside counts as arriving. */
|
||||||
store(context).getStringSet("registered", emptySet()).orEmpty()
|
private fun fresh(context: Context): Set<String> =
|
||||||
|
store(context).getStringSet("fresh", emptySet()).orEmpty()
|
||||||
|
|
||||||
/** The places still to remind of: what the service watches. */
|
private fun today(): LocalDate = LocalDate.now()
|
||||||
fun waiting(context: Context): List<LocationWatch> =
|
|
||||||
watches(context).filter { it.done !in fired(context) }
|
/** Whether [watch] is still to remind of today. */
|
||||||
|
private fun waits(context: Context, watch: LocationWatch, today: LocalDate = today()) =
|
||||||
|
LocationDays.due(watch.from, today) && watch.doneOn(today) !in fired(context)
|
||||||
|
|
||||||
|
/** The places still to remind of today: what the service watches. */
|
||||||
|
fun waiting(context: Context): List<LocationWatch> {
|
||||||
|
val today = today()
|
||||||
|
return watches(context).filter { waits(context, it, today) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether the service may watch: it runs with the app closed. */
|
/** Whether the service may watch: it runs with the app closed. */
|
||||||
fun mayWatch(context: Context): Boolean = access(context) == "always"
|
fun mayWatch(context: Context): Boolean = access(context) == "always"
|
||||||
|
|
@ -191,19 +211,62 @@ object LocationReminders {
|
||||||
for (watch in before) {
|
for (watch in before) {
|
||||||
if (watch.id !in ids) stop(context, watch)
|
if (watch.id !in ids) stop(context, watch)
|
||||||
}
|
}
|
||||||
val dones = watches.map { it.done }.toSet()
|
val today = today()
|
||||||
|
val dones = watches.map { it.doneOn(today) }.toSet()
|
||||||
|
// A task made or changed while the phone is at its place reminds there.
|
||||||
|
val versions = before.map { it.version }.toSet()
|
||||||
|
val fresh = (fresh(context) intersect ids.map { "$it" }.toSet()) +
|
||||||
|
watches.filter { it.version !in versions && LocationDays.due(it.from, today) }.map { "${it.id}" }
|
||||||
store(context).edit()
|
store(context).edit()
|
||||||
.putString("watches", JSONArray(watches.map { it.json() }).toString())
|
.putString("watches", JSONArray(watches.map { it.json() }).toString())
|
||||||
.putString("channel", channel)
|
.putString("channel", channel)
|
||||||
.putString("watching", watching)
|
.putString("watching", watching)
|
||||||
.putString("watchingChannel", watchingChannel)
|
.putString("watchingChannel", watchingChannel)
|
||||||
.putStringSet("fired", fired(context).filter { it in dones }.toSet())
|
.putStringSet("fired", fired(context).filter { it in dones }.toSet())
|
||||||
|
.putStringSet("fresh", fresh)
|
||||||
.apply()
|
.apply()
|
||||||
log(context, "plan: ${watches.size} places, ${before.size} before")
|
log(context, "plan: ${watches.size} places, ${before.size} before")
|
||||||
// A task that has reminded and then changes reminds anew; one that hasn't
|
// A task that has reminded and then changes reminds anew; one that hasn't
|
||||||
// changed stays silent.
|
// changed stays silent.
|
||||||
val waiting = waiting(context)
|
val waiting = waiting(context)
|
||||||
for (watch in watches - waiting.toSet()) stop(context, watch)
|
for (watch in watches - waiting.toSet()) stop(context, watch)
|
||||||
|
awaitDay(context)
|
||||||
|
LocationWatchService.sync(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dayIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
Intent(context, LocationReminderReceiver::class.java).setAction(LocationReminderReceiver.DAY),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An alarm at midnight while a place waits for a later day (a task for
|
||||||
|
* tomorrow, one that reminded today); none otherwise. Inexact: a few
|
||||||
|
* minutes late is no harm, and it needs no permission.
|
||||||
|
*/
|
||||||
|
fun awaitDay(context: Context) {
|
||||||
|
val alarms = context.getSystemService(AlarmManager::class.java)
|
||||||
|
val waiting = waiting(context).toSet()
|
||||||
|
if (watches(context).all { it in waiting }) {
|
||||||
|
alarms.cancel(dayIntent(context))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val at = LocationDays.nextDay(ZonedDateTime.now()).toEpochMilli()
|
||||||
|
alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, dayIntent(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new day: yesterday's reminders forgotten, its places waiting again. */
|
||||||
|
fun newDay(context: Context) {
|
||||||
|
val dones = watches(context).map { it.doneOn(today()) }.toSet()
|
||||||
|
store(context).edit()
|
||||||
|
.putStringSet("fired", fired(context).filter { it in dones }.toSet())
|
||||||
|
// Only a crossing reminds now: at home over midnight is no arriving.
|
||||||
|
.putStringSet("fresh", emptySet())
|
||||||
|
.apply()
|
||||||
|
log(context, "new day: ${waiting(context).size} places waiting")
|
||||||
|
awaitDay(context)
|
||||||
LocationWatchService.sync(context)
|
LocationWatchService.sync(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,7 +293,9 @@ object LocationReminders {
|
||||||
val kept = store(context)
|
val kept = store(context)
|
||||||
val before = if (kept.contains(key)) kept.getBoolean(key, false) else null
|
val before = if (kept.contains(key)) kept.getBoolean(key, false) else null
|
||||||
val inside = LocationDwell.insideAfter(before, metres[0])
|
val inside = LocationDwell.insideAfter(before, metres[0])
|
||||||
if (LocationDwell.crossed(before, inside)) {
|
val fresh = "${watch.id}" in fresh(context)
|
||||||
|
if (fresh) kept.edit().putStringSet("fresh", fresh(context) - "${watch.id}").apply()
|
||||||
|
if (LocationDwell.crossed(before, inside, fresh)) {
|
||||||
crossed(context, watch, inside)
|
crossed(context, watch, inside)
|
||||||
} else {
|
} else {
|
||||||
kept.edit().putBoolean(key, inside).apply()
|
kept.edit().putBoolean(key, inside).apply()
|
||||||
|
|
@ -269,7 +334,7 @@ object LocationReminders {
|
||||||
|
|
||||||
/** The check: reminds if the phone is still on that side. */
|
/** The check: reminds if the phone is still on that side. */
|
||||||
fun check(context: Context, watch: LocationWatch) {
|
fun check(context: Context, watch: LocationWatch) {
|
||||||
if (watch.done in fired(context)) return
|
if (!waits(context, watch)) return
|
||||||
val event = store(context).getBoolean("inside ${watch.id}", false)
|
val event = store(context).getBoolean("inside ${watch.id}", false)
|
||||||
val fix = lastKnown(context)
|
val fix = lastKnown(context)
|
||||||
val distance = fix?.let {
|
val distance = fix?.let {
|
||||||
|
|
@ -284,13 +349,15 @@ object LocationReminders {
|
||||||
if (notify) fire(context, watch)
|
if (notify) fire(context, watch)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reminds once: the task's other places stop until it changes. */
|
/** Reminds once today: the task's other places stop until tomorrow or it changes. */
|
||||||
private fun fire(context: Context, watch: LocationWatch) {
|
private fun fire(context: Context, watch: LocationWatch) {
|
||||||
for (other in watches(context)) {
|
for (other in watches(context)) {
|
||||||
if (other.taskId == watch.taskId) stop(context, other)
|
if (other.taskId == watch.taskId) stop(context, other)
|
||||||
}
|
}
|
||||||
store(context).edit().putStringSet("fired", fired(context) + watch.done).apply()
|
store(context).edit().putStringSet("fired", fired(context) + watch.doneOn(today())).apply()
|
||||||
// Nothing left to watch, the service and its notification go.
|
// It waits for tomorrow; nothing left to watch, the service and its
|
||||||
|
// notification go.
|
||||||
|
awaitDay(context)
|
||||||
LocationWatchService.sync(context)
|
LocationWatchService.sync(context)
|
||||||
val notifications = context.getSystemService(NotificationManager::class.java)
|
val notifications = context.getSystemService(NotificationManager::class.java)
|
||||||
if (notifications.getNotificationChannel(NOTIFICATION_CHANNEL) == null) {
|
if (notifications.getNotificationChannel(NOTIFICATION_CHANNEL) == null) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package app.halkyon.todo
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LocationDaysTest {
|
||||||
|
private val today = LocalDate.of(2026, 11, 7)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a task for tomorrow stays quiet today`() {
|
||||||
|
assertFalse(LocationDays.due(from = "2026-11-08", today = today))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a task for today, an overdue one and one without a day remind`() {
|
||||||
|
assertTrue(LocationDays.due(from = "2026-11-07", today = today))
|
||||||
|
assertTrue(LocationDays.due(from = "2026-11-01", today = today))
|
||||||
|
assertTrue(LocationDays.due(from = null, today = today))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a day it can't read doesn't silence a task`() {
|
||||||
|
assertTrue(LocationDays.due(from = "someday", today = today))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reminding marks the day, so the next day reminds again`() {
|
||||||
|
val mark = LocationDays.done(taskId = "a", stamp = 7, day = today)
|
||||||
|
assertEquals(mark, LocationDays.done(taskId = "a", stamp = 7, day = today))
|
||||||
|
assertNotEquals(mark, LocationDays.done(taskId = "a", stamp = 7, day = today.plusDays(1)))
|
||||||
|
assertNotEquals(mark, LocationDays.done(taskId = "a", stamp = 8, day = today))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the next day starts at midnight, a change of the clocks too`() {
|
||||||
|
val berlin = ZoneId.of("Europe/Berlin")
|
||||||
|
val evening = ZonedDateTime.of(2026, 10, 24, 22, 30, 0, 0, berlin)
|
||||||
|
assertEquals(
|
||||||
|
ZonedDateTime.of(2026, 10, 25, 0, 0, 0, 0, berlin).toInstant(),
|
||||||
|
LocationDays.nextDay(evening),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -64,12 +64,20 @@ class LocationDwellTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the first fix crosses only by being inside`() {
|
fun `the first fix of a place just planned crosses only by being inside`() {
|
||||||
assertTrue(LocationDwell.crossed(before = null, inside = true))
|
assertTrue(LocationDwell.crossed(before = null, inside = true, fresh = true))
|
||||||
assertFalse(LocationDwell.crossed(before = null, inside = false))
|
assertFalse(LocationDwell.crossed(before = null, inside = false, fresh = true))
|
||||||
assertTrue(LocationDwell.crossed(before = false, inside = true))
|
assertTrue(LocationDwell.crossed(before = false, inside = true, fresh = true))
|
||||||
assertTrue(LocationDwell.crossed(before = true, inside = false))
|
assertTrue(LocationDwell.crossed(before = true, inside = false, fresh = true))
|
||||||
assertFalse(LocationDwell.crossed(before = true, inside = true))
|
assertFalse(LocationDwell.crossed(before = true, inside = true, fresh = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a place waiting again on a new day reminds only after crossing`() {
|
||||||
|
// At home over midnight, where a task for the new day reminds on arriving.
|
||||||
|
assertFalse(LocationDwell.crossed(before = null, inside = true, fresh = false))
|
||||||
|
assertTrue(LocationDwell.crossed(before = false, inside = true, fresh = false))
|
||||||
|
assertTrue(LocationDwell.crossed(before = true, inside = false, fresh = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ import 'package:halkyon_todo_flutter/src/feature/reminder/model/reminder_plan.da
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||||
|
|
||||||
/// A place the device is to watch for the task [taskId] (ADR 0033, D32): on
|
/// A place the device is to watch for the task [taskId] (ADR 0033, D32): on
|
||||||
/// [arrive] or leaving it, it shows [title]. [id] is the same on every plan;
|
/// [arrive] or leaving it, it shows [title], once a day, from the day [from]
|
||||||
/// [stamp] changes with the task, so a task that has reminded does again
|
/// on (any day without one). [id] is the same on every plan; [stamp] changes
|
||||||
/// once it changes.
|
/// with the task, so a task that has reminded does again once it changes.
|
||||||
final class const LocationWatch({
|
final class const LocationWatch({
|
||||||
required final int id,
|
required final int id,
|
||||||
required final String taskId,
|
required final String taskId,
|
||||||
|
|
@ -17,6 +17,9 @@ final class const LocationWatch({
|
||||||
required final double lon,
|
required final double lon,
|
||||||
required final bool arrive,
|
required final bool arrive,
|
||||||
|
|
||||||
|
/// The task's day: the first it reminds on. Null for a task without one.
|
||||||
|
final Day? from,
|
||||||
|
|
||||||
/// What its notification says below [title], in the app's language.
|
/// What its notification says below [title], in the app's language.
|
||||||
final String body = '',
|
final String body = '',
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
|
|
@ -30,6 +33,7 @@ final class const LocationWatch({
|
||||||
lat: lat,
|
lat: lat,
|
||||||
lon: lon,
|
lon: lon,
|
||||||
arrive: arrive,
|
arrive: arrive,
|
||||||
|
from: from,
|
||||||
body: body,
|
body: body,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -43,6 +47,7 @@ final class const LocationWatch({
|
||||||
'lat': lat,
|
'lat': lat,
|
||||||
'lon': lon,
|
'lon': lon,
|
||||||
'arrive': arrive,
|
'arrive': arrive,
|
||||||
|
'from': ?from?.toString(),
|
||||||
'body': body,
|
'body': body,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -56,11 +61,13 @@ final class const LocationWatch({
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
arrive,
|
arrive,
|
||||||
|
from,
|
||||||
body,
|
body,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The places to watch: every location with a reminder of each open task.
|
/// The places to watch: every location with a reminder of each open task,
|
||||||
|
/// from the task's day on (ADR 0033, D32's amendment of 7 Nov 2026).
|
||||||
List<LocationWatch> planLocationWatches(List<StoredTask> tasks) => [
|
List<LocationWatch> planLocationWatches(List<StoredTask> tasks) => [
|
||||||
for (final StoredTask(:id, :content) in tasks)
|
for (final StoredTask(:id, :content) in tasks)
|
||||||
if (content case TaskContent(completed: false, :final locations))
|
if (content case TaskContent(completed: false, :final locations))
|
||||||
|
|
@ -78,6 +85,7 @@ List<LocationWatch> planLocationWatches(List<StoredTask> tasks) => [
|
||||||
lat: location.lat,
|
lat: location.lat,
|
||||||
lon: location.lon,
|
lon: location.lon,
|
||||||
arrive: location.trigger == LocationTrigger.arrive,
|
arrive: location.trigger == LocationTrigger.arrive,
|
||||||
|
from: content.date?.day,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,27 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a place reminds from the day the task is for on, or any day '
|
||||||
|
'without one', () {
|
||||||
|
expect(
|
||||||
|
planLocationWatches([
|
||||||
|
task('a', date: const TaskDate(Day(2026, 11, 8), time: 9 * 60)),
|
||||||
|
]).single.from,
|
||||||
|
const Day(2026, 11, 8),
|
||||||
|
);
|
||||||
|
expect(planLocationWatches([task('a')]).single.from, isNull);
|
||||||
|
expect(
|
||||||
|
planLocationWatches([
|
||||||
|
task('a', date: const TaskDate(Day(2026, 11, 8))),
|
||||||
|
]).single.toJson()['from'],
|
||||||
|
'2026-11-08',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
planLocationWatches([task('a')]).single.toJson(),
|
||||||
|
isNot(contains('from')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('a changed task reminds anew: its stamp changes with what it is', () {
|
test('a changed task reminds anew: its stamp changes with what it is', () {
|
||||||
final stamp = planLocationWatches([task('a')]).single.stamp;
|
final stamp = planLocationWatches([task('a')]).single.stamp;
|
||||||
expect(planLocationWatches([task('a')]).single.stamp, stamp);
|
expect(planLocationWatches([task('a')]).single.stamp, stamp);
|
||||||
|
|
|
||||||
|
|
@ -696,9 +696,24 @@ The developer's review of 4 Nov 2026, again beside Todoist's screens.
|
||||||
with JUnit tests CI doesn't run yet), by a fix at most 90 s old where there is one,
|
with JUnit tests CI doesn't run yet), by a fix at most 90 s old where there is one,
|
||||||
else by the last crossing. A red light of over
|
else by the last crossing. A red light of over
|
||||||
two minutes beside the shop still reminds; that is accepted for now.
|
two minutes beside the shop still reminds; that is accepted for now.
|
||||||
- *Once*: a task reminds once until it changes (title, day, places: `stamp`), and an
|
- *Once a day, from the task's day on* (amended 7 Nov 2026, the developer's; before,
|
||||||
alternative that reminded stops the task's others. The notification goes on the
|
*once*: a task reminded once until it changed, whatever its day): a task reminds
|
||||||
reminders' channel and opens the app.
|
on arriving or leaving once a day, and not before its day. A task for tomorrow stays
|
||||||
|
quiet today; an overdue one or one without a day reminds on each day the phone
|
||||||
|
arrives, as a shopping list left open does; a daily one once a day. Changing it
|
||||||
|
(title, day, places: `stamp`) lets it remind again the same day, and an alternative
|
||||||
|
that reminded stops the task's others for the day. Todoist's reports gave the reasons
|
||||||
|
(r/todoist, read by the developer, as Reddit refuses the agent): its location
|
||||||
|
reminders ignore the due date, so a weekly task reminded on every arrival on the
|
||||||
|
other days and a daily one up to twenty times a day, as each tick made tomorrow's
|
||||||
|
task, which reminded on the next arrival; while people kept open tasks there to be
|
||||||
|
reminded on every visit. Once until it changed had the first two faults and lost
|
||||||
|
the third. The device keeps which tasks reminded today (`LocationDays`) and an
|
||||||
|
inexact alarm at midnight lets the new day's places wait (none while every place
|
||||||
|
waits). A place waiting again on a new day reminds only after the phone crosses its
|
||||||
|
circle, so a task for today whose place is home doesn't remind just after midnight;
|
||||||
|
a task made or changed at its place still reminds there on the first fix. The
|
||||||
|
notification goes on the reminders' channel and opens the app.
|
||||||
- *Permission*: the position first, then, told why, *Allow all the time* in the
|
- *Permission*: the position first, then, told why, *Allow all the time* in the
|
||||||
system's settings, asked as a person chooses such a reminder and never by planning
|
system's settings, asked as a person chooses such a reminder and never by planning
|
||||||
(a task may sync in); Settings' Reminders says while it is off, with Turn on, as D31.
|
(a task may sync in); Settings' Reminders says while it is off, with Turn on, as D31.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue