feat(todo): a pin in the map's middle, and what is around it (ADR 0033, D32)
The location picker fills the screen with its map, the search floating over it. A pin stays in the middle, lifting while the map moves, as Molly's; where it lands the instance names the address and lists the places around, as Threema's. That is `nearby` on the search endpoint, for those signed in, the point neither kept nor logged, counted among the searches: two lookups, as a plain one finds house numbers alone. Fixes the map's credit, which overflowed a phone and was pale on white. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
5d9c640e5a
commit
ce07fab193
24 changed files with 1254 additions and 113 deletions
File diff suppressed because one or more lines are too long
|
|
@ -13,6 +13,8 @@
|
|||
import 'dart:async' as _ida;
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart' as _ihk4p5t7;
|
||||
import 'package:halkyon_oidc_client/halkyon_oidc_client.dart' as _ibkpwzgl;
|
||||
import 'package:halkyon_todo_client/src/protocol/search/nearby.dart'
|
||||
as _i77fdwoz;
|
||||
import 'package:halkyon_todo_client/src/protocol/search/place.dart'
|
||||
as _i15767wh;
|
||||
import 'package:halkyon_todo_client/src/protocol/search/place_point.dart'
|
||||
|
|
@ -66,6 +68,22 @@ class EndpointSearch extends _isc.EndpointRef {
|
|||
'language': language,
|
||||
},
|
||||
);
|
||||
|
||||
/// What is around [at], a point on the map, named in [language]: the
|
||||
/// address that names a pin and the places to go to. Refused as [places]
|
||||
/// is, and counted among its searches; the point is neither kept nor
|
||||
/// logged.
|
||||
_ida.Future<_i77fdwoz.Nearby> nearby(
|
||||
_icyq0v3n.PlacePoint at, {
|
||||
String? language,
|
||||
}) => caller.callServerEndpoint<_i77fdwoz.Nearby>(
|
||||
'search',
|
||||
'nearby',
|
||||
{
|
||||
'at': at,
|
||||
'language': language,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class Modules {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import 'label/label.dart' as _ivak08z4;
|
|||
import 'privacy/content_key.dart' as _imed3x5d;
|
||||
import 'project/project.dart' as _i9c44spp;
|
||||
import 'project/section.dart' as _imadkvi9;
|
||||
import 'search/nearby.dart' as _ispk4ita;
|
||||
import 'search/place.dart' as _iadh29yw;
|
||||
import 'search/place_point.dart' as _itf0ur2u;
|
||||
import 'search/search_refused.dart' as _ihbxkwke;
|
||||
|
|
@ -36,6 +37,7 @@ export 'label/label.dart';
|
|||
export 'privacy/content_key.dart';
|
||||
export 'project/project.dart';
|
||||
export 'project/section.dart';
|
||||
export 'search/nearby.dart';
|
||||
export 'search/place.dart';
|
||||
export 'search/place_point.dart';
|
||||
export 'search/search_refused.dart';
|
||||
|
|
@ -509,6 +511,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (t == _imadkvi9.Section) {
|
||||
return _imadkvi9.Section.fromJson(data) as T;
|
||||
}
|
||||
if (t == _ispk4ita.Nearby) {
|
||||
return _ispk4ita.Nearby.fromJson(data) as T;
|
||||
}
|
||||
if (t == _iadh29yw.Place) {
|
||||
return _iadh29yw.Place.fromJson(data) as T;
|
||||
}
|
||||
|
|
@ -536,6 +541,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (t == _isc.getType<_imadkvi9.Section?>()) {
|
||||
return (data != null ? _imadkvi9.Section.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _isc.getType<_ispk4ita.Nearby?>()) {
|
||||
return (data != null ? _ispk4ita.Nearby.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _isc.getType<_iadh29yw.Place?>()) {
|
||||
return (data != null ? _iadh29yw.Place.fromJson(data) : null) as T;
|
||||
}
|
||||
|
|
@ -552,6 +560,10 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (t == _isc.getType<_ib4f8uyw.Task?>()) {
|
||||
return (data != null ? _ib4f8uyw.Task.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == List<_iadh29yw.Place>) {
|
||||
return (data as List).map((e) => deserialize<_iadh29yw.Place>(e)).toList()
|
||||
as T;
|
||||
}
|
||||
if (t == List<_i15767wh.Place>) {
|
||||
return (data as List).map((e) => deserialize<_i15767wh.Place>(e)).toList()
|
||||
as T;
|
||||
|
|
@ -580,6 +592,7 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
_imed3x5d.ContentKey => 'ContentKey',
|
||||
_i9c44spp.Project => 'Project',
|
||||
_imadkvi9.Section => 'Section',
|
||||
_ispk4ita.Nearby => 'Nearby',
|
||||
_iadh29yw.Place => 'Place',
|
||||
_itf0ur2u.PlacePoint => 'PlacePoint',
|
||||
_ihbxkwke.SearchRefused => 'SearchRefused',
|
||||
|
|
@ -610,6 +623,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
return 'Project';
|
||||
case _imadkvi9.Section():
|
||||
return 'Section';
|
||||
case _ispk4ita.Nearby():
|
||||
return 'Nearby';
|
||||
case _iadh29yw.Place():
|
||||
return 'Place';
|
||||
case _itf0ur2u.PlacePoint():
|
||||
|
|
@ -668,6 +683,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (dataClassName == 'Section') {
|
||||
return deserialize<_imadkvi9.Section>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'Nearby') {
|
||||
return deserialize<_ispk4ita.Nearby>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'Place') {
|
||||
return deserialize<_iadh29yw.Place>(data['data']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:halkyon_todo_client/src/protocol/protocol.dart' as _i5b39hch;
|
||||
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||
import '../search/place.dart' as _iz6lll64;
|
||||
|
||||
/// What is around a point on the map (ADR 0033, D32): never stored by the
|
||||
/// server, as a found place isn't.
|
||||
abstract class Nearby
|
||||
implements _isc.SerializableModel, _isc.ProtocolSerialization {
|
||||
Nearby._({
|
||||
this.spot,
|
||||
required this.places,
|
||||
});
|
||||
|
||||
factory Nearby({
|
||||
_iz6lll64.Place? spot,
|
||||
required List<_iz6lll64.Place> places,
|
||||
}) = _NearbyImpl;
|
||||
|
||||
factory Nearby.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return Nearby(
|
||||
spot: jsonSerialization['spot'] == null
|
||||
? null
|
||||
: _i5b39hch.Protocol().deserialize<_iz6lll64.Place>(
|
||||
jsonSerialization['spot'],
|
||||
),
|
||||
places: _i5b39hch.Protocol().deserialize<List<_iz6lll64.Place>>(
|
||||
jsonSerialization['places'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The address nearest the point, what names a pin; null where none is.
|
||||
_iz6lll64.Place? spot;
|
||||
|
||||
/// The places to go to around it, nearest first: shops, restaurants and
|
||||
/// the like, what a person more likely means than a house number.
|
||||
List<_iz6lll64.Place> places;
|
||||
|
||||
/// Returns a shallow copy of this [Nearby]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
Nearby copyWith({
|
||||
_iz6lll64.Place? spot,
|
||||
List<_iz6lll64.Place>? places,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'Nearby',
|
||||
if (spot != null) 'spot': spot?.toJson(),
|
||||
'places': places.toJson(valueToJson: (v) => v.toJson()),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'Nearby',
|
||||
if (spot != null) 'spot': spot?.toJsonForProtocol(),
|
||||
'places': places.toJson(valueToJson: (v) => v.toJsonForProtocol()),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _isc.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _Undefined {}
|
||||
|
||||
class _NearbyImpl extends Nearby {
|
||||
_NearbyImpl({
|
||||
_iz6lll64.Place? spot,
|
||||
required List<_iz6lll64.Place> places,
|
||||
}) : super._(
|
||||
spot: spot,
|
||||
places: places,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [Nearby]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
@override
|
||||
Nearby copyWith({
|
||||
Object? spot = _Undefined,
|
||||
List<_iz6lll64.Place>? places,
|
||||
}) {
|
||||
return Nearby(
|
||||
spot: spot is _iz6lll64.Place? ? spot : this.spot?.copyWith(),
|
||||
places: places ?? this.places.map((e0) => e0.copyWith()).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -287,6 +287,9 @@
|
|||
"locationSearchFailed": "Die Suche hat nicht geklappt. Versuch es noch einmal.",
|
||||
"locationNoResults": "Nichts gefunden",
|
||||
"locationRecent": "Zuletzt verwendet",
|
||||
"locationNearby": "Oder einen Ort in der Nähe wählen",
|
||||
"locationZoomIn": "Vergrößern",
|
||||
"locationZoomOut": "Verkleinern",
|
||||
"locationPin": "Gesetzter Ort",
|
||||
"locationName": "Name",
|
||||
"locationMine": "Mein Standort",
|
||||
|
|
|
|||
|
|
@ -962,6 +962,18 @@
|
|||
"@locationRecent": {
|
||||
"description": "The location picker's places of the person's tasks."
|
||||
},
|
||||
"locationNearby": "Or choose a nearby place",
|
||||
"@locationNearby": {
|
||||
"description": "Above the places around the pin in the location picker."
|
||||
},
|
||||
"locationZoomIn": "Zoom in",
|
||||
"@locationZoomIn": {
|
||||
"description": "The location picker's button zooming the map in."
|
||||
},
|
||||
"locationZoomOut": "Zoom out",
|
||||
"@locationZoomOut": {
|
||||
"description": "The location picker's button zooming the map out."
|
||||
},
|
||||
"locationPin": "Pinned place",
|
||||
"@locationPin": {
|
||||
"description": "The name of a pin placed by hand where nothing names the spot."
|
||||
|
|
|
|||
|
|
@ -1822,6 +1822,24 @@ abstract class AppLocalizations {
|
|||
/// **'Recent'**
|
||||
String get locationRecent;
|
||||
|
||||
/// Above the places around the pin in the location picker.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Or choose a nearby place'**
|
||||
String get locationNearby;
|
||||
|
||||
/// The location picker's button zooming the map in.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Zoom in'**
|
||||
String get locationZoomIn;
|
||||
|
||||
/// The location picker's button zooming the map out.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Zoom out'**
|
||||
String get locationZoomOut;
|
||||
|
||||
/// The name of a pin placed by hand where nothing names the spot.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1126,6 +1126,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get locationRecent => 'Zuletzt verwendet';
|
||||
|
||||
@override
|
||||
String get locationNearby => 'Oder einen Ort in der Nähe wählen';
|
||||
|
||||
@override
|
||||
String get locationZoomIn => 'Vergrößern';
|
||||
|
||||
@override
|
||||
String get locationZoomOut => 'Verkleinern';
|
||||
|
||||
@override
|
||||
String get locationPin => 'Gesetzter Ort';
|
||||
|
||||
|
|
|
|||
|
|
@ -1125,6 +1125,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get locationRecent => 'Recent';
|
||||
|
||||
@override
|
||||
String get locationNearby => 'Or choose a nearby place';
|
||||
|
||||
@override
|
||||
String get locationZoomIn => 'Zoom in';
|
||||
|
||||
@override
|
||||
String get locationZoomOut => 'Zoom out';
|
||||
|
||||
@override
|
||||
String get locationPin => 'Pinned place';
|
||||
|
||||
|
|
|
|||
|
|
@ -19,13 +19,19 @@ abstract interface class PlaceSearchGateway() {
|
|||
String? language,
|
||||
});
|
||||
|
||||
/// The place at a point tapped on the map; null where nothing names it.
|
||||
Future<TaskLocation?> at(LatLon point, {String? language});
|
||||
/// What is around a point on the map.
|
||||
Future<PlacesAround> around(LatLon point, {String? language});
|
||||
}
|
||||
|
||||
/// What is around a point on the map: the address that names a pin, null
|
||||
/// where none is near, and the places to go to, nearest first.
|
||||
final class const PlacesAround({
|
||||
final TaskLocation? spot,
|
||||
final List<TaskLocation> places = const [],
|
||||
});
|
||||
|
||||
/// The instance's search (D32), on Todo's server (`search`), which leaves
|
||||
/// out what is no place to go itself. It names no point on the map: a pin
|
||||
/// placed by hand stays unnamed.
|
||||
/// out what is no place to go itself.
|
||||
class InstancePlaceSearch({
|
||||
required final Future<bool> Function() asked,
|
||||
required final Future<List<protocol.Place>> Function(
|
||||
|
|
@ -34,11 +40,17 @@ class InstancePlaceSearch({
|
|||
String? language,
|
||||
)
|
||||
find,
|
||||
required final Future<protocol.Nearby> Function(
|
||||
protocol.PlacePoint at,
|
||||
String? language,
|
||||
)
|
||||
nearby,
|
||||
}) implements PlaceSearchGateway {
|
||||
factory of(protocol.Client client) => InstancePlaceSearch(
|
||||
asked: client.search.offered,
|
||||
find: (query, near, language) =>
|
||||
client.search.places(query, near: near, language: language),
|
||||
nearby: (at, language) => client.search.nearby(at, language: language),
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -64,21 +76,32 @@ class InstancePlaceSearch({
|
|||
},
|
||||
language,
|
||||
);
|
||||
return [
|
||||
for (final place in places)
|
||||
TaskLocation(
|
||||
name: place.name,
|
||||
lat: place.lat,
|
||||
lon: place.lon,
|
||||
address: place.detail ?? '',
|
||||
osmType: place.osmType,
|
||||
osmId: place.osmId,
|
||||
),
|
||||
];
|
||||
return [for (final place in places) _location(place)];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TaskLocation?> at(LatLon point, {String? language}) async => null;
|
||||
Future<PlacesAround> around(LatLon point, {String? language}) async {
|
||||
final found = await nearby(
|
||||
protocol.PlacePoint(lat: point.lat, lon: point.lon),
|
||||
language,
|
||||
);
|
||||
return PlacesAround(
|
||||
spot: switch (found.spot) {
|
||||
final spot? => _location(spot),
|
||||
null => null,
|
||||
},
|
||||
places: [for (final place in found.places) _location(place)],
|
||||
);
|
||||
}
|
||||
|
||||
TaskLocation _location(protocol.Place place) => TaskLocation(
|
||||
name: place.name,
|
||||
lat: place.lat,
|
||||
lon: place.lon,
|
||||
address: place.detail ?? '',
|
||||
osmType: place.osmType,
|
||||
osmId: place.osmId,
|
||||
);
|
||||
}
|
||||
|
||||
/// What a search leaves out: places of that kind beside the place itself
|
||||
|
|
@ -158,13 +181,28 @@ class PublicPhotonGateway() implements PlaceSearchGateway {
|
|||
);
|
||||
|
||||
@override
|
||||
Future<TaskLocation?> at(LatLon point, {String? language}) async =>
|
||||
(await _get(
|
||||
Future<PlacesAround> around(LatLon point, {String? language}) async {
|
||||
Uri reverse(Map<String, Object> parameters) =>
|
||||
Uri.https('photon.komoot.io', '/reverse', {
|
||||
'lat': '${point.lat}',
|
||||
'lon': '${point.lon}',
|
||||
}),
|
||||
)).firstOrNull;
|
||||
if (_languages.contains(language)) 'lang': language,
|
||||
...parameters,
|
||||
});
|
||||
return PlacesAround(
|
||||
spot: (await _get(reverse({'limit': '1', 'radius': '0.1'}))).firstOrNull,
|
||||
places: {
|
||||
for (final place in await _get(
|
||||
reverse({
|
||||
'limit': '20',
|
||||
'radius': '0.5',
|
||||
'osm_tag': ['shop', 'amenity', 'tourism', 'leisure'],
|
||||
}),
|
||||
))
|
||||
place.name: place,
|
||||
}.values.take(8).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
static const _languages = {'en', 'de'};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ const locationMapKey = ValueKey('location-map');
|
|||
/// The key of the picker's button adding the place chosen.
|
||||
const locationAddKey = ValueKey('location-add');
|
||||
|
||||
/// The key of the pin in the map's middle.
|
||||
const locationPinKey = ValueKey('location-pin');
|
||||
|
||||
/// The key of the map's credit.
|
||||
const locationAttributionKey = ValueKey('location-attribution');
|
||||
|
||||
/// The places of the person's tasks, the latest tasks' first.
|
||||
List<TaskLocation> recentsOf(TodoState state) => recentLocations(
|
||||
state.tasks.reversed.expand((task) => task.content.locations),
|
||||
|
|
@ -31,8 +37,10 @@ List<TaskLocation> recentsOf(TodoState state) => recentLocations(
|
|||
|
||||
/// Asks where (ADR 0033, D32): people know a city or district and a name, not
|
||||
/// an address, so a search of those ([query] to start with), its places as a
|
||||
/// list that tells two EDEKAs apart and as pins on the map, or a pin placed
|
||||
/// by hand; with what it reminds of. The place chosen, if any.
|
||||
/// list that tells two EDEKAs apart and as pins on the map, or the spot under
|
||||
/// 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
|
||||
/// what it reminds of. The place chosen, if any.
|
||||
Future<TaskLocation?> showLocationPicker(
|
||||
BuildContext context, {
|
||||
String? query,
|
||||
|
|
@ -73,12 +81,27 @@ class _LocationPickerState() extends State<LocationPicker> {
|
|||
/// All of Germany until the phone's position says better.
|
||||
static const _start = LatLng(51.2, 10.4);
|
||||
|
||||
/// Below this the pin stands for no spot: that far out it would be a
|
||||
/// region, and nothing is asked of the instance.
|
||||
static const _closeEnough = 13.0;
|
||||
|
||||
late final _search = TextEditingController(text: widget.query);
|
||||
final _searchFocus = FocusNode();
|
||||
final _name = TextEditingController();
|
||||
final _map = MapController();
|
||||
var _mapReady = false;
|
||||
Timer? _typing;
|
||||
Timer? _moved;
|
||||
|
||||
/// Whether the map is moving, the pin lifted off it.
|
||||
var _moving = false;
|
||||
|
||||
/// Answers to anything but the latest look around are dropped.
|
||||
var _lookedAround = 0;
|
||||
List<TaskLocation> _around = const [];
|
||||
|
||||
/// Whether the person typed a name, which no address then replaces.
|
||||
var _renamed = false;
|
||||
|
||||
/// Answers to anything but the latest search are dropped.
|
||||
var _asked = 0;
|
||||
|
|
@ -107,6 +130,7 @@ class _LocationPickerState() extends State<LocationPicker> {
|
|||
@override
|
||||
void dispose() {
|
||||
_typing?.cancel();
|
||||
_moved?.cancel();
|
||||
_search.dispose();
|
||||
_searchFocus.dispose();
|
||||
_name.dispose();
|
||||
|
|
@ -123,6 +147,7 @@ class _LocationPickerState() extends State<LocationPicker> {
|
|||
setState(() => _position = at);
|
||||
if (_mapReady && _results == null && _picked == null) {
|
||||
_map.move(LatLng(at.lat, at.lon), 14);
|
||||
unawaited(_settled());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -134,6 +159,7 @@ class _LocationPickerState() extends State<LocationPicker> {
|
|||
if (mounted && at != null) {
|
||||
setState(() => _position = at);
|
||||
_map.move(LatLng(at.lat, at.lon), 15);
|
||||
unawaited(_settled());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -225,9 +251,12 @@ class _LocationPickerState() extends State<LocationPicker> {
|
|||
|
||||
void _pick(TaskLocation place) {
|
||||
_searchFocus.unfocus();
|
||||
_moved?.cancel();
|
||||
_lookedAround++;
|
||||
setState(() {
|
||||
_picked = place;
|
||||
_byHand = false;
|
||||
_moving = false;
|
||||
});
|
||||
if (_mapReady) {
|
||||
_map.move(
|
||||
|
|
@ -237,41 +266,91 @@ class _LocationPickerState() extends State<LocationPicker> {
|
|||
}
|
||||
}
|
||||
|
||||
/// A pin placed by hand, named by what the search knows of the spot, if
|
||||
/// anything; its name editable.
|
||||
Future<void> _placed(LatLng point) async {
|
||||
/// The person moves the map: the pin lifts, and comes down a moment after
|
||||
/// the last move, a fling's too.
|
||||
void _gesture() {
|
||||
_searchFocus.unfocus();
|
||||
_moved?.cancel();
|
||||
_moved = Timer(
|
||||
const Duration(milliseconds: 450),
|
||||
() => unawaited(_settled()),
|
||||
);
|
||||
if (!_moving) {
|
||||
setState(() => _moving = true);
|
||||
}
|
||||
}
|
||||
|
||||
/// A tap moves the spot there, for those who'd rather point at it.
|
||||
void _tapped(LatLng point) {
|
||||
_searchFocus.unfocus();
|
||||
_map.move(point, _map.camera.zoom);
|
||||
unawaited(_settled());
|
||||
}
|
||||
|
||||
/// The spot under the pin, which the map stopped at, named by what is near
|
||||
/// and with the places around it, if anything; its name editable.
|
||||
Future<void> _settled() async {
|
||||
_moved?.cancel();
|
||||
if (!mounted || !_mapReady) {
|
||||
return;
|
||||
}
|
||||
final asked = ++_lookedAround;
|
||||
if (_map.camera.zoom < _closeEnough) {
|
||||
setState(() {
|
||||
_moving = false;
|
||||
_picked = null;
|
||||
_around = const [];
|
||||
});
|
||||
return;
|
||||
}
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final language = Localizations.localeOf(context).languageCode;
|
||||
_searchFocus.unfocus();
|
||||
final centre = _map.camera.center;
|
||||
final pin = TaskLocation(
|
||||
name: l10n.locationPin,
|
||||
lat: point.latitude,
|
||||
lon: point.longitude,
|
||||
lat: centre.latitude,
|
||||
lon: centre.longitude,
|
||||
);
|
||||
_name.text = pin.name;
|
||||
if (!_renamed) {
|
||||
_name.text = pin.name;
|
||||
}
|
||||
setState(() {
|
||||
_moving = false;
|
||||
_picked = pin;
|
||||
_byHand = true;
|
||||
});
|
||||
TaskLocation? named;
|
||||
final places = widget.places;
|
||||
if (places == null || !_offered) {
|
||||
return;
|
||||
}
|
||||
PlacesAround found;
|
||||
try {
|
||||
named = await widget.places?.at(
|
||||
(lat: point.latitude, lon: point.longitude),
|
||||
found = await places.around(
|
||||
(lat: pin.lat, lon: pin.lon),
|
||||
language: language,
|
||||
);
|
||||
} on Object {
|
||||
// The pin stands, unnamed; the person can name it.
|
||||
found = const PlacesAround();
|
||||
}
|
||||
if (mounted && named != null && identical(_picked, pin)) {
|
||||
final spot = TaskLocation(
|
||||
name: named.name,
|
||||
lat: pin.lat,
|
||||
lon: pin.lon,
|
||||
address: named.address,
|
||||
);
|
||||
_name.text = spot.name;
|
||||
setState(() => _picked = spot);
|
||||
if (!mounted || asked != _lookedAround) {
|
||||
return;
|
||||
}
|
||||
final named = found.spot;
|
||||
if (named != null && !_renamed) {
|
||||
_name.text = named.name;
|
||||
}
|
||||
setState(() {
|
||||
_around = found.places;
|
||||
if (named != null) {
|
||||
_picked = TaskLocation(
|
||||
name: named.name,
|
||||
lat: pin.lat,
|
||||
lon: pin.lon,
|
||||
address: named.address,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The place as chosen, the person told first why a reminder needs the
|
||||
|
|
@ -305,46 +384,74 @@ class _LocationPickerState() extends State<LocationPicker> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final picked = _searchFocus.hasFocus ? null : _picked;
|
||||
final list = _list(context);
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(flex: 3, child: _mapView(context)),
|
||||
?switch (picked) {
|
||||
final picked? => _chosen(context, picked),
|
||||
null => null,
|
||||
},
|
||||
if (list != null)
|
||||
Expanded(flex: 2, child: list)
|
||||
else
|
||||
SizedBox(height: MediaQuery.paddingOf(context).bottom),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The bar floating over the map: closing, and the search.
|
||||
Widget _bar(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final search = widget.places != null && _offered;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.locationAdd)),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
child: search
|
||||
? TextField(
|
||||
key: locationSearchKey,
|
||||
controller: _search,
|
||||
focusNode: _searchFocus,
|
||||
autofocus: widget.query == null,
|
||||
textInputAction: TextInputAction.search,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.locationSearchHint,
|
||||
prefixIcon: const Icon(LucideIcons.search),
|
||||
),
|
||||
onChanged: _typed,
|
||||
onSubmitted: (text) {
|
||||
_typing?.cancel();
|
||||
unawaited(_find(text));
|
||||
},
|
||||
)
|
||||
: Text(
|
||||
return Material(
|
||||
elevation: 3,
|
||||
color: scheme.surfaceContainerHigh,
|
||||
shape: const StadiumBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
Expanded(
|
||||
child: search
|
||||
? TextField(
|
||||
key: locationSearchKey,
|
||||
controller: _search,
|
||||
focusNode: _searchFocus,
|
||||
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));
|
||||
},
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
l10n.locationSearchUnavailable,
|
||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
Expanded(flex: 3, child: _mapView(context)),
|
||||
if (_list(context) case final list?) Expanded(flex: 2, child: list),
|
||||
if (_picked case final picked? when !_searchFocus.hasFocus)
|
||||
_chosen(context, picked),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -375,9 +482,15 @@ class _LocationPickerState() extends State<LocationPicker> {
|
|||
_show(results);
|
||||
} else if (_position case (:final lat, :final lon)) {
|
||||
_map.move(LatLng(lat, lon), 14);
|
||||
unawaited(_settled());
|
||||
}
|
||||
},
|
||||
onTap: (_, point) => unawaited(_placed(point)),
|
||||
onPositionChanged: (_, hasGesture) {
|
||||
if (hasGesture) {
|
||||
_gesture();
|
||||
}
|
||||
},
|
||||
onTap: (_, point) => _tapped(point),
|
||||
),
|
||||
children: [
|
||||
?tiles?.call(),
|
||||
|
|
@ -398,26 +511,114 @@ class _LocationPickerState() extends State<LocationPicker> {
|
|||
),
|
||||
for (final place in _results ?? const <TaskLocation>[])
|
||||
if (place.key != picked?.key) _pin(context, place),
|
||||
if (picked != null) _pin(context, picked, chosen: true),
|
||||
],
|
||||
),
|
||||
if (tiles != null)
|
||||
const SimpleAttributionWidget(source: Text(mapAttribution)),
|
||||
],
|
||||
),
|
||||
if (_results == null || picked != null) _centrePin(context),
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 8,
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: l10n.locationMine,
|
||||
icon: const Icon(LucideIcons.locateFixed),
|
||||
onPressed: () => unawaited(_locateAsked()),
|
||||
left: 12,
|
||||
right: 12,
|
||||
child: SafeArea(bottom: false, child: _bar(context)),
|
||||
),
|
||||
Positioned(
|
||||
right: 12,
|
||||
bottom: tiles == null ? 12 : 28,
|
||||
child: Column(
|
||||
spacing: 8,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: l10n.locationZoomIn,
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
onPressed: () => _zoom(1),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: l10n.locationZoomOut,
|
||||
icon: const Icon(LucideIcons.minus),
|
||||
onPressed: () => _zoom(-1),
|
||||
),
|
||||
IconButton.filled(
|
||||
tooltip: l10n.locationMine,
|
||||
icon: const Icon(LucideIcons.locateFixed),
|
||||
onPressed: () => unawaited(_locateAsked()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (tiles != null)
|
||||
Positioned(
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: ColoredBox(
|
||||
color: scheme.surface.withValues(alpha: 0.8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Text(
|
||||
mapAttribution,
|
||||
key: locationAttributionKey,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: scheme.onSurface),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _zoom(int by) {
|
||||
if (_mapReady) {
|
||||
_map.move(_map.camera.center, _map.camera.zoom + by);
|
||||
unawaited(_settled());
|
||||
}
|
||||
}
|
||||
|
||||
/// The pin in the map's middle, its point on the spot, lifting off it
|
||||
/// while the map moves, with a shadow of where it will land.
|
||||
Widget _centrePin(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
const size = 44.0;
|
||||
const lift = 20.0;
|
||||
return IgnorePointer(
|
||||
child: Center(
|
||||
child: Transform.translate(
|
||||
offset: const Offset(0, -size / 2),
|
||||
child: SizedBox(
|
||||
key: locationPinKey,
|
||||
width: size,
|
||||
height: size + lift,
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.shadow.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
padding: EdgeInsets.only(bottom: _moving ? lift : 0),
|
||||
child: Icon(
|
||||
LucideIcons.mapPin,
|
||||
size: size,
|
||||
color: scheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Marker _pin(
|
||||
BuildContext context,
|
||||
TaskLocation place, {
|
||||
|
|
@ -449,7 +650,8 @@ class _LocationPickerState() extends State<LocationPicker> {
|
|||
final l10n = AppLocalizations.of(context);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final results = _results;
|
||||
final places = results ?? widget.recents;
|
||||
final nearby = results == null && _around.isNotEmpty;
|
||||
final places = results ?? (nearby ? _around : widget.recents);
|
||||
final message = switch ((_failed, results)) {
|
||||
(true, _) => l10n.locationSearchFailed,
|
||||
(_, []) when !_searching => l10n.locationNoResults,
|
||||
|
|
@ -459,7 +661,7 @@ class _LocationPickerState() extends State<LocationPicker> {
|
|||
return null;
|
||||
}
|
||||
return ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom),
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
children: [
|
||||
if (_searching) const LinearProgressIndicator(minHeight: 2),
|
||||
|
|
@ -475,7 +677,7 @@ class _LocationPickerState() extends State<LocationPicker> {
|
|||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
l10n.locationRecent,
|
||||
nearby ? l10n.locationNearby : l10n.locationRecent,
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
|
|
@ -509,6 +711,7 @@ class _LocationPickerState() extends State<LocationPicker> {
|
|||
controller: _name,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(labelText: l10n.locationName),
|
||||
onChanged: (_) => _renamed = true,
|
||||
)
|
||||
else
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/app/todo_languages.dart';
|
||||
|
|
@ -6,10 +8,15 @@ 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/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/map_tiles.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class _Places(final List<TaskLocation> found, {final bool isOffered = true})
|
||||
implements PlaceSearchGateway {
|
||||
class _Places(
|
||||
final List<TaskLocation> found, {
|
||||
final bool isOffered = true,
|
||||
final Future<PlacesAround> Function(LatLon point) nearby = _nothing,
|
||||
}) implements PlaceSearchGateway {
|
||||
final looked = <LatLon>[];
|
||||
final asked = <String>[];
|
||||
LatLon? near;
|
||||
|
||||
|
|
@ -25,12 +32,17 @@ class _Places(final List<TaskLocation> found, {final bool isOffered = true})
|
|||
}
|
||||
|
||||
@override
|
||||
Future<TaskLocation?> at(LatLon point, {String? language}) async => null;
|
||||
Future<PlacesAround> around(LatLon point, {String? language}) {
|
||||
looked.add(point);
|
||||
return nearby(point);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> offered() async => isOffered;
|
||||
}
|
||||
|
||||
Future<PlacesAround> _nothing(LatLon point) async => const PlacesAround();
|
||||
|
||||
/// The location picker (ADR 0033, D32), without tiles.
|
||||
void main() {
|
||||
const aeschach = TaskLocation(
|
||||
|
|
@ -51,6 +63,8 @@ void main() {
|
|||
PlaceSearchGateway? places,
|
||||
List<TaskLocation> recents = const [],
|
||||
String? query,
|
||||
LatLon? at,
|
||||
MapTiles? tiles,
|
||||
}) async {
|
||||
final picked = <TaskLocation?>[];
|
||||
await tester.pumpWidget(
|
||||
|
|
@ -66,11 +80,11 @@ void main() {
|
|||
MaterialPageRoute(
|
||||
builder: (context) => LocationPicker(
|
||||
places: places,
|
||||
mapTiles: null,
|
||||
mapTiles: tiles,
|
||||
access: LocationReminderAccess(
|
||||
const NoLocationReminders(),
|
||||
),
|
||||
position: () async => null,
|
||||
position: () async => at,
|
||||
recents: recents,
|
||||
query: query,
|
||||
),
|
||||
|
|
@ -170,4 +184,185 @@ void main() {
|
|||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.text('On arriving'), findsNothing);
|
||||
});
|
||||
|
||||
group("the pin in the map's middle", () {
|
||||
const here = (lat: 47.55, lon: 9.70);
|
||||
const street = TaskLocation(
|
||||
name: 'Danziger Weg 4',
|
||||
lat: 47.55,
|
||||
lon: 9.70,
|
||||
address: 'Lindau',
|
||||
);
|
||||
const bakery = TaskLocation(
|
||||
name: 'Schwarz',
|
||||
lat: 47.551,
|
||||
lon: 9.701,
|
||||
address: 'Kemptener Straße, Lindau',
|
||||
);
|
||||
Future<PlacesAround> found(LatLon point) async =>
|
||||
const PlacesAround(spot: street, places: [bakery]);
|
||||
|
||||
/// Lets a move of the map come to rest, and what it asked answer.
|
||||
Future<void> rest(WidgetTester tester) async {
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Future<void> dragMap(WidgetTester tester) async {
|
||||
await tester.timedDrag(
|
||||
find.byKey(locationMapKey),
|
||||
const Offset(-120, 40),
|
||||
const Duration(milliseconds: 300),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
EdgeInsetsGeometry lifted(WidgetTester tester) => tester
|
||||
.widget<AnimatedPadding>(
|
||||
find.descendant(
|
||||
of: find.byKey(locationPinKey),
|
||||
matching: find.byType(AnimatedPadding),
|
||||
),
|
||||
)
|
||||
.padding;
|
||||
|
||||
testWidgets('the spot is named by what is near, and adds', (tester) async {
|
||||
final picked = await open(
|
||||
tester,
|
||||
places: _Places(const [], nearby: found),
|
||||
at: here,
|
||||
);
|
||||
await rest(tester);
|
||||
expect(find.byKey(locationPinKey), findsOneWidget);
|
||||
expect(find.text('Danziger Weg 4'), findsOneWidget);
|
||||
await tester.ensureVisible(find.byKey(locationAddKey));
|
||||
await tester.tap(find.byKey(locationAddKey));
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(picked.single?.name, 'Danziger Weg 4');
|
||||
expect(picked.single?.address, 'Lindau');
|
||||
});
|
||||
|
||||
testWidgets('the places around it can be chosen instead', (tester) async {
|
||||
final picked = await open(
|
||||
tester,
|
||||
places: _Places(const [], nearby: found),
|
||||
at: here,
|
||||
);
|
||||
await rest(tester);
|
||||
expect(find.text('Or choose a nearby place'), findsOneWidget);
|
||||
await tester.tap(find.text(bakery.address));
|
||||
await rest(tester);
|
||||
await tester.tap(find.byKey(locationAddKey));
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(picked.single?.name, bakery.name);
|
||||
expect(
|
||||
(picked.single?.lat, picked.single?.lon),
|
||||
(bakery.lat, bakery.lon),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('moving the map lifts the pin, and lands on a new spot', (
|
||||
tester,
|
||||
) async {
|
||||
final places = _Places(const [], nearby: found);
|
||||
await open(tester, places: places, at: here);
|
||||
await rest(tester);
|
||||
final before = places.looked.length;
|
||||
await dragMap(tester);
|
||||
expect(lifted(tester), isNot(EdgeInsets.zero));
|
||||
await rest(tester);
|
||||
expect(lifted(tester), EdgeInsets.zero);
|
||||
expect(places.looked.length, before + 1);
|
||||
expect(places.looked.last, isNot(here));
|
||||
});
|
||||
|
||||
testWidgets('far out it stands for no spot, and asks nothing', (
|
||||
tester,
|
||||
) async {
|
||||
final places = _Places(const [], nearby: found);
|
||||
await open(tester, places: places);
|
||||
await dragMap(tester);
|
||||
await rest(tester);
|
||||
expect(places.looked, isEmpty);
|
||||
expect(find.byKey(locationAddKey), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a name typed stays when the spot moves', (tester) async {
|
||||
final picked = await open(
|
||||
tester,
|
||||
places: _Places(const [], nearby: found),
|
||||
at: here,
|
||||
);
|
||||
await rest(tester);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Danziger Weg 4'),
|
||||
'Home',
|
||||
);
|
||||
await dragMap(tester);
|
||||
await rest(tester);
|
||||
await tester.tap(find.byKey(locationAddKey));
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(picked.single?.name, 'Home');
|
||||
});
|
||||
|
||||
testWidgets('a lookup that fails leaves the pin, unnamed', (tester) async {
|
||||
await open(
|
||||
tester,
|
||||
places: _Places(
|
||||
const [],
|
||||
nearby: (_) async => throw Exception('offline'),
|
||||
),
|
||||
at: here,
|
||||
);
|
||||
await rest(tester);
|
||||
expect(find.text('Pinned place'), findsOneWidget);
|
||||
expect(find.byKey(locationAddKey), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the answer for a spot left behind is dropped', (tester) async {
|
||||
final first = Completer<PlacesAround>();
|
||||
var asked = 0;
|
||||
await open(
|
||||
tester,
|
||||
places: _Places(
|
||||
const [],
|
||||
nearby: (_) => asked++ == 0
|
||||
? first.future
|
||||
: Future.value(const PlacesAround(spot: street)),
|
||||
),
|
||||
at: here,
|
||||
);
|
||||
await rest(tester);
|
||||
await dragMap(tester);
|
||||
await rest(tester);
|
||||
first.complete(const PlacesAround(spot: bakery));
|
||||
await rest(tester);
|
||||
expect(find.text('Danziger Weg 4'), findsOneWidget);
|
||||
expect(find.text('Schwarz'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the credit fits a narrow phone, and can be read', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(320, 640);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await open(
|
||||
tester,
|
||||
places: _Places(const []),
|
||||
at: here,
|
||||
tiles: () => const SizedBox(),
|
||||
);
|
||||
await rest(tester);
|
||||
expect(tester.takeException(), isNull);
|
||||
final credit = tester.widget<Text>(find.byKey(locationAttributionKey));
|
||||
expect(credit.data, mapAttribution);
|
||||
final theme = Theme.of(
|
||||
tester.element(find.byKey(locationAttributionKey)),
|
||||
);
|
||||
expect(credit.style?.color, theme.colorScheme.onSurface);
|
||||
expect(find.textContaining('flutter_map'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import 'package:halkyon_todo_client/halkyon_todo_client.dart';
|
|||
import 'package:halkyon_todo_flutter/src/feature/location/data/place_search_gateway.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
Future<Nearby> _nearby(PlacePoint at, String? language) async =>
|
||||
Nearby(places: const []);
|
||||
|
||||
/// Replies as Photon's (ADR 0033, D32).
|
||||
void main() {
|
||||
test("the instance's places keep their address and object", () async {
|
||||
|
|
@ -21,6 +24,7 @@ void main() {
|
|||
),
|
||||
];
|
||||
},
|
||||
nearby: _nearby,
|
||||
);
|
||||
final [place] = await gateway.search(
|
||||
'Lindau EDEKA',
|
||||
|
|
@ -38,6 +42,7 @@ void main() {
|
|||
find: (_, _, _) async => [
|
||||
Place(name: 'Lindau', kind: 'city', lat: 47.55, lon: 9.7),
|
||||
],
|
||||
nearby: _nearby,
|
||||
);
|
||||
final [place] = await gateway.search('Lindau');
|
||||
expect(place.address, '');
|
||||
|
|
@ -47,6 +52,7 @@ void main() {
|
|||
final gateway = InstancePlaceSearch(
|
||||
asked: () async => throw Exception('offline'),
|
||||
find: (_, _, _) async => const [],
|
||||
nearby: _nearby,
|
||||
);
|
||||
expect(await gateway.offered(), isFalse);
|
||||
});
|
||||
|
|
@ -55,16 +61,49 @@ void main() {
|
|||
final gateway = InstancePlaceSearch(
|
||||
asked: () async => true,
|
||||
find: (_, _, _) async => throw SearchRefused(reason: 'unreachable'),
|
||||
nearby: _nearby,
|
||||
);
|
||||
expect(gateway.search('Lindau'), throwsA(isA<SearchRefused>()));
|
||||
});
|
||||
|
||||
test('a point on the map has no name from the instance', () async {
|
||||
test('what is around a point keeps the address and the places', () async {
|
||||
final gateway = InstancePlaceSearch(
|
||||
asked: () async => true,
|
||||
find: (_, _, _) async => const [],
|
||||
nearby: (at, language) async {
|
||||
expect((at.lat, at.lon, language), (47.5, 9.7, 'de'));
|
||||
return Nearby(
|
||||
spot: Place(
|
||||
name: 'Danziger Weg 4',
|
||||
detail: 'Lindau',
|
||||
kind: 'house',
|
||||
lat: 47.5,
|
||||
lon: 9.7,
|
||||
),
|
||||
places: [
|
||||
Place(name: 'Schwarz', kind: 'house', lat: 47.51, lon: 9.71),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(await gateway.at((lat: 47.5, lon: 9.7)), isNull);
|
||||
final around = await gateway.around(
|
||||
(lat: 47.5, lon: 9.7),
|
||||
language: 'de',
|
||||
);
|
||||
expect(around.spot?.name, 'Danziger Weg 4');
|
||||
expect(around.spot?.address, 'Lindau');
|
||||
expect(around.places.single.name, 'Schwarz');
|
||||
});
|
||||
|
||||
test('nothing around a point leaves the pin unnamed', () async {
|
||||
final gateway = InstancePlaceSearch(
|
||||
asked: () async => true,
|
||||
find: (_, _, _) async => const [],
|
||||
nearby: _nearby,
|
||||
);
|
||||
final around = await gateway.around((lat: 47.5, lon: 9.7));
|
||||
expect(around.spot, isNull);
|
||||
expect(around.places, isEmpty);
|
||||
});
|
||||
|
||||
Map<String, Object?> feature(
|
||||
|
|
|
|||
|
|
@ -82,6 +82,31 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
language: params['language'],
|
||||
),
|
||||
),
|
||||
'nearby': _is.MethodConnector(
|
||||
name: 'nearby',
|
||||
params: {
|
||||
'at': _is.ParameterDescription(
|
||||
name: 'at',
|
||||
type: _is.getType<_iywv9say.PlacePoint>(),
|
||||
nullable: false,
|
||||
),
|
||||
'language': _is.ParameterDescription(
|
||||
name: 'language',
|
||||
type: _is.getType<String?>(),
|
||||
nullable: true,
|
||||
),
|
||||
},
|
||||
call:
|
||||
(
|
||||
_is.Session session,
|
||||
Map<String, dynamic> params,
|
||||
) async =>
|
||||
(endpoints['search'] as _i2f0v2ey.SearchEndpoint).nearby(
|
||||
session,
|
||||
params['at'],
|
||||
language: params['language'],
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
modules['halkyon_base'] = _ik8molhd.Endpoints()
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import 'label/label.dart' as _ivak08z4;
|
|||
import 'privacy/content_key.dart' as _imed3x5d;
|
||||
import 'project/project.dart' as _i9c44spp;
|
||||
import 'project/section.dart' as _imadkvi9;
|
||||
import 'search/nearby.dart' as _ispk4ita;
|
||||
import 'search/place.dart' as _iadh29yw;
|
||||
import 'search/place_point.dart' as _itf0ur2u;
|
||||
import 'search/search_refused.dart' as _ihbxkwke;
|
||||
|
|
@ -36,6 +37,7 @@ export 'label/label.dart';
|
|||
export 'privacy/content_key.dart';
|
||||
export 'project/project.dart';
|
||||
export 'project/section.dart';
|
||||
export 'search/nearby.dart';
|
||||
export 'search/place.dart';
|
||||
export 'search/place_point.dart';
|
||||
export 'search/search_refused.dart';
|
||||
|
|
@ -494,6 +496,9 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (t == _imadkvi9.Section) {
|
||||
return _imadkvi9.Section.fromJson(data) as T;
|
||||
}
|
||||
if (t == _ispk4ita.Nearby) {
|
||||
return _ispk4ita.Nearby.fromJson(data) as T;
|
||||
}
|
||||
if (t == _iadh29yw.Place) {
|
||||
return _iadh29yw.Place.fromJson(data) as T;
|
||||
}
|
||||
|
|
@ -521,6 +526,9 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (t == _is.getType<_imadkvi9.Section?>()) {
|
||||
return (data != null ? _imadkvi9.Section.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _is.getType<_ispk4ita.Nearby?>()) {
|
||||
return (data != null ? _ispk4ita.Nearby.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _is.getType<_iadh29yw.Place?>()) {
|
||||
return (data != null ? _iadh29yw.Place.fromJson(data) : null) as T;
|
||||
}
|
||||
|
|
@ -537,6 +545,10 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (t == _is.getType<_ib4f8uyw.Task?>()) {
|
||||
return (data != null ? _ib4f8uyw.Task.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == List<_iadh29yw.Place>) {
|
||||
return (data as List).map((e) => deserialize<_iadh29yw.Place>(e)).toList()
|
||||
as T;
|
||||
}
|
||||
if (t == List<_ih6de0l6.Place>) {
|
||||
return (data as List).map((e) => deserialize<_ih6de0l6.Place>(e)).toList()
|
||||
as T;
|
||||
|
|
@ -568,6 +580,7 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
_imed3x5d.ContentKey => 'ContentKey',
|
||||
_i9c44spp.Project => 'Project',
|
||||
_imadkvi9.Section => 'Section',
|
||||
_ispk4ita.Nearby => 'Nearby',
|
||||
_iadh29yw.Place => 'Place',
|
||||
_itf0ur2u.PlacePoint => 'PlacePoint',
|
||||
_ihbxkwke.SearchRefused => 'SearchRefused',
|
||||
|
|
@ -598,6 +611,8 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
return 'Project';
|
||||
case _imadkvi9.Section():
|
||||
return 'Section';
|
||||
case _ispk4ita.Nearby():
|
||||
return 'Nearby';
|
||||
case _iadh29yw.Place():
|
||||
return 'Place';
|
||||
case _itf0ur2u.PlacePoint():
|
||||
|
|
@ -660,6 +675,9 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (dataClassName == 'Section') {
|
||||
return deserialize<_imadkvi9.Section>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'Nearby') {
|
||||
return deserialize<_ispk4ita.Nearby>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'Place') {
|
||||
return deserialize<_iadh29yw.Place>(data['data']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
search:
|
||||
- offered:
|
||||
- places:
|
||||
- nearby:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:halkyon_todo_server/src/generated/protocol.dart' as _i6bvr7df;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import '../search/place.dart' as _iz6lll64;
|
||||
|
||||
/// What is around a point on the map (ADR 0033, D32): never stored by the
|
||||
/// server, as a found place isn't.
|
||||
abstract class Nearby
|
||||
implements _is.SerializableModel, _is.ProtocolSerialization {
|
||||
Nearby._({
|
||||
this.spot,
|
||||
required this.places,
|
||||
});
|
||||
|
||||
factory Nearby({
|
||||
_iz6lll64.Place? spot,
|
||||
required List<_iz6lll64.Place> places,
|
||||
}) = _NearbyImpl;
|
||||
|
||||
factory Nearby.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return Nearby(
|
||||
spot: jsonSerialization['spot'] == null
|
||||
? null
|
||||
: _i6bvr7df.Protocol().deserialize<_iz6lll64.Place>(
|
||||
jsonSerialization['spot'],
|
||||
),
|
||||
places: _i6bvr7df.Protocol().deserialize<List<_iz6lll64.Place>>(
|
||||
jsonSerialization['places'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The address nearest the point, what names a pin; null where none is.
|
||||
_iz6lll64.Place? spot;
|
||||
|
||||
/// The places to go to around it, nearest first: shops, restaurants and
|
||||
/// the like, what a person more likely means than a house number.
|
||||
List<_iz6lll64.Place> places;
|
||||
|
||||
/// Returns a shallow copy of this [Nearby]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
Nearby copyWith({
|
||||
_iz6lll64.Place? spot,
|
||||
List<_iz6lll64.Place>? places,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'Nearby',
|
||||
if (spot != null) 'spot': spot?.toJson(),
|
||||
'places': places.toJson(valueToJson: (v) => v.toJson()),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'Nearby',
|
||||
if (spot != null) 'spot': spot?.toJsonForProtocol(),
|
||||
'places': places.toJson(valueToJson: (v) => v.toJsonForProtocol()),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _is.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _Undefined {}
|
||||
|
||||
class _NearbyImpl extends Nearby {
|
||||
_NearbyImpl({
|
||||
_iz6lll64.Place? spot,
|
||||
required List<_iz6lll64.Place> places,
|
||||
}) : super._(
|
||||
spot: spot,
|
||||
places: places,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [Nearby]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
@override
|
||||
Nearby copyWith({
|
||||
Object? spot = _Undefined,
|
||||
List<_iz6lll64.Place>? places,
|
||||
}) {
|
||||
return Nearby(
|
||||
spot: spot is _iz6lll64.Place? ? spot : this.spot?.copyWith(),
|
||||
places: places ?? this.places.map((e0) => e0.copyWith()).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
### What is around a point on the map (ADR 0033, D32): never stored by the
|
||||
### server, as a found place isn't.
|
||||
class: Nearby
|
||||
fields:
|
||||
### The address nearest the point, what names a pin; null where none is.
|
||||
spot: Place?
|
||||
### The places to go to around it, nearest first: shops, restaurants and
|
||||
### the like, what a person more likely means than a house number.
|
||||
places: List<Place>
|
||||
|
|
@ -12,6 +12,16 @@ abstract interface class PlaceFinder {
|
|||
String? language,
|
||||
required int limit,
|
||||
});
|
||||
|
||||
/// What is around [at], within [radiusKm], nearest first; of the
|
||||
/// OpenStreetMap keys [tags] alone, if given.
|
||||
Future<Map<String, Object?>> around(
|
||||
PlacePoint at, {
|
||||
required double radiusKm,
|
||||
Set<String> tags = const {},
|
||||
String? language,
|
||||
required int limit,
|
||||
});
|
||||
}
|
||||
|
||||
/// Photon didn't answer, or not with GeoJSON.
|
||||
|
|
@ -22,7 +32,8 @@ final class const PhotonFailed(final String reason) implements Exception {
|
|||
|
||||
/// Photon (v1.3.0) at [address], asked over plain HTTP on the network it
|
||||
/// shares with Todo's server alone: `/api` with the query, the point its
|
||||
/// results are nearest to first, the language of their names.
|
||||
/// results are nearest to first, the language of their names; `/reverse`
|
||||
/// for what is around a point.
|
||||
class HttpPhoton(final Uri address) implements PlaceFinder {
|
||||
static const _timeout = Duration(seconds: 10);
|
||||
|
||||
|
|
@ -32,22 +43,36 @@ class HttpPhoton(final Uri address) implements PlaceFinder {
|
|||
PlacePoint? near,
|
||||
String? language,
|
||||
required int limit,
|
||||
}) async {
|
||||
}) => _get('api', {
|
||||
'q': query,
|
||||
'limit': '$limit',
|
||||
if (near != null) ...{'lat': '${near.lat}', 'lon': '${near.lon}'},
|
||||
'lang': ?language,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> around(
|
||||
PlacePoint at, {
|
||||
required double radiusKm,
|
||||
Set<String> tags = const {},
|
||||
String? language,
|
||||
required int limit,
|
||||
}) => _get('reverse', {
|
||||
'lat': '${at.lat}',
|
||||
'lon': '${at.lon}',
|
||||
'radius': '$radiusKm',
|
||||
'limit': '$limit',
|
||||
if (tags.isNotEmpty) 'osm_tag': tags.toList(),
|
||||
'lang': ?language,
|
||||
});
|
||||
|
||||
Future<Map<String, Object?>> _get(
|
||||
String path,
|
||||
Map<String, Object?> parameters,
|
||||
) async {
|
||||
final client = HttpClient()..connectionTimeout = _timeout;
|
||||
try {
|
||||
final uri = address
|
||||
.resolve('api')
|
||||
.replace(
|
||||
queryParameters: {
|
||||
'q': query,
|
||||
'limit': '$limit',
|
||||
if (near != null) ...{
|
||||
'lat': '${near.lat}',
|
||||
'lon': '${near.lon}',
|
||||
},
|
||||
'lang': ?language,
|
||||
},
|
||||
);
|
||||
final uri = address.resolve(path).replace(queryParameters: parameters);
|
||||
final HttpClientResponse response;
|
||||
final String body;
|
||||
try {
|
||||
|
|
@ -79,6 +104,30 @@ const _noDestination = {
|
|||
'parking_space',
|
||||
};
|
||||
|
||||
/// What a list of the places around a point leaves out besides: found by
|
||||
/// name it may be meant, but nobody goes there as a place around them.
|
||||
const _noNeighbour = {
|
||||
'atm',
|
||||
'bench',
|
||||
'bicycle_parking',
|
||||
'post_box',
|
||||
'recycling',
|
||||
'toilets',
|
||||
'vending_machine',
|
||||
'waste_basket',
|
||||
};
|
||||
|
||||
/// The places around a point in Photon's [geoJson]: each name once, what
|
||||
/// nobody goes to left out.
|
||||
List<Place> neighboursOf(Map<String, Object?> geoJson) {
|
||||
final names = <String>{};
|
||||
return [
|
||||
for (final place in placesOf(geoJson))
|
||||
if (!_noNeighbour.contains(place.osmValue) && names.add(place.name))
|
||||
place,
|
||||
];
|
||||
}
|
||||
|
||||
/// The places in Photon's [geoJson], in its order: each feature a point
|
||||
/// with a name, or a street (with its number) for an address; what is no
|
||||
/// place to go and the rest left out.
|
||||
|
|
|
|||
|
|
@ -39,8 +39,20 @@ class PlaceSearch {
|
|||
/// The most asked of Photon, as what is no place to go is left out of it.
|
||||
static const _askedOfPhoton = 12;
|
||||
|
||||
/// How far around a point a person looks for a place to go, and for the
|
||||
/// address naming it.
|
||||
static const nearbyKm = 0.5;
|
||||
static const addressKm = 0.1;
|
||||
|
||||
/// What a list of places around a point is made of: a plain lookup finds
|
||||
/// the nearest house numbers alone (komoot's Photon, 5 Nov 2026).
|
||||
static const _placesToGo = {'shop', 'amenity', 'tourism', 'leisure'};
|
||||
|
||||
/// The most asked of Photon around a point, as more is left out of it.
|
||||
static const _askedAround = 20;
|
||||
|
||||
/// The most searches a person may make in a minute: the location sheet asks
|
||||
/// as they type, a pause after each word.
|
||||
/// as they type, a pause after each word, and as the map stops moving.
|
||||
static const perMinute = 60;
|
||||
|
||||
/// The languages Photon names places in; any other gets its own names.
|
||||
|
|
@ -83,6 +95,43 @@ class PlaceSearch {
|
|||
}
|
||||
}
|
||||
|
||||
/// What is around [at] for [person], named in [language]: the address
|
||||
/// that names a pin, and the places to go to. Refused as [places] is, and
|
||||
/// counted as one search.
|
||||
Future<Nearby> nearby(
|
||||
UuidValue person,
|
||||
PlacePoint at, {
|
||||
String? language,
|
||||
}) async {
|
||||
final finder = this.finder;
|
||||
if (finder == null) {
|
||||
throw SearchRefused(reason: 'off');
|
||||
}
|
||||
if (!_onEarth(at)) {
|
||||
throw SearchRefused(reason: 'invalid');
|
||||
}
|
||||
_count(person);
|
||||
final named = languages.contains(language) ? language : null;
|
||||
try {
|
||||
final [address, around] = await Future.wait([
|
||||
finder.around(at, radiusKm: addressKm, language: named, limit: 1),
|
||||
finder.around(
|
||||
at,
|
||||
radiusKm: nearbyKm,
|
||||
tags: _placesToGo,
|
||||
language: named,
|
||||
limit: _askedAround,
|
||||
),
|
||||
]);
|
||||
return Nearby(
|
||||
spot: placesOf(address).firstOrNull,
|
||||
places: neighboursOf(around).take(limit).toList(),
|
||||
);
|
||||
} on PhotonFailed {
|
||||
throw SearchRefused(reason: 'unreachable');
|
||||
}
|
||||
}
|
||||
|
||||
static bool _onEarth(PlacePoint point) =>
|
||||
point.lat.isFinite &&
|
||||
point.lon.isFinite &&
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue