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:
parent
ce07fab193
commit
a4c1fe298d
16 changed files with 1663 additions and 241 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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?>);
|
||||||
|
|
|
||||||
|
|
@ -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)}';
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue