feat(todo): places are watched by a quiet service, and asked to notify (ADR 0033, D32)

The first walk showed two faults. Android gives a proximity alert of an
app in the background a fix every 30 minutes (17:04:38, then 17:34:43 on
the Pixel), so a short stop goes unseen. And the place did remind, at
17:36:43, but nothing had asked to notify, so Android showed nothing.

A foreground service of the type location now takes its own fixes while
places wait, under the quietest notification there is: as long as
reaching the nearest circle takes at 30 m/s, doubled while the phone
lies still, between 30 s and 5 min. A place with a reminder asks to
notify as any reminder does.

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-05 18:17:48 +01:00
commit a7385e2169
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 499 additions and 125 deletions

View file

@ -292,6 +292,8 @@
"locationRemindArrive": "Beim Ankommen",
"locationRemindLeave": "Beim Verlassen",
"locationRemove": "Entfernen",
"locationWatching": "Achtet auf deine Orte",
"locationWatchingChannel": "Überwachte Orte",
"locationAccessTitle": "Standort immer erlauben",
"locationAccessHint": "Damit Halkyon Todo dich beim Ankommen oder Verlassen erinnert, braucht es deinen Standort auch, wenn es geschlossen ist. Er bleibt auf diesem Gerät. Wähle als Nächstes „Immer zulassen“.",
"locationAccessGo": "Weiter",

View file

@ -988,6 +988,8 @@
"@locationRemove": {
"description": "A task's location's menu."
},
"locationWatching": "Watching your places",
"locationWatchingChannel": "Places watched",
"locationAccessTitle": "Allow location all the time",
"@locationAccessTitle": {
"description": "Before asking for the position in the background."

View file

@ -1852,6 +1852,18 @@ abstract class AppLocalizations {
/// **'Remove'**
String get locationRemove;
/// No description provided for @locationWatching.
///
/// In en, this message translates to:
/// **'Watching your places'**
String get locationWatching;
/// No description provided for @locationWatchingChannel.
///
/// In en, this message translates to:
/// **'Places watched'**
String get locationWatchingChannel;
/// Before asking for the position in the background.
///
/// In en, this message translates to:

View file

@ -1141,6 +1141,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get locationRemove => 'Entfernen';
@override
String get locationWatching => 'Achtet auf deine Orte';
@override
String get locationWatchingChannel => 'Überwachte Orte';
@override
String get locationAccessTitle => 'Standort immer erlauben';

View file

@ -1140,6 +1140,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get locationRemove => 'Remove';
@override
String get locationWatching => 'Watching your places';
@override
String get locationWatchingChannel => 'Places watched';
@override
String get locationAccessTitle => 'Allow location all the time';

View file

@ -12,16 +12,23 @@ enum LocationAccess() {
/// A point on the map.
typedef LatLon = ({double lat, double lon});
/// Where the device watches places: the system's proximity alerts on Android
/// alone, with no Google services, on the device, as the instance reads no
/// task (D32).
/// Where the device watches places: a foreground service on Android alone,
/// with no Google services, on the device, as the instance reads no task
/// (D32).
abstract interface class LocationReminderGateway() {
/// Whether this device can watch places at all.
bool get available;
/// Makes [watches] the places watched, those watched before and no longer
/// among them stopped; [channel] names the notifications' kind.
Future<void> replace(List<LocationWatch> watches, {required String channel});
/// among them stopped; [channel] names the notifications' kind, [watching]
/// says what the quiet notification of watching says, [watchingChannel]
/// names its kind.
Future<void> replace(
List<LocationWatch> watches, {
required String channel,
String watching = '',
String watchingChannel = '',
});
/// What the device allows now.
Future<LocationAccess> access();
@ -49,6 +56,8 @@ class const NoLocationReminders() implements LocationReminderGateway {
Future<void> replace(
List<LocationWatch> watches, {
required String channel,
String watching = '',
String watchingChannel = '',
}) async {}
@override
@ -81,9 +90,13 @@ class const AndroidLocationReminders() implements LocationReminderGateway {
Future<void> replace(
List<LocationWatch> watches, {
required String channel,
String watching = '',
String watchingChannel = '',
}) => _channel.invokeMethod<void>('replace', {
'watches': [for (final watch in watches) watch.toJson()],
'channel': channel,
'watching': watching,
'watchingChannel': watchingChannel,
});
@override

View file

@ -64,15 +64,16 @@ class _ReminderSchedulerState() extends State<ReminderScheduler> {
final tasks = [
for (final task in state.tasks) StoredTask(task.id, task.content),
];
_watch(tasks);
final watching = _watch(tasks);
final plan = planReminders(tasks, now: clock.now());
// Asked to notify wherever the reminders came from (D31): typed, added
// by the automatic reminder, chosen in a sheet or synced; and read
// again as the app comes back, from the system's settings perhaps.
// by the automatic reminder, chosen in a sheet or synced, or at a place
// (D32); and read again as the app comes back, from the system's
// settings perhaps.
unawaited(
PersonScope.of<TodoPerson>(
context,
).notifications.planned(any: plan.isNotEmpty),
).notifications.planned(any: plan.isNotEmpty || watching),
);
final snooze = state.settings.reminders.snooze;
if (listEquals(plan, _planned) && snooze == _snooze) {
@ -101,8 +102,9 @@ class _ReminderSchedulerState() extends State<ReminderScheduler> {
);
}
/// Hands the places [tasks] remind at to the device, where they changed.
void _watch(List<StoredTask> tasks) {
/// Hands the places [tasks] remind at to the device, where they changed;
/// whether there are any.
bool _watch(List<StoredTask> tasks) {
final l10n = AppLocalizations.of(context);
final todo = PersonScope.of<TodoPerson>(context);
final watches = [
@ -120,9 +122,12 @@ class _ReminderSchedulerState() extends State<ReminderScheduler> {
todo.locationReminders.replace(
watches,
channel: l10n.remindersChannel,
watching: l10n.locationWatching,
watchingChannel: l10n.locationWatchingChannel,
),
);
}
return watches.isNotEmpty;
}
@override