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
4 changed files with 2540 additions and 18 deletions

View file

@ -0,0 +1,88 @@
import 'package:halkyon_todo_flutter/src/feature/location/data/location_access.dart';
import 'package:halkyon_todo_flutter/src/feature/location/data/location_reminder_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/location/model/location_plan.dart';
import 'package:test/test.dart';
class _Gateway(var LocationAccess now) implements LocationReminderGateway {
LocationAccess onAsk = LocationAccess.whileInUse;
LocationAccess onAskAlways = LocationAccess.always;
final asked = <String>[];
int settings = 0;
@override
bool get available => true;
@override
Future<LocationAccess> access() async => now;
@override
Future<LocationAccess> askWhileInUse() async {
asked.add('in use');
return now = onAsk;
}
@override
Future<LocationAccess> askAlways() async {
asked.add('always');
return now = onAskAlways;
}
@override
Future<void> openSettings() async => settings++;
@override
Future<LatLon?> lastKnown() async => null;
@override
Future<void> replace(
List<LocationWatch> watches, {
required String channel,
}) async {}
}
/// Whether places can be watched (ADR 0033, D32).
void main() {
test('off only while places are planned and it may not watch', () async {
final gateway = _Gateway(LocationAccess.whileInUse);
final access = LocationReminderAccess(gateway);
expect(access.off, isFalse);
await access.planned(any: true);
expect(access.off, isTrue);
await access.planned(any: false);
expect(access.off, isFalse);
});
test('planning never asks: the person is told why first', () async {
final gateway = _Gateway(LocationAccess.none);
await LocationReminderAccess(gateway).planned(any: true);
expect(gateway.asked, isEmpty);
});
test('asks for the position first, then at all times', () async {
final gateway = _Gateway(LocationAccess.none);
expect(await LocationReminderAccess(gateway).ask(), isTrue);
expect(gateway.asked, ['in use', 'always']);
});
test('refused the position, it stops there', () async {
final gateway = _Gateway(LocationAccess.none)..onAsk = LocationAccess.none;
expect(await LocationReminderAccess(gateway).ask(), isFalse);
expect(gateway.asked, ['in use']);
});
test('Turn on opens the settings where the system asks no more', () async {
final gateway = _Gateway(LocationAccess.whileInUse)
..onAskAlways = LocationAccess.whileInUse;
await LocationReminderAccess(gateway).turnOn();
expect(gateway.settings, 1);
});
test('Turn on leaves the settings closed once allowed', () async {
final gateway = _Gateway(LocationAccess.whileInUse);
final access = LocationReminderAccess(gateway);
await access.planned(any: true);
await access.turnOn();
expect(gateway.settings, 0);
expect(access.off, isFalse);
});
}

View file

@ -0,0 +1,154 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_languages.dart';
import 'package:halkyon_todo_flutter/src/feature/location/data/location_access.dart';
import 'package:halkyon_todo_flutter/src/feature/location/data/location_reminder_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/location/data/place_search_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:material_ui/material_ui.dart';
class _Places(final List<TaskLocation> found) implements PlaceSearchGateway {
final asked = <String>[];
LatLon? near;
@override
Future<List<TaskLocation>> search(
String query, {
LatLon? near,
String? language,
}) async {
asked.add(query);
this.near = near;
return found;
}
@override
Future<TaskLocation?> at(LatLon point, {String? language}) async => null;
}
/// The location picker (ADR 0033, D32), without tiles.
void main() {
const aeschach = TaskLocation(
name: 'EDEKA',
lat: 47.55,
lon: 9.70,
address: 'Ludwig-Kick-Straße 1, Aeschach, Lindau',
);
const reutin = TaskLocation(
name: 'EDEKA',
lat: 47.56,
lon: 9.71,
address: 'Bregenzer Straße 2, Reutin, Lindau',
);
Future<List<TaskLocation?>> open(
WidgetTester tester, {
PlaceSearchGateway? places,
List<TaskLocation> recents = const [],
String? query,
}) async {
final picked = <TaskLocation?>[];
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: todoLocalizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: Builder(
builder: (context) => TextButton(
onPressed: () async {
picked.add(
await Navigator.of(context).push<TaskLocation>(
MaterialPageRoute(
builder: (context) => LocationPicker(
places: places,
mapTiles: null,
access: LocationReminderAccess(
const NoLocationReminders(),
),
position: () async => null,
recents: recents,
query: query,
),
),
),
);
},
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
return picked;
}
testWidgets('results tell two of a name apart by their addresses', (
tester,
) async {
final places = _Places(const [aeschach, reutin]);
await open(tester, places: places);
await tester.enterText(find.byKey(locationSearchKey), 'Lindau EDEKA');
await tester.pump(const Duration(milliseconds: 400));
await tester.pump();
expect(places.asked, ['Lindau EDEKA']);
expect(find.text(aeschach.address), findsOneWidget);
expect(find.text(reutin.address), findsOneWidget);
});
testWidgets('a row tapped is the place added', (tester) async {
final picked = await open(
tester,
places: _Places(const [aeschach, reutin]),
query: 'Lindau EDEKA',
);
await tester.pump();
await tester.tap(find.text(reutin.address));
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(locationAddKey));
await tester.pump(const Duration(milliseconds: 400));
expect(picked, [reutin]);
});
testWidgets('a search answers what was asked last, and says what failed', (
tester,
) async {
await open(tester, places: _Places(const []), query: 'Nowhere');
await tester.pump();
expect(find.text('Nothing found'), findsOneWidget);
});
testWidgets('without a search it says so, the map stays', (tester) async {
await open(tester);
expect(
find.text(
"Search isn't available here. Move the map and tap to place a pin.",
),
findsOneWidget,
);
expect(find.byKey(locationSearchKey), findsNothing);
expect(find.byKey(locationMapKey), findsOneWidget);
});
testWidgets('recent places show before a search', (tester) async {
await open(tester, places: _Places(const []), recents: const [aeschach]);
expect(find.text('Recent'), findsOneWidget);
expect(find.text(aeschach.address), findsOneWidget);
});
testWidgets('no reminder is offered on a device that watches nothing', (
tester,
) async {
await open(
tester,
places: _Places(const [aeschach]),
query: 'EDEKA',
);
await tester.pump();
await tester.tap(find.text(aeschach.address));
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('On arriving'), findsNothing);
});
}

View file

@ -0,0 +1,93 @@
import 'package:halkyon_todo_flutter/src/feature/location/model/location_plan.dart';
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:test/test.dart';
/// Which places the device watches (ADR 0033, D32).
void main() {
const arrive = TaskLocation(
name: 'EDEKA',
lat: 47.55,
lon: 9.70,
trigger: LocationTrigger.arrive,
);
const leave = TaskLocation(
name: 'Flat',
lat: 47.54,
lon: 9.69,
trigger: LocationTrigger.leave,
);
const quiet = TaskLocation(name: 'Lidl', lat: 47.56, lon: 9.71);
StoredTask task(
String id, {
List<TaskLocation> locations = const [arrive],
DateTime? completedAt,
String title = 'Shop',
TaskDate? date,
}) => StoredTask(
id,
TaskContent(
title: title,
projectId: 'p',
locations: locations,
completedAt: completedAt,
date: date,
),
);
test('a place reminding, and none of those that do not', () {
final plan = planLocationWatches([
task('a', locations: const [quiet, arrive, leave]),
]);
expect(plan.map((watch) => watch.name), ['EDEKA', 'Flat']);
expect(plan.first.arrive, isTrue);
expect(plan.last.arrive, isFalse);
});
test('nothing of a task done, or one that does not open', () {
expect(
planLocationWatches([
task('a', completedAt: DateTime(2026)),
const StoredTask('b', null),
]),
isEmpty,
);
});
test("a task's alternatives share its task", () {
final plan = planLocationWatches([
task('a', locations: const [arrive, leave]),
]);
expect(plan.map((watch) => watch.taskId).toSet(), {'a'});
expect(plan.map((watch) => watch.id).toSet(), hasLength(2));
});
test('an ID the same on every plan', () {
expect(
planLocationWatches([task('a')]).single.id,
planLocationWatches([task('a')]).single.id,
);
});
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);
expect(
planLocationWatches([task('a', title: 'Shop more')]).single.stamp,
isNot(stamp),
);
expect(
planLocationWatches([
task('a', date: const TaskDate(Day(2026, 11, 6))),
]).single.stamp,
isNot(stamp),
);
expect(
planLocationWatches([
task('a', locations: const [arrive, quiet]),
]).single.stamp,
isNot(stamp),
);
});
}

View file

@ -0,0 +1,78 @@
import 'package:halkyon_todo_flutter/src/feature/location/data/place_search_gateway.dart';
import 'package:test/test.dart';
/// Replies as Photon's (ADR 0033, D32).
void main() {
Map<String, Object?> feature(
Map<String, Object?> properties, {
List<num> at = const [9.70, 47.55],
}) => {
'geometry': {'coordinates': at},
'properties': properties,
};
test('a place: its name, then street, district and city', () {
final [place] = placesOfPhoton({
'features': [
feature({
'name': 'EDEKA',
'street': 'Ludwig-Kick-Straße',
'housenumber': '1',
'district': 'Aeschach',
'city': 'Lindau (Bodensee)',
'osm_type': 'W',
'osm_id': 42,
'osm_value': 'supermarket',
}),
],
});
expect(place.name, 'EDEKA');
expect(place.address, 'Ludwig-Kick-Straße 1, Aeschach, Lindau (Bodensee)');
expect(place.lat, 47.55);
expect(place.lon, 9.70);
expect(place.osmType, 'W');
expect(place.osmId, 42);
});
test('a car park and a charging station named after it are left out', () {
expect(
placesOfPhoton({
'features': [
feature({'name': 'EDEKA', 'osm_value': 'parking'}),
feature({'name': 'Lidl Lindau', 'osm_value': 'charging_station'}),
feature({'name': 'EDEKA', 'osm_value': 'supermarket'}),
],
}),
hasLength(1),
);
});
test('an address is named by its street, not said twice', () {
final [place] = placesOfPhoton({
'features': [
feature({
'street': 'Orlandostraße',
'housenumber': '5',
'city': 'München',
}),
],
});
expect(place.name, 'Orlandostraße 5');
expect(place.address, 'München');
});
test('a feature without a name or a street is left out', () {
expect(
placesOfPhoton({
'features': [
feature({'city': 'München'}),
],
}),
isEmpty,
);
});
test('no features: nothing', () {
expect(placesOfPhoton(const {}), isEmpty);
});
}