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:
Tizian 「ティツィアーン」 2026-11-07 19:38:47 +01:00
commit 30ee2a9ca3
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 242 additions and 31 deletions

View file

@ -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()
}

View file

@ -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

View file

@ -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"
} }
} }

View file

@ -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) {

View file

@ -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),
)
}
}

View file

@ -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

View file

@ -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,
), ),
]; ];

View file

@ -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);

View file

@ -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.