feat(todo): the map keeps its size, the search a screen of its own (ADR 0033, D32)

From the developer's trial of the picker. Below a medium width a sheet
of fixed height sits under the map, whatever is around the pin, and is
pulled up over it for the whole list, as Threema's; a landscape phone or
a wider window has it as a pane beside the map. The search opens its own
screen: name, what it is, where it lies and how far. A compass under the
bar turns the map to north, and my position no longer zooms out.

A pinch only zooms unless the fingers turn first, the pin's shadow lies
on the map's middle, and a look around that failed says so, with Try again. It asks
little: after the map rested, one at a time, nothing for a spot answered
or by zooming, and nothing for 30 seconds after a failure, as komoot's
Photon throttled a person moving the map about.

Fixes the wrong map for the theme: OpenFreeMap's styles name themselves
nothing and the tile cache goes by that name, so a theme got the other's
tiles (seen on an emulator, both ways); each now has its own name.

Whats-New: The location map keeps its size, the list of places pulls up over it, and search opens its own screen with kinds and distances.
Whats-New-De: Die Ortskarte behält ihre Größe, die Ortsliste lässt sich über sie ziehen, und die Suche öffnet sich als eigene Seite mit Art und Entfernung.

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-06 00:12:01 +01:00
commit a4c1fe298d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
16 changed files with 1663 additions and 241 deletions

File diff suppressed because one or more lines are too long

View file

@ -287,6 +287,11 @@
"locationSearchFailed": "Die Suche hat nicht geklappt. Versuch es noch einmal.", "locationSearchFailed": "Die Suche hat nicht geklappt. Versuch es noch einmal.",
"locationNoResults": "Nichts gefunden", "locationNoResults": "Nichts gefunden",
"locationRecent": "Zuletzt verwendet", "locationRecent": "Zuletzt verwendet",
"locationNearbyFailed": "Die Umgebung ließ sich nicht abfragen",
"locationTryAgain": "Nochmal versuchen",
"locationPickHint": "Zoome hinein und schiebe die Karte auf den Ort",
"locationNorth": "Nach Norden ausrichten",
"locationKind": "{kind, select, city{Stadt} town{Stadt} village{Dorf} hamlet{Weiler} isolated_dwelling{Einzelsiedlung} locality{Flurname} suburb{Stadtteil} neighbourhood{Viertel} residential{Straße} house{Adresse} supermarket{Supermarkt} convenience{Laden} bakery{Bäckerei} butcher{Metzgerei} chemist{Drogerie} pharmacy{Apotheke} doctors{Arztpraxis} dentist{Zahnarztpraxis} hospital{Krankenhaus} restaurant{Restaurant} cafe{Café} fast_food{Imbiss} bar{Bar} pub{Kneipe} fuel{Tankstelle} bank{Bank} post_office{Post} school{Schule} kindergarten{Kindergarten} library{Bibliothek} station{Bahnhof} bus_stop{Bushaltestelle} hotel{Hotel} park{Park} other{}}",
"locationNearby": "Oder einen Ort in der Nähe wählen", "locationNearby": "Oder einen Ort in der Nähe wählen",
"locationZoomIn": "Vergrößern", "locationZoomIn": "Vergrößern",
"locationZoomOut": "Verkleinern", "locationZoomOut": "Verkleinern",

View file

@ -966,6 +966,31 @@
"@locationNearby": { "@locationNearby": {
"description": "Above the places around the pin in the location picker." "description": "Above the places around the pin in the location picker."
}, },
"locationNearbyFailed": "Couldn't look up what's around here",
"@locationNearbyFailed": {
"description": "The location picker when asking what is around the pin failed."
},
"locationTryAgain": "Try again",
"@locationTryAgain": {
"description": "Beside the location picker's message that asking what is around the pin failed."
},
"locationPickHint": "Zoom in and move the map to a spot",
"@locationPickHint": {
"description": "The location picker's sheet while the map is too far out for a spot."
},
"locationNorth": "Point north",
"@locationNorth": {
"description": "The location picker's compass, turning the map back to north."
},
"locationKind": "{kind, select, city{City} town{Town} village{Village} hamlet{Hamlet} isolated_dwelling{Isolated dwelling} locality{Locality} suburb{District} neighbourhood{Neighbourhood} residential{Street} house{Address} supermarket{Supermarket} convenience{Shop} bakery{Bakery} butcher{Butcher} chemist{Chemist} pharmacy{Pharmacy} doctors{Doctor} dentist{Dentist} hospital{Hospital} restaurant{Restaurant} cafe{Café} fast_food{Fast food} bar{Bar} pub{Pub} fuel{Petrol station} bank{Bank} post_office{Post office} school{School} kindergarten{Nursery} library{Library} station{Station} bus_stop{Bus stop} hotel{Hotel} park{Park} other{}}",
"@locationKind": {
"description": "What a place found is, from OpenStreetMap's value; empty for any other.",
"placeholders": {
"kind": {
"type": "String"
}
}
},
"locationZoomIn": "Zoom in", "locationZoomIn": "Zoom in",
"@locationZoomIn": { "@locationZoomIn": {
"description": "The location picker's button zooming the map in." "description": "The location picker's button zooming the map in."

View file

@ -1828,6 +1828,36 @@ abstract class AppLocalizations {
/// **'Or choose a nearby place'** /// **'Or choose a nearby place'**
String get locationNearby; String get locationNearby;
/// The location picker when asking what is around the pin failed.
///
/// In en, this message translates to:
/// **'Couldn\'t look up what\'s around here'**
String get locationNearbyFailed;
/// Beside the location picker's message that asking what is around the pin failed.
///
/// In en, this message translates to:
/// **'Try again'**
String get locationTryAgain;
/// The location picker's sheet while the map is too far out for a spot.
///
/// In en, this message translates to:
/// **'Zoom in and move the map to a spot'**
String get locationPickHint;
/// The location picker's compass, turning the map back to north.
///
/// In en, this message translates to:
/// **'Point north'**
String get locationNorth;
/// What a place found is, from OpenStreetMap's value; empty for any other.
///
/// In en, this message translates to:
/// **'{kind, select, city{City} town{Town} village{Village} hamlet{Hamlet} isolated_dwelling{Isolated dwelling} locality{Locality} suburb{District} neighbourhood{Neighbourhood} residential{Street} house{Address} supermarket{Supermarket} convenience{Shop} bakery{Bakery} butcher{Butcher} chemist{Chemist} pharmacy{Pharmacy} doctors{Doctor} dentist{Dentist} hospital{Hospital} restaurant{Restaurant} cafe{Café} fast_food{Fast food} bar{Bar} pub{Pub} fuel{Petrol station} bank{Bank} post_office{Post office} school{School} kindergarten{Nursery} library{Library} station{Station} bus_stop{Bus stop} hotel{Hotel} park{Park} other{}}'**
String locationKind(String kind);
/// The location picker's button zooming the map in. /// The location picker's button zooming the map in.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -1129,6 +1129,64 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get locationNearby => 'Oder einen Ort in der Nähe wählen'; String get locationNearby => 'Oder einen Ort in der Nähe wählen';
@override
String get locationNearbyFailed => 'Die Umgebung ließ sich nicht abfragen';
@override
String get locationTryAgain => 'Nochmal versuchen';
@override
String get locationPickHint =>
'Zoome hinein und schiebe die Karte auf den Ort';
@override
String get locationNorth => 'Nach Norden ausrichten';
@override
String locationKind(String kind) {
String _temp0 = intl.Intl.selectLogic(
kind,
{
'city': 'Stadt',
'town': 'Stadt',
'village': 'Dorf',
'hamlet': 'Weiler',
'isolated_dwelling': 'Einzelsiedlung',
'locality': 'Flurname',
'suburb': 'Stadtteil',
'neighbourhood': 'Viertel',
'residential': 'Straße',
'house': 'Adresse',
'supermarket': 'Supermarkt',
'convenience': 'Laden',
'bakery': 'Bäckerei',
'butcher': 'Metzgerei',
'chemist': 'Drogerie',
'pharmacy': 'Apotheke',
'doctors': 'Arztpraxis',
'dentist': 'Zahnarztpraxis',
'hospital': 'Krankenhaus',
'restaurant': 'Restaurant',
'cafe': 'Café',
'fast_food': 'Imbiss',
'bar': 'Bar',
'pub': 'Kneipe',
'fuel': 'Tankstelle',
'bank': 'Bank',
'post_office': 'Post',
'school': 'Schule',
'kindergarten': 'Kindergarten',
'library': 'Bibliothek',
'station': 'Bahnhof',
'bus_stop': 'Bushaltestelle',
'hotel': 'Hotel',
'park': 'Park',
'other': '',
},
);
return '$_temp0';
}
@override @override
String get locationZoomIn => 'Vergrößern'; String get locationZoomIn => 'Vergrößern';

View file

@ -1128,6 +1128,63 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get locationNearby => 'Or choose a nearby place'; String get locationNearby => 'Or choose a nearby place';
@override
String get locationNearbyFailed => 'Couldn\'t look up what\'s around here';
@override
String get locationTryAgain => 'Try again';
@override
String get locationPickHint => 'Zoom in and move the map to a spot';
@override
String get locationNorth => 'Point north';
@override
String locationKind(String kind) {
String _temp0 = intl.Intl.selectLogic(
kind,
{
'city': 'City',
'town': 'Town',
'village': 'Village',
'hamlet': 'Hamlet',
'isolated_dwelling': 'Isolated dwelling',
'locality': 'Locality',
'suburb': 'District',
'neighbourhood': 'Neighbourhood',
'residential': 'Street',
'house': 'Address',
'supermarket': 'Supermarket',
'convenience': 'Shop',
'bakery': 'Bakery',
'butcher': 'Butcher',
'chemist': 'Chemist',
'pharmacy': 'Pharmacy',
'doctors': 'Doctor',
'dentist': 'Dentist',
'hospital': 'Hospital',
'restaurant': 'Restaurant',
'cafe': 'Café',
'fast_food': 'Fast food',
'bar': 'Bar',
'pub': 'Pub',
'fuel': 'Petrol station',
'bank': 'Bank',
'post_office': 'Post office',
'school': 'School',
'kindergarten': 'Nursery',
'library': 'Library',
'station': 'Station',
'bus_stop': 'Bus stop',
'hotel': 'Hotel',
'park': 'Park',
'other': '',
},
);
return '$_temp0';
}
@override @override
String get locationZoomIn => 'Zoom in'; String get locationZoomIn => 'Zoom in';

View file

@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol; 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/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/model/task_location.dart';
@ -101,6 +102,7 @@ class InstancePlaceSearch({
address: place.detail ?? '', address: place.detail ?? '',
osmType: place.osmType, osmType: place.osmType,
osmId: place.osmId, osmId: place.osmId,
kind: place.osmValue ?? place.kind,
); );
} }
@ -140,6 +142,7 @@ TaskLocation? _place(Map<String, Object?> at, double lat, double lon) {
}.where((part) => part.isNotEmpty).join(', '), }.where((part) => part.isNotEmpty).join(', '),
osmType: at['osm_type'] as String?, osmType: at['osm_type'] as String?,
osmId: at['osm_id'] as int?, osmId: at['osm_id'] as int?,
kind: at['osm_value'] as String?,
); );
} }
@ -158,6 +161,7 @@ class PublicPhotonGateway() implements PlaceSearchGateway {
final response = await request.close(); final response = await request.close();
final body = await response.transform(utf8.decoder).join(); final body = await response.transform(utf8.decoder).join();
if (response.statusCode != HttpStatus.ok) { if (response.statusCode != HttpStatus.ok) {
debugPrint('Photon answered ${response.statusCode}');
throw HttpException('Photon: ${response.statusCode}'); throw HttpException('Photon: ${response.statusCode}');
} }
return placesOfPhoton(jsonDecode(body) as Map<String, Object?>); return placesOfPhoton(jsonDecode(body) as Map<String, Object?>);

View file

@ -26,6 +26,10 @@ final class const TaskLocation({
final String? osmType, final String? osmType,
final int? osmId, final int? osmId,
final LocationTrigger trigger = LocationTrigger.none, final LocationTrigger trigger = LocationTrigger.none,
/// What it is in OpenStreetMap (`supermarket`, `village`), for the search's
/// list alone: kept nowhere, and no part of what tells two places apart.
final String? kind,
}) with Equatable { }) with Equatable {
/// What tells it from any other place, whatever its reminder. /// What tells it from any other place, whatever its reminder.
String get key => '${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}'; String get key => '${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}';

View file

@ -18,6 +18,30 @@ typedef MapTiles = Widget Function();
/// while the style loads, nor where it can't be read. /// while the style loads, nor where it can't be read.
Widget vectorTiles() => const _VectorTiles(); Widget vectorTiles() => const _VectorTiles();
/// The name of OpenFreeMap's style for a [dark] theme or a light one.
String mapStyleName({required bool dark}) => dark ? 'dark' : 'liberty';
/// OpenFreeMap's style for a [dark] theme or a light one. Neither names
/// itself, and vector_map_tiles keeps the tiles it draws on the device under
/// the style's name (`default` for both): unnamed, a dark app gets the tiles
/// it drew while light, so each is given its own.
Future<Style> readMapStyle({required bool dark}) async {
final name = mapStyleName(dark: dark);
return namedMapStyle(
await StyleReader(uri: 'https://tiles.openfreemap.org/styles/$name').read(),
name,
);
}
/// [style] under [name].
Style namedMapStyle(Style style, String name) => Style(
theme: style.theme.copyWith(id: name),
providers: style.providers,
sprites: style.sprites,
zoom: style.zoom,
center: style.center,
);
class const _VectorTiles() extends StatefulWidget { class const _VectorTiles() extends StatefulWidget {
@override @override
State<_VectorTiles> createState() => _VectorTilesState(); State<_VectorTiles> createState() => _VectorTilesState();
@ -33,11 +57,7 @@ class _VectorTilesState() extends State<_VectorTiles> {
final dark = Theme.of(context).brightness == Brightness.dark; final dark = Theme.of(context).brightness == Brightness.dark;
if (dark != _dark) { if (dark != _dark) {
_dark = dark; _dark = dark;
_style = StyleReader( _style = readMapStyle(dark: dark);
uri:
'https://tiles.openfreemap.org/styles/'
'${dark ? 'dark' : 'liberty'}',
).read();
} }
} }

View file

@ -0,0 +1,223 @@
import 'dart:async';
import 'package:halkyon_todo_flutter/l10n/app_localizations.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:intl/intl.dart';
import 'package:latlong2/latlong.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The key of the search's text field.
const locationSearchKey = ValueKey('location-search');
/// What the search ended on: the place chosen, among all it found for what
/// was typed.
typedef PlaceSearched = ({
TaskLocation picked,
List<TaskLocation> found,
String query,
});
/// [metres] as a list says it, in [locale]'s digits: `850 m`, `1.2 km`,
/// `178 km`.
String distanceText(double metres, String locale) => switch (metres) {
< 1000 => '${metres.round()} m',
< 10000 => '${NumberFormat('0.0', locale).format(metres / 1000)} km',
_ => '${NumberFormat('#,##0', locale).format(metres / 1000)} km',
};
/// The search of the location picker (ADR 0033, D32), a screen of its own as
/// Threema's, the keyboard leaving a map no room: [query] to start with,
/// [recents] until something is typed, each place with what it is, where it
/// lies, telling two EDEKAs apart, and how far it is from [from].
class const PlaceSearchScreen({
required final PlaceSearchGateway places,
final LatLon? near,
final LatLon? from,
final List<TaskLocation> recents = const [],
final String? query,
super.key,
}) extends StatefulWidget {
@override
State<PlaceSearchScreen> createState() => _PlaceSearchScreenState();
}
class _PlaceSearchScreenState() extends State<PlaceSearchScreen> {
late final _text = TextEditingController(text: widget.query);
Timer? _typing;
/// Answers to anything but the latest search are dropped.
var _asked = 0;
var _searching = false;
var _failed = false;
List<TaskLocation>? _results;
@override
void initState() {
super.initState();
if (widget.query?.trim().isNotEmpty ?? false) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => unawaited(_find(widget.query!)),
);
}
}
@override
void dispose() {
_typing?.cancel();
_text.dispose();
super.dispose();
}
void _typed(String text) {
_typing?.cancel();
if (text.trim().isEmpty) {
_asked++;
setState(() {
_results = null;
_searching = false;
_failed = false;
});
return;
}
_typing = Timer(
const Duration(milliseconds: 350),
() => unawaited(_find(text)),
);
}
Future<void> _find(String text) async {
final asked = ++_asked;
final language = Localizations.localeOf(context).languageCode;
setState(() {
_searching = true;
_failed = false;
});
try {
final found = await widget.places.search(
text.trim(),
near: widget.near,
language: language,
);
if (mounted && asked == _asked) {
setState(() {
_results = found;
_searching = false;
});
}
} on Object {
if (mounted && asked == _asked) {
setState(() {
_searching = false;
_failed = true;
});
}
}
}
String? _distance(TaskLocation place, String locale) => switch (widget.from) {
(:final lat, :final lon) => distanceText(
const Distance().as(
LengthUnit.Meter,
LatLng(lat, lon),
LatLng(place.lat, place.lon),
),
locale,
),
null => null,
};
Widget _row(BuildContext context, TaskLocation place) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final kind = switch (place.kind) {
final kind? => l10n.locationKind(kind),
null => '',
};
final about = [
kind,
place.address,
].where((part) => part.isNotEmpty).join(' · ');
final distance = _distance(place, locale);
return ListTile(
leading: const Icon(LucideIcons.mapPin),
title: Text(place.name),
subtitle: about.isEmpty ? null : Text(about),
trailing: distance == null ? null : Text(distance),
onTap: () => Navigator.of(context).pop<PlaceSearched>((
picked: place,
found: _results ?? const [],
query: _text.text.trim(),
)),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
final results = _results;
final places = results ?? widget.recents;
final message = switch ((_failed, results)) {
(true, _) => l10n.locationSearchFailed,
(_, []) when !_searching => l10n.locationNoResults,
_ => null,
};
return Scaffold(
appBar: AppBar(
leading: const BackButton(),
titleSpacing: 0,
title: TextField(
key: locationSearchKey,
controller: _text,
autofocus: true,
textInputAction: TextInputAction.search,
textCapitalization: TextCapitalization.words,
decoration: InputDecoration(
hintText: l10n.locationSearchHint,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
),
onChanged: _typed,
onSubmitted: (text) {
_typing?.cancel();
unawaited(_find(text));
},
),
bottom: _searching
? const PreferredSize(
preferredSize: Size.fromHeight(2),
child: LinearProgressIndicator(minHeight: 2),
)
: null,
),
body: ListView(
padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
children: [
if (message != null)
Padding(
padding: const EdgeInsets.all(16),
child: Text(
message,
style: TextStyle(color: scheme.onSurfaceVariant),
),
),
if (results == null && places.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
l10n.locationRecent,
style: Theme.of(context).textTheme.labelLarge,
),
),
for (final place in places) _row(context, place),
],
),
);
}
}

View file

@ -11,6 +11,9 @@ enum SidePaneKind() {
/// Today's Plan (D30). /// Today's Plan (D30).
plan, plan,
/// What the location picker says beside its map (D32).
location,
} }
/// How wide the device keeps its side panes (ADR 0033, D33): a window's /// How wide the device keeps its side panes (ADR 0033, D33): a window's

View file

@ -65,6 +65,7 @@ dependencies:
vector_map_tiles: 9.0.0-beta.13 vector_map_tiles: 9.0.0-beta.13
dev_dependencies: dev_dependencies:
vector_tile_renderer: ^6.1.0
flutter_driver: flutter_driver:
sdk: flutter sdk: flutter
# Writes the app's icons for every platform from assets/icon/halkyon_todo_icon.png, # Writes the app's icons for every platform from assets/icon/halkyon_todo_icon.png,

View file

@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:math';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.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/app/todo_languages.dart';
@ -9,8 +11,17 @@ import 'package:halkyon_todo_flutter/src/feature/location/data/place_search_gate
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.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/location/widget/location_picker.dart';
import 'package:halkyon_todo_flutter/src/feature/location/widget/map_tiles.dart'; import 'package:halkyon_todo_flutter/src/feature/location/widget/map_tiles.dart';
import 'package:halkyon_todo_flutter/src/feature/location/widget/place_search_screen.dart';
import 'package:halkyon_todo_flutter/src/tabs/pane_widths.dart';
import 'package:latlong2/latlong.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// Allows the position, as a device with the person's yes.
class _Allowed() extends NoLocationReminders {
@override
Future<LocationAccess> access() async => LocationAccess.whileInUse;
}
class _Places( class _Places(
final List<TaskLocation> found, { final List<TaskLocation> found, {
final bool isOffered = true, final bool isOffered = true,
@ -58,6 +69,25 @@ void main() {
address: 'Bregenzer Straße 2, Reutin, Lindau', address: 'Bregenzer Straße 2, Reutin, Lindau',
); );
const phone = Size(390, 844);
/// Lets the search find what was typed, and its screen open.
Future<void> typed(WidgetTester tester, String text) async {
await tester.tap(find.byKey(locationSearchBarKey));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
await tester.enterText(find.byKey(locationSearchKey), text);
await tester.pump(const Duration(milliseconds: 400));
await tester.pump();
}
/// Lets a search's screen open and answer.
Future<void> settle(WidgetTester tester) async {
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.pump();
}
Future<List<TaskLocation?>> open( Future<List<TaskLocation?>> open(
WidgetTester tester, { WidgetTester tester, {
PlaceSearchGateway? places, PlaceSearchGateway? places,
@ -65,7 +95,12 @@ void main() {
String? query, String? query,
LatLon? at, LatLon? at,
MapTiles? tiles, MapTiles? tiles,
Size size = phone,
LocationReminderGateway gateway = const NoLocationReminders(),
}) async { }) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final picked = <TaskLocation?>[]; final picked = <TaskLocation?>[];
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
@ -81,9 +116,8 @@ void main() {
builder: (context) => LocationPicker( builder: (context) => LocationPicker(
places: places, places: places,
mapTiles: tiles, mapTiles: tiles,
access: LocationReminderAccess( widths: PaneWidths(),
const NoLocationReminders(), access: LocationReminderAccess(gateway),
),
position: () async => at, position: () async => at,
recents: recents, recents: recents,
query: query, query: query,
@ -108,9 +142,7 @@ void main() {
) async { ) async {
final places = _Places(const [aeschach, reutin]); final places = _Places(const [aeschach, reutin]);
await open(tester, places: places); await open(tester, places: places);
await tester.enterText(find.byKey(locationSearchKey), 'Lindau EDEKA'); await typed(tester, 'Lindau EDEKA');
await tester.pump(const Duration(milliseconds: 400));
await tester.pump();
expect(places.asked, ['Lindau EDEKA']); expect(places.asked, ['Lindau EDEKA']);
expect(find.text(aeschach.address), findsOneWidget); expect(find.text(aeschach.address), findsOneWidget);
expect(find.text(reutin.address), findsOneWidget); expect(find.text(reutin.address), findsOneWidget);
@ -122,9 +154,10 @@ void main() {
places: _Places(const [aeschach, reutin]), places: _Places(const [aeschach, reutin]),
query: 'Lindau EDEKA', query: 'Lindau EDEKA',
); );
await tester.pump(); await settle(tester);
await tester.tap(find.text(reutin.address)); await tester.tap(find.text(reutin.address));
await tester.pump(const Duration(milliseconds: 300)); await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
await tester.tap(find.byKey(locationAddKey)); await tester.tap(find.byKey(locationAddKey));
await tester.pump(const Duration(milliseconds: 400)); await tester.pump(const Duration(milliseconds: 400));
expect(picked, [reutin]); expect(picked, [reutin]);
@ -134,7 +167,7 @@ void main() {
tester, tester,
) async { ) async {
await open(tester, places: _Places(const []), query: 'Nowhere'); await open(tester, places: _Places(const []), query: 'Nowhere');
await tester.pump(); await settle(tester);
expect(find.text('Nothing found'), findsOneWidget); expect(find.text('Nothing found'), findsOneWidget);
}); });
@ -146,7 +179,7 @@ void main() {
), ),
findsOneWidget, findsOneWidget,
); );
expect(find.byKey(locationSearchKey), findsNothing); expect(find.byKey(locationSearchBarKey), findsNothing);
expect(find.byKey(locationMapKey), findsOneWidget); expect(find.byKey(locationMapKey), findsOneWidget);
}); });
@ -155,7 +188,7 @@ void main() {
) async { ) async {
await open(tester, places: _Places(const [], isOffered: false)); await open(tester, places: _Places(const [], isOffered: false));
await tester.pump(); await tester.pump();
expect(find.byKey(locationSearchKey), findsNothing); expect(find.byKey(locationSearchBarKey), findsNothing);
expect( expect(
find.text( find.text(
"Search isn't available here. Move the map and tap to place a pin.", "Search isn't available here. Move the map and tap to place a pin.",
@ -179,12 +212,87 @@ void main() {
places: _Places(const [aeschach]), places: _Places(const [aeschach]),
query: 'EDEKA', query: 'EDEKA',
); );
await tester.pump(); await settle(tester);
await tester.tap(find.text(aeschach.address)); await tester.tap(find.text(aeschach.address));
await tester.pump(const Duration(milliseconds: 300)); await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('On arriving'), findsNothing); expect(find.text('On arriving'), findsNothing);
}); });
group('the search, a screen of its own', () {
const shop = TaskLocation(
name: 'EDEKA',
lat: 47.56,
lon: 9.70,
address: 'Lindau',
kind: 'supermarket',
);
const unknown = TaskLocation(
name: 'Somewhere',
lat: 47.57,
lon: 9.70,
kind: 'no_such_value',
);
testWidgets('the bar over the map opens it, the map stays behind', (
tester,
) async {
await open(tester, places: _Places(const []));
expect(find.byKey(locationSearchKey), findsNothing);
await tester.tap(find.byKey(locationSearchBarKey));
await settle(tester);
expect(find.byType(PlaceSearchScreen), findsOneWidget);
expect(find.byKey(locationSearchKey), findsOneWidget);
await tester.tap(find.byType(BackButton));
await settle(tester);
expect(find.byType(PlaceSearchScreen), findsNothing);
expect(find.byKey(locationMapKey), findsOneWidget);
});
testWidgets('a row says what it is, where it lies and how far it is', (
tester,
) async {
await open(
tester,
places: _Places(const [shop, unknown]),
at: (lat: 47.55, lon: 9.70),
);
await typed(tester, 'EDEKA');
expect(find.text('Supermarket · Lindau'), findsOneWidget);
expect(find.text('1.1 km'), findsOneWidget);
expect(find.textContaining('no_such_value'), findsNothing);
});
testWidgets('a place chosen comes back to the map, in the bar', (
tester,
) async {
final picked = await open(tester, places: _Places(const [shop]));
await typed(tester, 'EDEKA');
await tester.tap(find.text('Supermarket · Lindau'));
await settle(tester);
expect(find.byType(PlaceSearchScreen), findsNothing);
expect(
find.descendant(
of: find.byKey(locationSearchBarKey),
matching: find.text('EDEKA'),
),
findsOneWidget,
);
await tester.tap(find.byKey(locationAddKey));
await settle(tester);
expect(picked.single?.name, 'EDEKA');
expect(picked.single?.kind, isNull);
});
test('distances read as a list says them', () {
expect(distanceText(850.4, 'en'), '850 m');
expect(distanceText(1234, 'en'), '1.2 km');
expect(distanceText(1234, 'de'), '1,2 km');
expect(distanceText(178000, 'en'), '178 km');
expect(distanceText(6554000, 'de'), '6.554 km');
});
});
group("the pin in the map's middle", () { group("the pin in the map's middle", () {
const here = (lat: 47.55, lon: 9.70); const here = (lat: 47.55, lon: 9.70);
const street = TaskLocation( const street = TaskLocation(
@ -318,6 +426,152 @@ void main() {
await rest(tester); await rest(tester);
expect(find.text('Pinned place'), findsOneWidget); expect(find.text('Pinned place'), findsOneWidget);
expect(find.byKey(locationAddKey), findsOneWidget); expect(find.byKey(locationAddKey), findsOneWidget);
expect(find.text("Couldn't look up what's around here"), findsOneWidget);
});
group('asking as little as it can', () {
testWidgets('zooming asks nothing, and keeps the name', (tester) async {
final places = _Places(const [], nearby: found);
await open(tester, places: places, at: here);
await rest(tester);
final before = places.looked.length;
expect(before, 1);
await tester.tap(find.byTooltip('Zoom in'));
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.byTooltip('Zoom in'));
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.byTooltip('Zoom out'));
await rest(tester);
expect(places.looked.length, before);
expect(find.text('Danziger Weg 4'), findsOneWidget);
});
testWidgets('several moves in a row ask once', (tester) async {
final places = _Places(const [], nearby: found);
await open(tester, places: places, at: here);
await rest(tester);
final before = places.looked.length;
for (var move = 0; move < 4; move++) {
await dragMap(tester);
await tester.pump(const Duration(milliseconds: 500));
}
await rest(tester);
expect(places.looked.length, before + 1);
});
testWidgets('one question at a time, then the latest spot', (
tester,
) async {
final first = Completer<PlacesAround>();
var asked = 0;
final places = _Places(
const [],
nearby: (_) => asked++ == 0
? first.future
: Future.value(const PlacesAround(spot: street)),
);
await open(tester, places: places, at: here);
await rest(tester);
expect(places.looked.length, 1);
await dragMap(tester);
await rest(tester);
await dragMap(tester);
await rest(tester);
expect(places.looked.length, 1);
first.complete(const PlacesAround(spot: bakery));
await rest(tester);
expect(places.looked.length, 2);
});
testWidgets('an answer that never comes holds up nothing', (
tester,
) async {
var asked = 0;
final places = _Places(
const [],
nearby: (_) => asked++ == 0
? Completer<PlacesAround>().future
: Future.value(const PlacesAround(spot: street)),
);
await open(tester, places: places, at: here);
await tester.pump(const Duration(seconds: 12));
await rest(tester);
expect(
find.text("Couldn't look up what's around here"),
findsOneWidget,
);
});
testWidgets('a spot gone back to asks nothing', (tester) async {
late BuildContext inMap;
final places = _Places(const [], nearby: found);
await open(
tester,
places: places,
at: here,
tiles: () => Builder(
builder: (context) {
inMap = context;
return const SizedBox();
},
),
);
await rest(tester);
final start = MapCamera.of(inMap).center;
await dragMap(tester);
await rest(tester);
expect(places.looked.length, 2);
MapController.of(inMap).move(start, MapCamera.of(inMap).zoom);
await tester.tap(find.byTooltip('Zoom in'));
await rest(tester);
expect(places.looked.length, 2);
});
testWidgets('after a failure it waits, and Try again asks at once', (
tester,
) async {
var failing = true;
final places = _Places(
const [],
nearby: (point) =>
failing ? Future.error(Exception('503')) : found(point),
);
await open(tester, places: places, at: here);
await rest(tester);
expect(places.looked.length, 1);
await dragMap(tester);
await rest(tester);
expect(places.looked.length, 1);
failing = false;
await tester.tap(find.text('Try again'));
await rest(tester);
expect(places.looked.length, 2);
expect(find.text('Try again'), findsNothing);
expect(find.text('Danziger Weg 4'), findsOneWidget);
});
testWidgets('the pause ends by itself', (tester) async {
final places = _Places(
const [],
nearby: (_) async => throw Exception('503'),
);
await open(tester, places: places, at: here);
await rest(tester);
await tester.pump(const Duration(seconds: 31));
await dragMap(tester);
await rest(tester);
expect(places.looked.length, 2);
});
});
testWidgets('a spot with nothing around it reports no failure', (
tester,
) async {
await open(tester, places: _Places(const []), at: here);
await rest(tester);
expect(find.text("Couldn't look up what's around here"), findsNothing);
}); });
testWidgets('the answer for a spot left behind is dropped', (tester) async { testWidgets('the answer for a spot left behind is dropped', (tester) async {
@ -342,17 +596,370 @@ void main() {
expect(find.text('Schwarz'), findsNothing); expect(find.text('Schwarz'), findsNothing);
}); });
group('the sheet', () {
List<TaskLocation> many(int count) => [
for (var index = 0; index < count; index++)
TaskLocation(
name: 'Place $index',
lat: 47.55 + index / 1000,
lon: 9.70,
),
];
Finder sheet() => find.byType(DraggableScrollableSheet);
Future<void> pullUp(WidgetTester tester) async {
await tester.drag(find.text('Place 0'), const Offset(0, -700));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.pump();
}
testWidgets('the map keeps its size whatever is found', (tester) async {
var count = 0;
await open(
tester,
places: _Places(
const [],
nearby: (_) async => PlacesAround(
spot: street,
places: many(count++ == 0 ? 8 : 0),
),
),
at: here,
);
await rest(tester);
final before = tester.getSize(find.byKey(locationMapKey));
expect(find.text('Place 0'), findsOneWidget);
await dragMap(tester);
await rest(tester);
expect(find.text('Place 0'), findsNothing);
expect(tester.getSize(find.byKey(locationMapKey)), before);
});
testWidgets('nor does zooming far out, which leaves nothing chosen', (
tester,
) async {
late BuildContext inMap;
await open(
tester,
places: _Places(const [], nearby: found),
at: here,
tiles: () => Builder(
builder: (context) {
inMap = context;
return const SizedBox();
},
),
);
await rest(tester);
final before = tester.getSize(find.byKey(locationMapKey));
await tester.tap(find.byTooltip('Zoom out'));
MapController.of(inMap).move(MapCamera.of(inMap).center, 10);
await tester.tap(find.byTooltip('Zoom out'));
await rest(tester);
expect(find.byKey(locationAddKey), findsNothing);
expect(find.text('Zoom in and move the map to a spot'), findsOneWidget);
expect(tester.getSize(find.byKey(locationMapKey)), before);
});
testWidgets('pulled up over the map it shows the whole list', (
tester,
) async {
await open(
tester,
places: _Places(
const [],
nearby: (_) async => PlacesAround(spot: street, places: many(30)),
),
at: here,
);
await rest(tester);
final before = tester.getSize(find.byKey(locationMapKey));
expect(find.byKey(locationSheetBarKey), findsNothing);
await pullUp(tester);
expect(find.byKey(locationSheetBarKey), findsOneWidget);
expect(
tester
.getTopLeft(
find.descendant(of: sheet(), matching: find.byType(ListView)),
)
.dy,
kToolbarHeight,
);
expect(tester.getSize(find.byKey(locationMapKey)), before);
await tester.scrollUntilVisible(
find.text('Place 29'),
200,
scrollable: find
.descendant(of: sheet(), matching: find.byType(Scrollable))
.first,
);
expect(find.text('Place 29'), findsOneWidget);
});
testWidgets('its bar lowers it, back too, and a place chosen', (
tester,
) async {
await open(
tester,
places: _Places(
const [],
nearby: (_) async => PlacesAround(spot: street, places: many(30)),
),
at: here,
);
await rest(tester);
await pullUp(tester);
await tester.tap(find.byType(BackButton));
await rest(tester);
expect(find.byKey(locationSheetBarKey), findsNothing);
await pullUp(tester);
await tester.binding.handlePopRoute();
await rest(tester);
expect(find.byKey(locationSheetBarKey), findsNothing);
expect(find.byType(LocationPicker), findsOneWidget);
await pullUp(tester);
await tester.tap(find.text('Place 3'));
await rest(tester);
expect(find.byKey(locationSheetBarKey), findsNothing);
});
testWidgets('in a wide window it is a pane beside the map', (
tester,
) async {
await open(
tester,
places: _Places(const [], nearby: found),
at: here,
size: const Size(1280, 800),
);
await rest(tester);
expect(sheet(), findsNothing);
expect(find.byKey(locationAddKey), findsOneWidget);
final map = tester.getRect(find.byKey(locationMapKey));
final add = tester.getRect(find.byKey(locationAddKey));
expect(add.left, greaterThanOrEqualTo(map.right));
expect(map.height, 800);
});
testWidgets('turning the phone keeps where the map is', (tester) async {
late BuildContext inMap;
await open(
tester,
places: _Places(const [], nearby: found),
at: here,
tiles: () => Builder(
builder: (context) {
inMap = context;
return const SizedBox();
},
),
);
await rest(tester);
const spot = LatLng(47.6, 9.8);
MapController.of(inMap).move(spot, 17);
tester.view.physicalSize = const Size(844, 390);
await tester.pump();
await rest(tester);
expect(sheet(), findsNothing);
final camera = MapCamera.of(inMap);
expect(camera.zoom, 17);
expect(camera.center.latitude, closeTo(spot.latitude, 0.0001));
expect(camera.center.longitude, closeTo(spot.longitude, 0.0001));
expect(tester.takeException(), isNull);
});
testWidgets('a landscape phone gets the pane too', (tester) async {
await open(
tester,
places: _Places(const [], nearby: found),
at: here,
size: const Size(844, 390),
);
await rest(tester);
expect(sheet(), findsNothing);
expect(tester.takeException(), isNull);
});
});
group('the compass', () {
late BuildContext inMap;
Widget mapContext() => Builder(
builder: (context) {
inMap = context;
return const SizedBox();
},
);
testWidgets('sits under the bar on the right', (tester) async {
await open(tester, places: _Places(const []), tiles: mapContext);
final bar = tester.getRect(find.byKey(locationSearchBarKey));
final compass = tester.getRect(find.byKey(locationNorthKey));
expect(compass.top, greaterThanOrEqualTo(bar.bottom));
expect(
compass.right,
tester.getRect(find.byKey(locationMapKey)).right - 12,
);
});
testWidgets('turns the map to north, and its needle follows', (
tester,
) async {
await open(tester, places: _Places(const []), tiles: mapContext);
await tester.pump();
MapController.of(inMap).rotate(40);
await tester.pump();
await tester.pump();
final needle = find.descendant(
of: find.byKey(locationNorthKey),
matching: find.byType(Transform),
);
double angle() => -atan2(
tester.widget<Transform>(needle.last).transform.entry(0, 1),
tester.widget<Transform>(needle.last).transform.entry(0, 0),
);
expect(angle(), closeTo(40 * pi / 180, 0.001));
await tester.tap(find.byKey(locationNorthKey));
await tester.pump();
expect(MapCamera.of(inMap).rotation, 0);
expect(angle(), closeTo(0, 0.001));
});
});
testWidgets("the pin's shadow lies on the map's middle", (tester) async {
await open(tester, places: _Places(const []), at: here);
await rest(tester);
final middle = tester.getCenter(find.byKey(locationMapKey));
final pin = tester.getRect(find.byKey(locationPinKey));
expect(pin.center.dx, middle.dx);
expect(pin.bottom - 2, middle.dy);
});
group('two fingers', () {
late BuildContext inMap;
Widget mapContext() => Builder(
builder: (context) {
inMap = context;
return const SizedBox();
},
);
/// Two fingers on a circle around the map's middle, from [from] pixels
/// out to [to], turning by [degrees].
Future<void> fingers(
WidgetTester tester, {
required double from,
required double to,
required double degrees,
}) async {
final middle = tester.getCenter(find.byKey(locationMapKey));
Offset at(double radius, double turn) =>
Offset.fromDirection(turn, radius);
final first = await tester.startGesture(
middle + at(from, 0),
pointer: 1,
);
final second = await tester.startGesture(
middle - at(from, 0),
pointer: 2,
);
await tester.pump(const Duration(milliseconds: 50));
const steps = 24;
for (var step = 1; step <= steps; step++) {
final radius = from + (to - from) * step / steps;
final turn = degrees * pi / 180 * step / steps;
await first.moveTo(middle + at(radius, turn));
await second.moveTo(middle - at(radius, turn));
await tester.pump(const Duration(milliseconds: 16));
}
await first.up();
await second.up();
await rest(tester);
}
testWidgets('a pinch only zooms, the fingers turning a little', (
tester,
) async {
await open(
tester,
places: _Places(const []),
at: here,
tiles: mapContext,
);
await rest(tester);
final before = MapCamera.of(inMap).zoom;
await fingers(tester, from: 40, to: 150, degrees: 12);
expect(MapCamera.of(inMap).zoom, greaterThan(before));
expect(MapCamera.of(inMap).rotation, 0);
});
testWidgets('a twist turns the map', (tester) async {
await open(
tester,
places: _Places(const []),
at: here,
tiles: mapContext,
);
await rest(tester);
await fingers(tester, from: 100, to: 100, degrees: 50);
expect(MapCamera.of(inMap).rotation, isNot(0));
});
});
group('my position', () {
late BuildContext inMap;
Widget mapContext() => Builder(
builder: (context) {
inMap = context;
return const SizedBox();
},
);
testWidgets('keeps a closer zoom', (tester) async {
await open(
tester,
places: _Places(const []),
at: here,
tiles: mapContext,
gateway: _Allowed(),
);
await rest(tester);
MapController.of(inMap).move(MapCamera.of(inMap).center, 17.5);
await tester.tap(find.byTooltip('My position'));
await rest(tester);
expect(MapCamera.of(inMap).zoom, 17.5);
});
testWidgets('comes in as far as a place shows, from further out', (
tester,
) async {
await open(
tester,
places: _Places(const []),
at: here,
tiles: mapContext,
gateway: _Allowed(),
);
await rest(tester);
MapController.of(inMap).move(MapCamera.of(inMap).center, 6);
await tester.tap(find.byTooltip('My position'));
await rest(tester);
expect(MapCamera.of(inMap).zoom, 15);
});
});
testWidgets('the credit fits a narrow phone, and can be read', ( testWidgets('the credit fits a narrow phone, and can be read', (
tester, tester,
) async { ) async {
tester.view.physicalSize = const Size(320, 640);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await open( await open(
tester, tester,
places: _Places(const []), places: _Places(const []),
at: here, at: here,
tiles: () => const SizedBox(), tiles: () => const SizedBox(),
size: const Size(320, 640),
); );
await rest(tester); await rest(tester);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);

View file

@ -0,0 +1,29 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/location/widget/map_tiles.dart';
import 'package:vector_map_tiles/vector_map_tiles.dart';
import 'package:vector_tile_renderer/vector_tile_renderer.dart'
show ThemeReader;
/// OpenFreeMap's styles name themselves nothing, and vector_map_tiles keeps
/// its drawn tiles under a style's name.
void main() {
Style unnamed() => Style(
theme: ThemeReader().read({'version': 8, 'layers': <Object?>[]}),
providers: const TileProviders({}),
);
test('a style read has no name of its own', () {
expect(unnamed().theme.id, 'default');
});
test('a dark theme and a light one ask for their own style', () {
expect(mapStyleName(dark: true), 'dark');
expect(mapStyleName(dark: false), 'liberty');
});
test('dark and light are kept apart', () {
final dark = namedMapStyle(unnamed(), mapStyleName(dark: true));
final light = namedMapStyle(unnamed(), mapStyleName(dark: false));
expect(dark.theme.id, isNot(light.theme.id));
});
}

View file

@ -621,7 +621,31 @@ The developer's review of 4 Nov 2026, again beside Todoist's screens.
nearest address names the spot, and places to go to are listed below it (Threema's). nearest address names the spot, and places to go to are listed below it (Threema's).
Two lookups, as a plain one finds the eight nearest house numbers alone; the second Two lookups, as a plain one finds the eight nearest house numbers alone; the second
asks for shops, amenities, tourism and leisure. Further out than zoom 13 the pin stands asks for shops, amenities, tourism and leisure. Further out than zoom 13 the pin stands
for no spot and nothing is asked. `PlaceSearchGateway` is the seam: for no spot and nothing is asked. On the developer's trial of the first build
(5 Nov 2026) the map stopped changing size with what is around it: below a medium
width a sheet of fixed height (42 % of the screen, as Threema's) sits under the map and
is pulled up over it for the whole list, the map dimming and a bar coming in above it
(back or a place chosen lowers it); from a medium width on, a landscape phone too, it
is a pane beside the map, Todo's `SidePane`. The search is a screen of its own
(Threema's), as the keyboard left the map no room: name, what it is and where it lies,
the distance on the right. What it is comes from OpenStreetMap's value, named in the
app's language where `locationKind` knows it, nothing otherwise rather than English
tags in a German list, and it is never kept. The address stays beside it, Threema
showing the kind alone but two EDEKAs needing the street. A compass under the bar turns
the map to north, and my position keeps a closer zoom than a place's 15. Two fingers do
what shows first, zooming from 0.15 of a level or a turn of 15 degrees (flutter_map's gesture
race, off by default, turned the map on every pinch; its 0.5 felt like lag), and the pin's shadow lies on the map's middle,
so that the phone's position sits under it. A look around that fails says so, the
spot staying a pinned place, as it reads otherwise as a place with nothing around,
with Try again. It stays automatic, a button being a tap more in the usual case, but
asks little (komoot's public Photon answered 503 to a person moving a map about, then
refused connections, and an instance counts each among a person's 60 searches a
minute): 800 ms after the map stopped, one question at a time and then the latest spot,
nothing for a spot within 25 m or 12 pixels of one answered, the 16 latest answers
being kept, so that zooming asks nothing, and nothing for 30 seconds after a failure. OpenFreeMap's
styles name themselves nothing and vector_map_tiles keeps the tiles it draws on the
device under that name, so each is given its own, or the dark theme got the light
theme's tiles. `PlaceSearchGateway` is the seam:
`TodoDependencies.places` is the instance's search, or `PublicPhotonGateway` to `TodoDependencies.places` is the instance's search, or `PublicPhotonGateway` to
komoot's public Photon, in a debug build asked for it alone komoot's public Photon, in a debug build asked for it alone
(`--dart-define=TODO_PUBLIC_PHOTON=true`), with a generic User-Agent and no personal (`--dart-define=TODO_PUBLIC_PHOTON=true`), with a generic User-Agent and no personal