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
8 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 =
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. */
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
* after the phone crossed a circle, and a restart or an update, after which
* the watching starts anew.
* after the phone crossed a circle, midnight, and a restart or an update,
* after which the watching starts anew.
*/
class LocationReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> {
LocationReminders.log(context, "watching anew: ${intent.action}")
// A restart forgets alarms.
LocationReminders.awaitDay(context)
LocationWatchService.sync(context)
}
DAY -> LocationReminders.newDay(context)
CHECK -> {
val id = intent.getIntExtra(ID, 0)
val watch = LocationReminders.watches(context).find { it.id == id } ?: return
@ -26,6 +29,7 @@ class LocationReminderReceiver : BroadcastReceiver() {
companion object {
const val CHECK = "app.halkyon.todo.LOCATION_CHECK"
const val DAY = "app.halkyon.todo.LOCATION_DAY"
const val ID = "id"
}
}

View file

@ -19,6 +19,8 @@ import android.os.Build
import android.os.SystemClock
import android.provider.Settings
import android.util.Log
import java.time.LocalDate
import java.time.ZonedDateTime
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import org.json.JSONArray
@ -34,14 +36,20 @@ data class LocationWatch(
val lat: Double,
val lon: Double,
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()
.put("id", id).put("taskId", taskId).put("stamp", stamp)
.put("title", title).put("body", body)
.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. */
val done get() = "$taskId:$stamp"
/** The task as it is: a change makes it a place just planned. */
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 {
fun of(json: JSONObject) = LocationWatch(
@ -53,6 +61,7 @@ data class LocationWatch(
lat = json.getDouble("lat"),
lon = json.getDouble("lon"),
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
* 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
* 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 {
private const val CHANNEL = "app.halkyon.todo/location_reminders"
@ -161,12 +172,21 @@ object LocationReminders {
private fun fired(context: Context): Set<String> =
store(context).getStringSet("fired", emptySet()).orEmpty()
private fun registered(context: Context): Set<String> =
store(context).getStringSet("registered", emptySet()).orEmpty()
/** The IDs of places just planned, whose first fix inside counts as arriving. */
private fun fresh(context: Context): Set<String> =
store(context).getStringSet("fresh", emptySet()).orEmpty()
/** The places still to remind of: what the service watches. */
fun waiting(context: Context): List<LocationWatch> =
watches(context).filter { it.done !in fired(context) }
private fun today(): LocalDate = LocalDate.now()
/** 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. */
fun mayWatch(context: Context): Boolean = access(context) == "always"
@ -191,19 +211,62 @@ object LocationReminders {
for (watch in before) {
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()
.putString("watches", JSONArray(watches.map { it.json() }).toString())
.putString("channel", channel)
.putString("watching", watching)
.putString("watchingChannel", watchingChannel)
.putStringSet("fired", fired(context).filter { it in dones }.toSet())
.putStringSet("fresh", fresh)
.apply()
log(context, "plan: ${watches.size} places, ${before.size} before")
// A task that has reminded and then changes reminds anew; one that hasn't
// changed stays silent.
val waiting = waiting(context)
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)
}
@ -230,7 +293,9 @@ object LocationReminders {
val kept = store(context)
val before = if (kept.contains(key)) kept.getBoolean(key, false) else null
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)
} else {
kept.edit().putBoolean(key, inside).apply()
@ -269,7 +334,7 @@ object LocationReminders {
/** The check: reminds if the phone is still on that side. */
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 fix = lastKnown(context)
val distance = fix?.let {
@ -284,13 +349,15 @@ object LocationReminders {
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) {
for (other in watches(context)) {
if (other.taskId == watch.taskId) stop(context, other)
}
store(context).edit().putStringSet("fired", fired(context) + watch.done).apply()
// Nothing left to watch, the service and its notification go.
store(context).edit().putStringSet("fired", fired(context) + watch.doneOn(today())).apply()
// It waits for tomorrow; nothing left to watch, the service and its
// notification go.
awaitDay(context)
LocationWatchService.sync(context)
val notifications = context.getSystemService(NotificationManager::class.java)
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
fun `the first fix crosses only by being inside`() {
assertTrue(LocationDwell.crossed(before = null, inside = true))
assertFalse(LocationDwell.crossed(before = null, inside = false))
assertTrue(LocationDwell.crossed(before = false, inside = true))
assertTrue(LocationDwell.crossed(before = true, inside = false))
assertFalse(LocationDwell.crossed(before = true, inside = true))
fun `the first fix of a place just planned crosses only by being inside`() {
assertTrue(LocationDwell.crossed(before = null, inside = true, fresh = true))
assertFalse(LocationDwell.crossed(before = null, inside = false, fresh = true))
assertTrue(LocationDwell.crossed(before = false, inside = true, fresh = true))
assertTrue(LocationDwell.crossed(before = true, inside = false, fresh = 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

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';
/// 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;
/// [stamp] changes with the task, so a task that has reminded does again
/// once it changes.
/// [arrive] or leaving it, it shows [title], once a day, from the day [from]
/// on (any day without one). [id] is the same on every plan; [stamp] changes
/// with the task, so a task that has reminded does again once it changes.
final class const LocationWatch({
required final int id,
required final String taskId,
@ -17,6 +17,9 @@ final class const LocationWatch({
required final double lon,
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.
final String body = '',
}) with Equatable {
@ -30,6 +33,7 @@ final class const LocationWatch({
lat: lat,
lon: lon,
arrive: arrive,
from: from,
body: body,
);
@ -43,6 +47,7 @@ final class const LocationWatch({
'lat': lat,
'lon': lon,
'arrive': arrive,
'from': ?from?.toString(),
'body': body,
};
@ -56,11 +61,13 @@ final class const LocationWatch({
lat,
lon,
arrive,
from,
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) => [
for (final StoredTask(:id, :content) in tasks)
if (content case TaskContent(completed: false, :final locations))
@ -78,6 +85,7 @@ List<LocationWatch> planLocationWatches(List<StoredTask> tasks) => [
lat: location.lat,
lon: location.lon,
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', () {
final stamp = planLocationWatches([task('a')]).single.stamp;
expect(planLocationWatches([task('a')]).single.stamp, stamp);