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:
parent
c9d3eb4dd1
commit
5d9c640e5a
5 changed files with 170 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Reference in a new issue