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:
Tizian 「ティツィアーン」 2026-11-05 23:42:06 +01:00
commit ce07fab193
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 1254 additions and 113 deletions

View file

@ -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",

View file

@ -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."

View file

@ -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:

View file

@ -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';

View file

@ -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';

View file

@ -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'};
}

View file

@ -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(

View file

@ -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);
});
});
}

View file

@ -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(