feat(todo): a location's search goes through the instance (ADR 0033, D32)

The picker now searches with the server's `search`, and says search
isn't available where the instance has no Photon rather than that it
failed. A debug build may still ask for komoot's public one, for an
instance without. A point on the map stays unnamed through the instance,
which searches by name alone.

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 23:04:18 +01:00
commit 5d9c640e5a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 170 additions and 8 deletions

View file

@ -193,9 +193,9 @@ class TodoDependencies({
/// that a report can name the release; none in tests that don't say.
final ({String version, int? build}) appVersion = (version: '', build: null),
/// Finds places for a task's location (ADR 0033, D32). The seam for the
/// instance's own search: [create] names it. Where there is none, the
/// picker says search isn't available and pins are placed by hand.
/// Finds places for a task's location (ADR 0033, D32), through the
/// instance. Where it has no search, the picker says so and pins are
/// placed by hand.
final PlaceSearchGateway? places,
/// The tile layer of the location picker's map; none in widget tests,
@ -298,7 +298,9 @@ class TodoDependencies({
),
speech: DeviceSpeechInput(),
appVersion: (version: device.appVersion, build: device.appBuild),
places: _publicPhoton ? PublicPhotonGateway() : null,
places: _publicPhoton
? PublicPhotonGateway()
: InstancePlaceSearch.of(client),
mapTiles: vectorTiles,
sourceCodeUrl: () async =>
(await client.modules.base.instance.basics()).sourceCodeUrl,
@ -496,8 +498,9 @@ class TodoDevice({
}
}
/// Whether this build searches on komoot's public Photon (ADR 0033, D32):
/// a debug build asked to alone, until the instance searches.
/// Whether this build searches on komoot's public Photon (ADR 0033, D32),
/// not the instance's: a debug build asked to alone, for an instance without
/// one.
const _publicPhoton = kDebugMode && bool.fromEnvironment('TODO_PUBLIC_PHOTON');
/// The system's notifications, or none where they don't start (a desktop

View file

@ -1,12 +1,17 @@
import 'dart:convert';
import 'dart:io';
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
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';
/// Finds places (ADR 0033, D32): by what people know, a city or district and
/// a name (`Lindau EDEKA`), not an address.
abstract interface class PlaceSearchGateway() {
/// Whether it searches at all; false where the instance has no Photon or
/// can't be asked, so that the picker says so.
Future<bool> offered();
/// The places [query] finds, those [near] first, named in [language].
Future<List<TaskLocation>> search(
String query, {
@ -18,6 +23,64 @@ abstract interface class PlaceSearchGateway() {
Future<TaskLocation?> at(LatLon point, {String? language});
}
/// The instance's search (D32), on Todo's server (`search`), which leaves
/// out what is no place to go itself. It names no point on the map: a pin
/// placed by hand stays unnamed.
class InstancePlaceSearch({
required final Future<bool> Function() asked,
required final Future<List<protocol.Place>> Function(
String query,
protocol.PlacePoint? near,
String? language,
)
find,
}) implements PlaceSearchGateway {
factory of(protocol.Client client) => InstancePlaceSearch(
asked: client.search.offered,
find: (query, near, language) =>
client.search.places(query, near: near, language: language),
);
@override
Future<bool> offered() async {
try {
return await asked();
} on Object {
return false;
}
}
@override
Future<List<TaskLocation>> search(
String query, {
LatLon? near,
String? language,
}) async {
final places = await find(
query,
switch (near) {
final at? => protocol.PlacePoint(lat: at.lat, lon: at.lon),
null => null,
},
language,
);
return [
for (final place in places)
TaskLocation(
name: place.name,
lat: place.lat,
lon: place.lon,
address: place.detail ?? '',
osmType: place.osmType,
osmId: place.osmId,
),
];
}
@override
Future<TaskLocation?> at(LatLon point, {String? language}) async => null;
}
/// What a search leaves out: places of that kind beside the place itself
/// (`EDEKA` the car park, the charging station at `Lidl Lindau`).
const _beside = {'parking', 'parking_space', 'charging_station'};
@ -63,6 +126,9 @@ TaskLocation? _place(Map<String, Object?> at, double lat, double lon) {
class PublicPhotonGateway() implements PlaceSearchGateway {
final _client = HttpClient();
@override
Future<bool> offered() async => true;
Future<List<TaskLocation>> _get(Uri uri) async {
final request = await _client.getUrl(uri);
request.headers.set(HttpHeaders.userAgentHeader, 'halkyon-todo-debug/1.0');

View file

@ -84,6 +84,7 @@ class _LocationPickerState() extends State<LocationPicker> {
var _asked = 0;
var _searching = false;
var _failed = false;
var _offered = true;
List<TaskLocation>? _results;
LatLon? _position;
TaskLocation? _picked;
@ -93,6 +94,7 @@ class _LocationPickerState() extends State<LocationPicker> {
@override
void initState() {
super.initState();
unawaited(_askOffered());
_searchFocus.addListener(() => setState(() {}));
unawaited(_locate());
if (widget.query?.trim().isNotEmpty ?? false) {
@ -161,6 +163,14 @@ class _LocationPickerState() extends State<LocationPicker> {
return _position;
}
/// Asks once whether there is a search, as the instance may have none.
Future<void> _askOffered() async {
final offered = await widget.places?.offered() ?? false;
if (mounted && !offered) {
setState(() => _offered = false);
}
}
Future<void> _find(String text) async {
final places = widget.places;
if (places == null) {
@ -297,7 +307,7 @@ class _LocationPickerState() extends State<LocationPicker> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
final search = widget.places != null;
final search = widget.places != null && _offered;
return Scaffold(
appBar: AppBar(title: Text(l10n.locationAdd)),
body: SafeArea(

View file

@ -8,7 +8,8 @@ import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.da
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 {
class _Places(final List<TaskLocation> found, {final bool isOffered = true})
implements PlaceSearchGateway {
final asked = <String>[];
LatLon? near;
@ -25,6 +26,9 @@ class _Places(final List<TaskLocation> found) implements PlaceSearchGateway {
@override
Future<TaskLocation?> at(LatLon point, {String? language}) async => null;
@override
Future<bool> offered() async => isOffered;
}
/// The location picker (ADR 0033, D32), without tiles.
@ -132,6 +136,21 @@ void main() {
expect(find.byKey(locationMapKey), findsOneWidget);
});
testWidgets('an instance without a search says so, the map stays', (
tester,
) async {
await open(tester, places: _Places(const [], isOffered: false));
await tester.pump();
expect(find.byKey(locationSearchKey), findsNothing);
expect(
find.text(
"Search isn't available here. Move the map and tap to place a pin.",
),
findsOneWidget,
);
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);

View file

@ -1,8 +1,72 @@
import 'package:halkyon_todo_client/halkyon_todo_client.dart';
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() {
test("the instance's places keep their address and object", () async {
final gateway = InstancePlaceSearch(
asked: () async => true,
find: (query, near, language) async {
expect((query, near?.lat, language), ('Lindau EDEKA', 47.5, 'de'));
return [
Place(
name: 'EDEKA',
detail: 'Ludwig-Kick-Straße 1, Lindau',
kind: 'house',
osmType: 'N',
osmId: 7,
lat: 47.55,
lon: 9.7,
),
];
},
);
final [place] = await gateway.search(
'Lindau EDEKA',
near: (lat: 47.5, lon: 9.7),
language: 'de',
);
expect(place.address, 'Ludwig-Kick-Straße 1, Lindau');
expect((place.lat, place.lon), (47.55, 9.7));
expect((place.osmType, place.osmId), ('N', 7));
});
test('a place without a detail has no address', () async {
final gateway = InstancePlaceSearch(
asked: () async => true,
find: (_, _, _) async => [
Place(name: 'Lindau', kind: 'city', lat: 47.55, lon: 9.7),
],
);
final [place] = await gateway.search('Lindau');
expect(place.address, '');
});
test("an instance that can't be asked offers no search", () async {
final gateway = InstancePlaceSearch(
asked: () async => throw Exception('offline'),
find: (_, _, _) async => const [],
);
expect(await gateway.offered(), isFalse);
});
test('a refusal reaches the picker, which says the search failed', () {
final gateway = InstancePlaceSearch(
asked: () async => true,
find: (_, _, _) async => throw SearchRefused(reason: 'unreachable'),
);
expect(gateway.search('Lindau'), throwsA(isA<SearchRefused>()));
});
test('a point on the map has no name from the instance', () async {
final gateway = InstancePlaceSearch(
asked: () async => true,
find: (_, _, _) async => const [],
);
expect(await gateway.at((lat: 47.5, lon: 9.7)), isNull);
});
Map<String, Object?> feature(
Map<String, Object?> properties, {
List<num> at = const [9.70, 47.55],