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

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:math';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
@ -10,13 +11,23 @@ import 'package:halkyon_todo_flutter/src/feature/location/data/location_reminder
import 'package:halkyon_todo_flutter/src/feature/location/data/place_search_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/model/task_location.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/feature/task/bloc/todo_cubit.dart'; import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/tabs/pane_widths.dart';
import 'package:halkyon_todo_flutter/src/tabs/side_pane.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// The key of the picker's search field. /// The key of the bar opening the picker's search.
const locationSearchKey = ValueKey('location-search'); const locationSearchBarKey = ValueKey('location-search-bar');
/// The key of the bar above the sheet pulled all the way up.
const locationSheetBarKey = ValueKey('location-sheet-bar');
/// The key of the compass.
const locationNorthKey = ValueKey('location-north');
/// The key of the picker's map. /// The key of the picker's map.
const locationMapKey = ValueKey('location-map'); const locationMapKey = ValueKey('location-map');
@ -41,6 +52,10 @@ List<TaskLocation> recentsOf(TodoState state) => recentLocations(
/// the pin in the map's middle, which stays there as the map moves beneath /// the pin in the map's middle, which stays there as the map moves beneath
/// it, named by what is near, with the places around it to choose from; with /// it, named by what is near, with the places around it to choose from; with
/// what it reminds of. The place chosen, if any. /// what it reminds of. The place chosen, if any.
///
/// On a phone the map keeps its size whatever is found: a sheet of fixed
/// height below it, pulled up over it for the whole list; beside the map in
/// a wider window.
Future<TaskLocation?> showLocationPicker( Future<TaskLocation?> showLocationPicker(
BuildContext context, { BuildContext context, {
String? query, String? query,
@ -54,6 +69,7 @@ Future<TaskLocation?> showLocationPicker(
builder: (context) => LocationPicker( builder: (context) => LocationPicker(
places: dependencies.places, places: dependencies.places,
mapTiles: dependencies.mapTiles, mapTiles: dependencies.mapTiles,
widths: todo.paneWidths,
access: todo.locationAccess, access: todo.locationAccess,
position: todo.locationReminders.lastKnown, position: todo.locationReminders.lastKnown,
recents: recents, recents: recents,
@ -67,6 +83,7 @@ Future<TaskLocation?> showLocationPicker(
class const LocationPicker({ class const LocationPicker({
required final PlaceSearchGateway? places, required final PlaceSearchGateway? places,
required final MapTiles? mapTiles, required final MapTiles? mapTiles,
required final PaneWidths widths,
required final LocationReminderAccess access, required final LocationReminderAccess access,
required final Future<LatLon?> Function() position, required final Future<LatLon?> Function() position,
required final List<TaskLocation> recents, required final List<TaskLocation> recents,
@ -85,28 +102,89 @@ class _LocationPickerState() extends State<LocationPicker> {
/// region, and nothing is asked of the instance. /// region, and nothing is asked of the instance.
static const _closeEnough = 13.0; static const _closeEnough = 13.0;
late final _search = TextEditingController(text: widget.query); /// How long after the map stopped what is around the pin is asked, so
final _searchFocus = FocusNode(); /// that looking around the map asks once. The public Photon throttles (it
/// answered 503 to a person moving a map about), and an instance's counts
/// each of these among a person's searches.
static const _askAfter = Duration(milliseconds: 800);
/// How long after the last move the pin comes down.
static const _landAfter = Duration(milliseconds: 450);
/// The pin stands on the same spot within this many metres.
static const _sameSpot = 25.0;
/// Or this many pixels, further out.
static const _sameSpotPixels = 12.0;
/// The Earth's circumference at the equator, in metres.
static const _earthAround = 40075016.686;
/// How many answers are kept while the picker is open.
static const _answersKept = 16;
/// How long an answer may take: after this the next question may go.
static const _askedLongEnough = Duration(seconds: 10);
/// How long nothing is asked after a failure, unless the person asks.
static const _pauseAfterFailure = Duration(seconds: 30);
/// How far two fingers turn, in degrees, before the map turns with them.
static const _turnFrom = 15.0;
/// The zoom a place is shown at, at least.
static const _placeZoom = 15.0;
/// The share of the screen the sheet has unless pulled up, whatever is in
/// it: the map beside it never changes size.
static const _sheetShare = 0.42;
/// How wide the pane beside the map is until it is dragged.
static const _paneWidth = 380.0;
/// Keeps the map, and where it is, as the phone turns and a sheet becomes
/// a pane.
final _mapHolder = GlobalKey();
final _sheet = DraggableScrollableController();
/// Whether the sheet is pulled up.
var _pulledUp = false;
/// The map's turn, in radians.
final _turn = ValueNotifier<double>(0);
/// The search the map shows the places of.
String? _query;
final _name = TextEditingController(); final _name = TextEditingController();
final _map = MapController(); final _map = MapController();
var _mapReady = false; var _mapReady = false;
Timer? _typing;
Timer? _moved; Timer? _moved;
/// Whether the map is moving, the pin lifted off it. /// Whether the map is moving, the pin lifted off it.
var _moving = false; var _moving = false;
/// Answers to anything but the latest look around are dropped. /// The spot under the pin, whose answer is what shows.
var _lookedAround = 0; LatLon? _spot;
/// The spot to ask about next, once nothing moves and nothing is asked.
LatLon? _wanted;
Timer? _asking;
var _lookingUp = false;
/// Runs while nothing is asked, after a failure.
Timer? _paused;
/// What was answered of late, the latest first.
final _answers = <({LatLon at, PlacesAround found})>[];
/// Whether the latest look around failed, rather than found nothing.
var _lookupFailed = false;
List<TaskLocation> _around = const []; List<TaskLocation> _around = const [];
/// Whether the person typed a name, which no address then replaces. /// Whether the person typed a name, which no address then replaces.
var _renamed = false; var _renamed = false;
/// Answers to anything but the latest search are dropped.
var _asked = 0;
var _searching = false;
var _failed = false;
var _offered = true; var _offered = true;
List<TaskLocation>? _results; List<TaskLocation>? _results;
LatLon? _position; LatLon? _position;
@ -118,21 +196,22 @@ class _LocationPickerState() extends State<LocationPicker> {
void initState() { void initState() {
super.initState(); super.initState();
unawaited(_askOffered()); unawaited(_askOffered());
_searchFocus.addListener(() => setState(() {})); _sheet.addListener(_sheetMoved);
unawaited(_locate()); unawaited(_locate());
if (widget.query?.trim().isNotEmpty ?? false) { if (widget.query?.trim().isNotEmpty ?? false) {
WidgetsBinding.instance.addPostFrameCallback( WidgetsBinding.instance.addPostFrameCallback(
(_) => unawaited(_find(widget.query!)), (_) => unawaited(_openSearch(widget.query)),
); );
} }
} }
@override @override
void dispose() { void dispose() {
_typing?.cancel();
_moved?.cancel(); _moved?.cancel();
_search.dispose(); _asking?.cancel();
_searchFocus.dispose(); _paused?.cancel();
_sheet.dispose();
_turn.dispose();
_name.dispose(); _name.dispose();
_map.dispose(); _map.dispose();
super.dispose(); super.dispose();
@ -147,7 +226,7 @@ class _LocationPickerState() extends State<LocationPicker> {
setState(() => _position = at); setState(() => _position = at);
if (_mapReady && _results == null && _picked == null) { if (_mapReady && _results == null && _picked == null) {
_map.move(LatLng(at.lat, at.lon), 14); _map.move(LatLng(at.lat, at.lon), 14);
unawaited(_settled()); _settled();
} }
} }
@ -158,28 +237,11 @@ class _LocationPickerState() extends State<LocationPicker> {
final at = await widget.position(); final at = await widget.position();
if (mounted && at != null) { if (mounted && at != null) {
setState(() => _position = at); setState(() => _position = at);
_map.move(LatLng(at.lat, at.lon), 15); _map.move(LatLng(at.lat, at.lon), max(_map.camera.zoom, _placeZoom));
unawaited(_settled()); _settled();
} }
} }
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)),
);
}
/// Where a search looks first: where the map shows, else the phone. /// Where a search looks first: where the map shows, else the phone.
LatLon? get _near { LatLon? get _near {
if (_mapReady) { if (_mapReady) {
@ -197,38 +259,65 @@ class _LocationPickerState() extends State<LocationPicker> {
} }
} }
Future<void> _find(String text) async { /// The search's screen, [query] to start with; the place chosen there,
/// the others as pins on the map to compare it with.
Future<void> _openSearch(String? query) async {
final places = widget.places; final places = widget.places;
if (places == null) { if (places == null) {
return; return;
} }
final asked = ++_asked; final searched = await Navigator.of(context).push<PlaceSearched>(
final language = Localizations.localeOf(context).languageCode; MaterialPageRoute(
setState(() { builder: (context) => PlaceSearchScreen(
_searching = true; places: places,
_failed = false;
});
try {
final found = await places.search(
text.trim(),
near: _near, near: _near,
language: language, from: _position ?? _near,
recents: widget.recents,
query: query,
),
),
); );
if (mounted && asked == _asked) { if (mounted && searched != null) {
setState(() { setState(() {
_results = found; _results = searched.found;
_searching = false; _query = searched.query;
}); });
_show(found); _pick(searched.picked);
} }
} on Object { }
if (mounted && asked == _asked) {
setState(() { void _clearSearch() => setState(() {
_searching = false; _results = null;
_failed = true; _query = null;
}); });
void _sheetMoved() {
final up = _sheet.size > _middleOfSheet;
if (up != _pulledUp) {
setState(() => _pulledUp = up);
} }
} }
/// Where the sheet is nearer pulled up than not.
double get _middleOfSheet => (_sheetShare + _fullSheet) / 2;
/// The sheet's share of the screen pulled up, under its bar.
double get _fullSheet {
final height = MediaQuery.sizeOf(context).height;
return (height - _sheetBarHeight) / height;
}
double get _sheetBarHeight =>
kToolbarHeight + MediaQuery.paddingOf(context).top;
void _lowerSheet() {
if (_sheet.isAttached) {
_sheet.animateTo(
_sheetShare,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
} }
/// Fits the map around [places]. /// Fits the map around [places].
@ -250,9 +339,12 @@ class _LocationPickerState() extends State<LocationPicker> {
} }
void _pick(TaskLocation place) { void _pick(TaskLocation place) {
_searchFocus.unfocus(); _unfocus();
_lowerSheet();
_moved?.cancel(); _moved?.cancel();
_lookedAround++; _asking?.cancel();
_wanted = null;
_spot = null;
setState(() { setState(() {
_picked = place; _picked = place;
_byHand = false; _byHand = false;
@ -261,7 +353,7 @@ class _LocationPickerState() extends State<LocationPicker> {
if (_mapReady) { if (_mapReady) {
_map.move( _map.move(
LatLng(place.lat, place.lon), LatLng(place.lat, place.lon),
_map.camera.zoom < 15 ? 15 : _map.camera.zoom, max(_map.camera.zoom, _placeZoom),
); );
} }
} }
@ -269,11 +361,13 @@ class _LocationPickerState() extends State<LocationPicker> {
/// The person moves the map: the pin lifts, and comes down a moment after /// The person moves the map: the pin lifts, and comes down a moment after
/// the last move, a fling's too. /// the last move, a fling's too.
void _gesture() { void _gesture() {
_searchFocus.unfocus(); _unfocus();
_moved?.cancel(); _moved?.cancel();
_asking?.cancel();
_wanted = null;
_moved = Timer( _moved = Timer(
const Duration(milliseconds: 450), _landAfter,
() => unawaited(_settled()), () => _settled(wait: _askAfter - _landAfter),
); );
if (!_moving) { if (!_moving) {
setState(() => _moving = true); setState(() => _moving = true);
@ -282,34 +376,41 @@ class _LocationPickerState() extends State<LocationPicker> {
/// A tap moves the spot there, for those who'd rather point at it. /// A tap moves the spot there, for those who'd rather point at it.
void _tapped(LatLng point) { void _tapped(LatLng point) {
_searchFocus.unfocus(); _unfocus();
_map.move(point, _map.camera.zoom); _map.move(point, _map.camera.zoom);
unawaited(_settled()); _settled();
} }
/// The spot under the pin, which the map stopped at, named by what is near void _unfocus() => FocusManager.instance.primaryFocus?.unfocus();
/// and with the places around it, if anything; its name editable.
Future<void> _settled() async { /// The spot under the pin, which the map stopped at, its name editable,
/// named by what is near and with the places around it, if anything: by
/// what is known of the spot, else asked after [wait], so that a map still
/// moving asks nothing.
void _settled({Duration wait = _askAfter}) {
_moved?.cancel(); _moved?.cancel();
_asking?.cancel();
_wanted = null;
if (!mounted || !_mapReady) { if (!mounted || !_mapReady) {
return; return;
} }
final asked = ++_lookedAround;
if (_map.camera.zoom < _closeEnough) { if (_map.camera.zoom < _closeEnough) {
setState(() { setState(() {
_moving = false; _moving = false;
_picked = null; _picked = null;
_spot = null;
_around = const []; _around = const [];
_lookupFailed = false;
}); });
return; return;
} }
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final language = Localizations.localeOf(context).languageCode;
final centre = _map.camera.center; final centre = _map.camera.center;
final spot = (lat: centre.latitude, lon: centre.longitude);
final pin = TaskLocation( final pin = TaskLocation(
name: l10n.locationPin, name: l10n.locationPin,
lat: centre.latitude, lat: spot.lat,
lon: centre.longitude, lon: spot.lon,
); );
if (!_renamed) { if (!_renamed) {
_name.text = pin.name; _name.text = pin.name;
@ -318,41 +419,111 @@ class _LocationPickerState() extends State<LocationPicker> {
_moving = false; _moving = false;
_picked = pin; _picked = pin;
_byHand = true; _byHand = true;
_spot = spot;
}); });
final places = widget.places; if (_known(spot) case final known?) {
if (places == null || !_offered) { _showAround(known, spot);
return; return;
} }
PlacesAround found; if (widget.places != null && _offered) {
try { _wanted = spot;
found = await places.around( _asking = Timer(wait, () => unawaited(_ask()));
(lat: pin.lat, lon: pin.lon), }
language: language, }
/// What was answered for a spot so near [spot] that it says the same: how
/// near depends on how far out the map is, a pixel being more of the
/// ground there.
PlacesAround? _known(LatLon spot) {
final camera = _map.camera;
final metresPerPixel =
_earthAround * cos(spot.lat * pi / 180) / (256 * pow(2, camera.zoom));
final near = max(_sameSpot, _sameSpotPixels * metresPerPixel);
for (final (:at, :found) in _answers) {
final apart = const Distance().as(
LengthUnit.Meter,
LatLng(at.lat, at.lon),
LatLng(spot.lat, spot.lon),
); );
} on Object { if (apart <= near) {
// The pin stands, unnamed; the person can name it. return found;
found = const PlacesAround();
} }
if (!mounted || asked != _lookedAround) { }
return null;
}
/// Asks what is around the spot wanted; one question at a time, as answers
/// take a while and the map moves on: when it is answered, the latest spot
/// wanted is next. Asks nothing during the pause after a failure.
Future<void> _ask() async {
final places = widget.places;
final spot = _wanted;
if (places == null || spot == null || _lookingUp || _paused != null) {
return; return;
} }
_wanted = null;
_lookingUp = true;
final language = Localizations.localeOf(context).languageCode;
PlacesAround? found;
try {
found = await places
.around(spot, language: language)
.timeout(_askedLongEnough);
} on Object catch (error) {
debugPrint('Nothing looked up around the pin: ${error.runtimeType}');
}
_lookingUp = false;
if (!mounted) {
return;
}
if (found == null) {
_paused = Timer(_pauseAfterFailure, () => _paused = null);
setState(() {
_lookupFailed = true;
if (spot == _spot) {
_around = const [];
}
});
return;
}
_answers.insert(0, (at: spot, found: found));
if (_answers.length > _answersKept) {
_answers.removeLast();
}
if (spot == _spot) {
_showAround(found, spot);
}
await _ask();
}
/// [found] as what is around [spot], where the pin stands.
void _showAround(PlacesAround found, LatLon spot) {
final named = found.spot; final named = found.spot;
if (named != null && !_renamed) { if (named != null && !_renamed) {
_name.text = named.name; _name.text = named.name;
} }
setState(() { setState(() {
_around = found.places; _around = found.places;
_lookupFailed = false;
if (named != null) { if (named != null) {
_picked = TaskLocation( _picked = TaskLocation(
name: named.name, name: named.name,
lat: pin.lat, lat: spot.lat,
lon: pin.lon, lon: spot.lon,
address: named.address, address: named.address,
); );
} }
}); });
} }
/// The person asks again, whatever the pause says.
void _tryAgain() {
_paused?.cancel();
_paused = null;
_wanted = _spot;
unawaited(_ask());
}
/// The place as chosen, the person told first why a reminder needs the /// The place as chosen, the person told first why a reminder needs the
/// position at all times, and asked (D32). /// position at all times, and asked (D32).
Future<void> _add() async { Future<void> _add() async {
@ -383,32 +554,148 @@ class _LocationPickerState() extends State<LocationPicker> {
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => Scaffold(
final picked = _searchFocus.hasFocus ? null : _picked; // The keyboard rises over the map, which keeps its size.
final list = _list(context); resizeToAvoidBottomInset: false,
return Scaffold( body: context.windowSize.isAtLeastMedium
body: Column( ? SidePane(
crossAxisAlignment: CrossAxisAlignment.stretch, kind: SidePaneKind.location,
children: [ widths: widget.widths,
Expanded(flex: 3, child: _mapView(context)), fallback: _paneWidth,
?switch (picked) { main: _mapView(context),
final picked? => _chosen(context, picked), pane: SafeArea(
null => null, left: false,
child: ListView(children: _details(context)),
),
)
: _withSheet(context),
);
/// The map with the sheet below it, pulled up over it for the whole list,
/// the map dimming behind it and a bar coming in above it (Threema's).
Widget _withSheet(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final media = MediaQuery.of(context);
double share() => switch (_sheet.isAttached) {
true => ((_sheet.size - _sheetShare) / (_fullSheet - _sheetShare)).clamp(
0.0,
1.0,
),
false => 0,
};
return PopScope(
canPop: !_pulledUp,
onPopInvokedWithResult: (popped, _) {
if (!popped) {
_lowerSheet();
}
}, },
if (list != null) child: Stack(
Expanded(flex: 2, child: list) children: [
else Positioned(
SizedBox(height: MediaQuery.paddingOf(context).bottom), top: 0,
left: 0,
right: 0,
height: media.size.height * (1 - _sheetShare),
child: _mapView(context),
),
Positioned.fill(
child: IgnorePointer(
child: ListenableBuilder(
listenable: _sheet,
builder: (context, _) => ColoredBox(
color: scheme.scrim.withValues(alpha: share() * 0.6),
),
),
),
),
Positioned.fill(
bottom: media.viewInsets.bottom,
child: DraggableScrollableSheet(
controller: _sheet,
initialChildSize: _sheetShare,
minChildSize: _sheetShare,
maxChildSize: _fullSheet,
snap: true,
builder: (context, scroll) => Material(
color: scheme.surfaceContainerLow,
elevation: 3,
child: ListView(
controller: scroll,
// A short list is pulled up as a long one is.
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.only(bottom: media.padding.bottom),
children: [
Center(
child: Container(
width: 32,
height: 4,
margin: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: scheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
..._details(context),
],
),
),
),
),
if (_pulledUp)
Positioned(
top: 0,
left: 0,
right: 0,
child: _sheetBar(context),
),
], ],
), ),
); );
} }
/// The bar floating over the map: closing, and the search. /// The bar above the sheet pulled up: lowering it, and the search.
Widget _sheetBar(BuildContext context) {
final l10n = AppLocalizations.of(context);
return AppBar(
key: locationSheetBarKey,
leading: BackButton(onPressed: _lowerSheet),
title: Text(l10n.locationAdd),
actions: [
if (widget.places != null && _offered)
IconButton(
tooltip: l10n.locationSearchAny,
icon: const Icon(LucideIcons.search),
onPressed: () => unawaited(_openSearch(_query)),
),
],
);
}
/// What the sheet or the pane says: what was chosen, then the list.
List<Widget> _details(BuildContext context) => [
switch (_picked) {
final picked? => _chosen(context, picked),
null => Padding(
padding: const EdgeInsets.all(16),
child: Text(
AppLocalizations.of(context).locationPickHint,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
},
..._list(context),
];
/// The bar floating over the map: closing, and the way to the search.
Widget _bar(BuildContext context) { Widget _bar(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final search = widget.places != null && _offered; final search = widget.places != null && _offered;
final query = _query;
return Material( return Material(
elevation: 3, elevation: 3,
color: scheme.surfaceContainerHigh, color: scheme.surfaceContainerHigh,
@ -423,24 +710,22 @@ class _LocationPickerState() extends State<LocationPicker> {
), ),
Expanded( Expanded(
child: search child: search
? TextField( ? InkWell(
key: locationSearchKey, key: locationSearchBarKey,
controller: _search, onTap: () => unawaited(_openSearch(query)),
focusNode: _searchFocus, child: Padding(
textInputAction: TextInputAction.search, padding: const EdgeInsets.symmetric(vertical: 12),
textCapitalization: TextCapitalization.words, child: Text(
decoration: InputDecoration( query ?? l10n.locationSearchHint,
hintText: l10n.locationSearchHint, maxLines: 1,
border: InputBorder.none, overflow: TextOverflow.ellipsis,
enabledBorder: InputBorder.none, style: TextStyle(
focusedBorder: InputBorder.none, color: query == null
filled: false, ? scheme.onSurfaceVariant
: scheme.onSurface,
),
),
), ),
onChanged: _typed,
onSubmitted: (text) {
_typing?.cancel();
unawaited(_find(text));
},
) )
: Padding( : Padding(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
@ -450,12 +735,40 @@ class _LocationPickerState() extends State<LocationPicker> {
), ),
), ),
), ),
if (query != null)
IconButton(
tooltip: MaterialLocalizations.of(context).clearButtonTooltip,
icon: const Icon(LucideIcons.circleX),
onPressed: _clearSearch,
)
else
const SizedBox(width: 12), const SizedBox(width: 12),
], ],
), ),
); );
} }
/// The compass, its needle at north, turning the map back to it.
Widget _compass(BuildContext context) => SizedBox.square(
dimension: 40,
child: IconButton.filledTonal(
key: locationNorthKey,
padding: EdgeInsets.zero,
tooltip: AppLocalizations.of(context).locationNorth,
icon: ValueListenableBuilder(
valueListenable: _turn,
builder: (context, turn, needle) =>
Transform.rotate(angle: turn, child: needle),
child: const Icon(LucideIcons.navigation2, size: 20),
),
onPressed: () {
if (_mapReady) {
_map.rotate(0);
}
},
),
);
Widget _mapView(BuildContext context) { Widget _mapView(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
@ -463,6 +776,7 @@ class _LocationPickerState() extends State<LocationPicker> {
final position = _position; final position = _position;
final picked = _picked; final picked = _picked;
return Stack( return Stack(
key: _mapHolder,
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
FlutterMap( FlutterMap(
@ -475,6 +789,17 @@ class _LocationPickerState() extends State<LocationPicker> {
}, },
initialZoom: position == null ? 5.5 : 14, initialZoom: position == null ? 5.5 : 14,
maxZoom: mapMaxZoom, maxZoom: mapMaxZoom,
// Without the race every pinch turns the map as well: whichever
// of zooming and turning shows first is what the fingers do,
// though a turn begun on purpose may zoom too, as Google Maps'.
interactionOptions: const InteractionOptions(
enableMultiFingerGestureRace: true,
rotationThreshold: _turnFrom,
// flutter_map's 0.5 held a pinch back until it had come that
// far, which felt like lag.
pinchZoomThreshold: 0.15,
rotationWinGestures: MultiFingerGesture.all,
),
backgroundColor: scheme.surfaceContainer, backgroundColor: scheme.surfaceContainer,
onMapReady: () { onMapReady: () {
_mapReady = true; _mapReady = true;
@ -482,9 +807,11 @@ class _LocationPickerState() extends State<LocationPicker> {
_show(results); _show(results);
} else if (_position case (:final lat, :final lon)) { } else if (_position case (:final lat, :final lon)) {
_map.move(LatLng(lat, lon), 14); _map.move(LatLng(lat, lon), 14);
unawaited(_settled()); _settled();
} }
}, },
// A turn isn't a change of position to flutter_map.
onMapEvent: (event) => _turn.value = event.camera.rotationRad,
onPositionChanged: (_, hasGesture) { onPositionChanged: (_, hasGesture) {
if (hasGesture) { if (hasGesture) {
_gesture(); _gesture();
@ -520,7 +847,14 @@ class _LocationPickerState() extends State<LocationPicker> {
top: 8, top: 8,
left: 12, left: 12,
right: 12, right: 12,
child: SafeArea(bottom: false, child: _bar(context)), child: SafeArea(
bottom: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
spacing: 8,
children: [_bar(context), _compass(context)],
),
),
), ),
Positioned( Positioned(
right: 12, right: 12,
@ -572,7 +906,7 @@ class _LocationPickerState() extends State<LocationPicker> {
void _zoom(int by) { void _zoom(int by) {
if (_mapReady) { if (_mapReady) {
_map.move(_map.camera.center, _map.camera.zoom + by); _map.move(_map.camera.center, _map.camera.zoom + by);
unawaited(_settled()); _settled();
} }
} }
@ -582,10 +916,12 @@ class _LocationPickerState() extends State<LocationPicker> {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
const size = 44.0; const size = 44.0;
const lift = 20.0; const lift = 20.0;
const shadow = 4.0;
return IgnorePointer( return IgnorePointer(
child: Center( child: Center(
child: Transform.translate( child: Transform.translate(
offset: const Offset(0, -size / 2), // The foot of the box, where the shadow lies, on the map's middle.
offset: const Offset(0, shadow / 2 - (size + lift) / 2),
child: SizedBox( child: SizedBox(
key: locationPinKey, key: locationPinKey,
width: size, width: size,
@ -595,7 +931,7 @@ class _LocationPickerState() extends State<LocationPicker> {
children: [ children: [
Container( Container(
width: 10, width: 10,
height: 4, height: shadow,
decoration: BoxDecoration( decoration: BoxDecoration(
color: scheme.shadow.withValues(alpha: 0.35), color: scheme.shadow.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
@ -645,32 +981,32 @@ class _LocationPickerState() extends State<LocationPicker> {
); );
} }
/// What the search found, else the recent places; nothing to show, none. /// What the search found, else what is around the pin, else the recent
Widget? _list(BuildContext context) { /// places.
List<Widget> _list(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
final results = _results; final results = _results;
final nearby = results == null && _around.isNotEmpty; final nearby = results == null && _around.isNotEmpty;
final places = results ?? (nearby ? _around : widget.recents); final places = results ?? (nearby ? _around : widget.recents);
final message = switch ((_failed, results)) { return [
(true, _) => l10n.locationSearchFailed, if (results == null && _lookupFailed)
(_, []) when !_searching => l10n.locationNoResults,
_ => null,
};
if (message == null && places.isEmpty && !_searching) {
return null;
}
return ListView(
padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
children: [
if (_searching) const LinearProgressIndicator(minHeight: 2),
if (message != null)
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Row(
children: [
Expanded(
child: Text( child: Text(
message, l10n.locationNearbyFailed,
style: TextStyle(color: scheme.onSurfaceVariant), style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
TextButton(
onPressed: _tryAgain,
child: Text(l10n.locationTryAgain),
),
],
), ),
), ),
if (results == null && places.isNotEmpty) if (results == null && places.isNotEmpty)
@ -690,17 +1026,14 @@ class _LocationPickerState() extends State<LocationPicker> {
subtitle: place.address.isEmpty ? null : Text(place.address), subtitle: place.address.isEmpty ? null : Text(place.address),
onTap: () => _pick(place), onTap: () => _pick(place),
), ),
], ];
);
} }
/// The place chosen, what it reminds of, and adding it. /// The place chosen, what it reminds of, and adding it.
Widget _chosen(BuildContext context, TaskLocation picked) { Widget _chosen(BuildContext context, TaskLocation picked) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return Material( return Padding(
color: Theme.of(context).colorScheme.surfaceContainerHigh, padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -747,7 +1080,6 @@ class _LocationPickerState() extends State<LocationPicker> {
), ),
], ],
), ),
),
); );
} }
} }

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