feat(todo): pick a place on a map, for quick add and a task's Where (ADR 0033, D32)

The location picker searches by city or district and name through
PlaceSearchGateway, its results a list and pins on Train's OpenFreeMap map,
or takes a pin placed by hand; recent places, and None, Arrive or Leave.
TodoDependencies.places is the seam for the instance's search; until then
komoot's public Photon, in a debug build asked for it alone. Quick add has
`+` suggestions, an entry in its + menu and a pin button; the task sheet a
Where box; a task's row the place's name.

ReminderScheduler plans the places to watch into LocationReminderGateway,
a no-op off Android, which TodoPerson.dispose clears, and Settings' Reminders
says while the device may not watch them.

Whats-New: Tasks can have locations: type + or use the + menu, search by city and name or pick on the map, and be reminded on arriving or leaving (Android).
Whats-New-De: Aufgaben können Orte haben: Tippe + oder nutze das +-Menü, suche nach Stadt und Name oder wähle auf der Karte, und lass dich beim Ankommen oder Verlassen erinnern (Android).
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 15:23:53 +01:00
commit 77d6a5528c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -10,6 +10,9 @@ import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/location/data/location_reminder_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.dart';
import 'package:halkyon_todo_flutter/src/feature/location/widget/location_picker.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
@ -39,6 +42,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import '../support/memory_content_key_gateway.dart';
import '../support/memory_location_gateway.dart';
import '../support/memory_reminder_gateway.dart';
import '../support/memory_sealed_row_gateway.dart';
@ -59,6 +63,7 @@ void main() {
late MemorySealedRowGateway labelRows;
late MemorySealedRowGateway settingsRows;
late MemoryReminderGateway reminders;
late MemoryLocationGateway locationGateway;
late FakeSpeechInput speech;
/// How many syncs ran.
@ -88,6 +93,7 @@ void main() {
labelRows = MemorySealedRowGateway();
settingsRows = MemorySealedRowGateway();
reminders = MemoryReminderGateway();
locationGateway = MemoryLocationGateway();
speech = FakeSpeechInput();
store = MemoryInstanceStore();
looks = MemoryLookStore();
@ -189,6 +195,7 @@ void main() {
labels: LabelRepository(labelRows, keys),
settings: settings,
reminders: reminders,
locationReminders: locationGateway,
);
},
),
@ -1895,6 +1902,139 @@ void main() {
expect(content.labelIds, [errand]);
});
group('+location (D32)', () {
const edeka = TaskLocation(
name: 'EDEKA',
lat: 47.55,
lon: 9.70,
address: 'Aeschach, Lindau',
);
/// A task at [edeka], so that it is a recent place.
Future<void> doneBefore(WidgetTester tester) async {
await tester.runAsync(() async {
final inbox = await ProjectRepository(
projectRows,
keys,
).ensureInbox();
await tasks().add(
TaskContent(
title: 'Before',
projectId: inbox,
locations: const [edeka],
),
);
});
taskRows.bringRemoteChanges();
await tester.pumpAndSettle();
}
testWidgets('a recent place is offered; tapped, it goes with the '
'task, its word out of the title', (tester) async {
await start(tester, usedBefore: true);
await doneBefore(tester);
await goTab(tester, 'Inbox');
expect(find.textContaining('EDEKA'), findsOneWidget, reason: 'row');
await open(tester, 'Shop +ede');
await tester.tap(
find.descendant(
of: find.byKey(quickAddSuggestionsKey),
matching: find.text('EDEKA'),
),
);
await tester.pumpAndSettle();
await add(tester);
final stored = await tester.runAsync(() => tasks().all());
final added = stored!.singleWhere(
(task) => task.content!.title == 'Shop',
);
expect(added.content!.locations, [edeka]);
});
testWidgets('Backspace after it keeps it as text', (tester) async {
await start(tester, usedBefore: true);
await doneBefore(tester);
await goTab(tester, 'Inbox');
await open(tester, 'Shop +EDEKA');
final title = tester
.widget<TextField>(find.byKey(quickAddTitleKey))
.controller!;
expect(find.byTooltip('Location'), findsOneWidget);
await tester.enterText(find.byKey(quickAddTitleKey), 'Shop +EDEK');
await tester.pumpAndSettle();
expect(title.text, 'Shop +EDEKA', reason: 'kept, not deleted');
expect(find.byTooltip('Location'), findsNothing);
await add(tester);
final stored = await tester.runAsync(() => tasks().all());
final added = stored!.singleWhere(
(task) => task.content!.title == 'Shop +EDEKA',
);
expect(added.content!.locations, isEmpty);
});
testWidgets('a place not named before opens the search, which says '
'where there is none', (tester) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Inbox');
await open(tester, 'Shop +Lindau EDEKA');
await tester.tap(
find.widgetWithText(ListTile, 'Search for “Lindau EDEKA”'),
);
await tester.pump(const Duration(milliseconds: 500));
expect(find.byKey(locationMapKey), findsOneWidget);
expect(
find.textContaining("Search isn't available"),
findsOneWidget,
);
});
testWidgets('a sum is no place', (tester) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Inbox');
await open(tester, '2 + 2');
expect(find.byKey(quickAddSuggestionsKey), findsNothing);
});
testWidgets("the + menu's Location opens the picker", (tester) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Inbox');
await open(tester, 'Shop');
await tester.tap(find.byTooltip('More').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Location'));
await tester.pump(const Duration(milliseconds: 500));
expect(find.byKey(locationMapKey), findsOneWidget);
});
testWidgets("a task's Where shows its places, each with its ⋮", (
tester,
) async {
await start(tester, usedBefore: true);
await doneBefore(tester);
await goTab(tester, 'Inbox');
await tester.tap(find.text('Before'));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text('Add location'),
100,
scrollable: find.byType(Scrollable).last,
);
expect(find.textContaining('Aeschach, Lindau'), findsOneWidget);
await tester.tap(find.byTooltip('Location'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remove'));
await tester.pumpAndSettle();
final stored = await tester.runAsync(() => tasks().all());
expect(stored!.single.content!.locations, isEmpty);
});
});
testWidgets('nothing is suggested for a # inside a word, nor where no '
'name matches', (tester) async {
await start(tester, usedBefore: true);
@ -2269,6 +2409,88 @@ void main() {
expect(stored.content?.completed, isFalse);
});
testWidgets('a place with a reminder is watched, in the words of the '
"app's language, until the task is done, not one without", (
tester,
) async {
await start(tester, usedBefore: true);
await seed(
tester,
TaskContent(
title: 'Shop',
projectId: inbox(),
locations: const [
TaskLocation(
name: 'EDEKA',
lat: 47.55,
lon: 9.70,
trigger: LocationTrigger.arrive,
),
TaskLocation(name: 'Lidl', lat: 47.56, lon: 9.71),
],
),
);
final watch = locationGateway.watched.single;
expect(watch.title, 'Shop');
expect(watch.body, "You're at EDEKA");
await goTab(tester, 'Inbox');
await tester.tap(find.byTooltip('Mark as done'));
await tester.pumpAndSettle();
expect(locationGateway.watched, isEmpty);
});
testWidgets('an unchanged plan is not handed over again, nor is a person '
'asked anything for it', (tester) async {
locationGateway.allowed = LocationAccess.whileInUse;
await start(tester, usedBefore: true);
await seed(
tester,
TaskContent(
title: 'Shop',
projectId: inbox(),
locations: const [
TaskLocation(
name: 'EDEKA',
lat: 47.55,
lon: 9.70,
trigger: LocationTrigger.leave,
),
],
),
);
final replaced = locationGateway.replaced;
await seed(
tester,
TaskContent(title: 'Other', projectId: inbox()),
);
expect(locationGateway.replaced, replaced);
});
testWidgets("a person gone, their places aren't watched", (tester) async {
await start(tester, usedBefore: true);
await seed(
tester,
TaskContent(
title: 'Shop',
projectId: inbox(),
locations: const [
TaskLocation(
name: 'EDEKA',
lat: 47.55,
lon: 9.70,
trigger: LocationTrigger.arrive,
),
],
),
);
expect(locationGateway.watched, hasLength(1));
await tester.pumpWidget(const SizedBox());
await tester.pumpAndSettle();
expect(locationGateway.watched, isEmpty);
});
testWidgets("a task's reminder is scheduled on the device before its "
'time, and goes once it is done', (tester) async {
final tomorrow = Day.of(DateTime.now()).plus(1);