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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-06 00:12:01 +01:00
commit a4c1fe298d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 1663 additions and 241 deletions

View file

@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter_map/flutter_map.dart';
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';
@ -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/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/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';
/// Allows the position, as a device with the person's yes.
class _Allowed() extends NoLocationReminders {
@override
Future<LocationAccess> access() async => LocationAccess.whileInUse;
}
class _Places(
final List<TaskLocation> found, {
final bool isOffered = true,
@ -58,6 +69,25 @@ void main() {
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(
WidgetTester tester, {
PlaceSearchGateway? places,
@ -65,7 +95,12 @@ void main() {
String? query,
LatLon? at,
MapTiles? tiles,
Size size = phone,
LocationReminderGateway gateway = const NoLocationReminders(),
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final picked = <TaskLocation?>[];
await tester.pumpWidget(
MaterialApp(
@ -81,9 +116,8 @@ void main() {
builder: (context) => LocationPicker(
places: places,
mapTiles: tiles,
access: LocationReminderAccess(
const NoLocationReminders(),
),
widths: PaneWidths(),
access: LocationReminderAccess(gateway),
position: () async => at,
recents: recents,
query: query,
@ -108,9 +142,7 @@ void main() {
) async {
final places = _Places(const [aeschach, reutin]);
await open(tester, places: places);
await tester.enterText(find.byKey(locationSearchKey), 'Lindau EDEKA');
await tester.pump(const Duration(milliseconds: 400));
await tester.pump();
await typed(tester, 'Lindau EDEKA');
expect(places.asked, ['Lindau EDEKA']);
expect(find.text(aeschach.address), findsOneWidget);
expect(find.text(reutin.address), findsOneWidget);
@ -122,9 +154,10 @@ void main() {
places: _Places(const [aeschach, reutin]),
query: 'Lindau EDEKA',
);
await tester.pump();
await settle(tester);
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.pump(const Duration(milliseconds: 400));
expect(picked, [reutin]);
@ -134,7 +167,7 @@ void main() {
tester,
) async {
await open(tester, places: _Places(const []), query: 'Nowhere');
await tester.pump();
await settle(tester);
expect(find.text('Nothing found'), findsOneWidget);
});
@ -146,7 +179,7 @@ void main() {
),
findsOneWidget,
);
expect(find.byKey(locationSearchKey), findsNothing);
expect(find.byKey(locationSearchBarKey), findsNothing);
expect(find.byKey(locationMapKey), findsOneWidget);
});
@ -155,7 +188,7 @@ void main() {
) async {
await open(tester, places: _Places(const [], isOffered: false));
await tester.pump();
expect(find.byKey(locationSearchKey), findsNothing);
expect(find.byKey(locationSearchBarKey), findsNothing);
expect(
find.text(
"Search isn't available here. Move the map and tap to place a pin.",
@ -179,12 +212,87 @@ void main() {
places: _Places(const [aeschach]),
query: 'EDEKA',
);
await tester.pump();
await settle(tester);
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);
});
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", () {
const here = (lat: 47.55, lon: 9.70);
const street = TaskLocation(
@ -318,6 +426,152 @@ void main() {
await rest(tester);
expect(find.text('Pinned place'), 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 {
@ -342,17 +596,370 @@ void main() {
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', (
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(),
size: const Size(320, 640),
);
await rest(tester);
expect(tester.takeException(), isNull);

View file

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