feat(train): Maps shows every route on the map, above the list or beside it (ADR 0030, D60)
The instance's map with every route drawn, fitted to them all, a route tapped opening its planner. On phones the map stands above the list; from expanded windows on the two are panes side by side, as Material's list-detail layout. Without a route to draw, the list alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
064c44bfa5
commit
220816884c
5 changed files with 263 additions and 6 deletions
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,108 @@
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
|
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
|
||||||
|
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/route/bloc/routes_cubit.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/route/widget/route_screen.dart';
|
||||||
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// The person's routes with a way to draw, each its planned way, else its
|
||||||
|
/// waypoints, by its id.
|
||||||
|
List<({String id, RouteContent content, List<LatLng> points})> drawnRoutes(
|
||||||
|
RoutesState state,
|
||||||
|
) => [
|
||||||
|
for (final route in state.visible)
|
||||||
|
if (route.content case final content?)
|
||||||
|
if (content.planned?.points ?? content.waypoints case final points
|
||||||
|
when points.length > 1)
|
||||||
|
(id: route.id, content: content, points: points),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Every route on the instance's map, as the Maps tab shows them (ADR 0030,
|
||||||
|
/// D60): the camera fitting them all, one tapped opening its planner; the
|
||||||
|
/// lines without tiles where the instance's map source can't be asked.
|
||||||
|
class const RoutesMap({super.key}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<RoutesMap> createState() => _RoutesMapState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RoutesMapState() extends State<RoutesMap> {
|
||||||
|
late final Future<MapSource?> _source = PersonScope.of<TrainPerson>(
|
||||||
|
context,
|
||||||
|
).maps.source();
|
||||||
|
|
||||||
|
/// Where a line was last hit, so that a tap knows which.
|
||||||
|
final _hit = LayerHitNotifier<Object>(null);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_hit.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final train = PersonScope.of<TrainPerson>(context);
|
||||||
|
final routes = drawnRoutes(context.watch<RoutesCubit>().state);
|
||||||
|
final all = [for (final route in routes) ...route.points];
|
||||||
|
return FutureBuilder(
|
||||||
|
future: _source,
|
||||||
|
builder: (context, snapshot) => FlutterMap(
|
||||||
|
// Fitted again as routes come and go.
|
||||||
|
key: ValueKey({for (final route in routes) route.id}.join(' ')),
|
||||||
|
options: MapOptions(
|
||||||
|
maxZoom: mapMaxZoom,
|
||||||
|
initialCameraFit: CameraFit.coordinates(
|
||||||
|
coordinates: all,
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
maxZoom: 16,
|
||||||
|
),
|
||||||
|
interactionOptions: const InteractionOptions(
|
||||||
|
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
if (snapshot.data case final source?) train.mapTiles(source),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
final id = _hit.value?.hitValues.firstOrNull;
|
||||||
|
if (id == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (final route in routes) {
|
||||||
|
if (route.id == id) {
|
||||||
|
openRouteScreen(context, route.content, id: route.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: PolylineLayer(
|
||||||
|
hitNotifier: _hit,
|
||||||
|
// A finger's width: the lines are 4 wide.
|
||||||
|
minimumHitbox: 20,
|
||||||
|
polylines: [
|
||||||
|
for (final route in routes)
|
||||||
|
Polyline<Object>(
|
||||||
|
points: route.points,
|
||||||
|
hitValue: route.id,
|
||||||
|
// Cobalt in either theme: the map's colours are the
|
||||||
|
// source's, not the app's.
|
||||||
|
color: PaletteColor.cobalt.background,
|
||||||
|
strokeWidth: 4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (snapshot.data case final source?)
|
||||||
|
RichAttributionWidget(
|
||||||
|
attributions: [TextSourceAttribution(source.attribution)],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,60 @@
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/route/bloc/routes_cubit.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/route/widget/routes_list.dart';
|
import 'package:halkyon_train_flutter/src/feature/route/widget/routes_list.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/route/widget/routes_map.dart';
|
||||||
import 'package:halkyon_train_flutter/src/tabs/tab_screen.dart';
|
import 'package:halkyon_train_flutter/src/tabs/tab_screen.dart';
|
||||||
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// Maps (ADR 0030, D60): the person's routes (D44), and planning another.
|
/// Maps (ADR 0030, D60): the person's routes (D44) on a map and in a list,
|
||||||
|
/// and planning another. Below expanded windows the map stands above the
|
||||||
|
/// list; from there on they are two panes, the list beside the map, as
|
||||||
|
/// Material's list–detail layout; without a route to draw, the list alone.
|
||||||
class const MapsTab({super.key}) extends StatelessWidget {
|
class const MapsTab({super.key}) extends StatelessWidget {
|
||||||
|
static const double _listWidth = 400;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => TabScreen(
|
Widget build(BuildContext context) {
|
||||||
|
final drawn = context.select<RoutesCubit, bool>(
|
||||||
|
(cubit) => drawnRoutes(cubit.state).isNotEmpty,
|
||||||
|
);
|
||||||
|
final map = Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: const RoutesMap(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return TabScreen(
|
||||||
title: AppLocalizations.of(context).shellTabMaps,
|
title: AppLocalizations.of(context).shellTabMaps,
|
||||||
body: const RoutesList(),
|
body: switch ((drawn, context.windowSize)) {
|
||||||
|
(false, _) => const RoutesList(),
|
||||||
|
(true, WindowSize.compact || WindowSize.medium) => Column(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: (MediaQuery.sizeOf(context).height * 0.4).clamp(
|
||||||
|
200,
|
||||||
|
360,
|
||||||
|
),
|
||||||
|
child: map,
|
||||||
|
),
|
||||||
|
const Expanded(child: RoutesList()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(true, _) => Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: _listWidth, child: RoutesList()),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: map,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/widget/week_card.dart';
|
import 'package:halkyon_train_flutter/src/feature/workout/widget/week_card.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/tabs/maps_tab.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:http/testing.dart';
|
import 'package:http/testing.dart';
|
||||||
|
|
@ -2548,6 +2549,102 @@ void main() {
|
||||||
expect(find.text("Your instance doesn't plan routes."), findsOneWidget);
|
expect(find.text("Your instance doesn't plan routes."), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('on the Maps tab (D60)', () {
|
||||||
|
Future<void> saveTwo() async {
|
||||||
|
for (final (name, end) in [
|
||||||
|
('Isar', const LatLng(48.12, 11.58)),
|
||||||
|
('Englischer Garten', const LatLng(48.16, 11.6)),
|
||||||
|
]) {
|
||||||
|
await routes.save(
|
||||||
|
RouteContent(
|
||||||
|
name: name,
|
||||||
|
profile: RouteProfile.run,
|
||||||
|
waypoints: [const LatLng(48.137, 11.575), end],
|
||||||
|
planned: straightRoute([const LatLng(48.137, 11.575), end]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Polyline<Object>> lines(WidgetTester tester) => [
|
||||||
|
for (final layer in tester.widgetList<PolylineLayer<Object>>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(MapsTab),
|
||||||
|
matching: find.byType(PolylineLayer<Object>),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
...layer.polylines,
|
||||||
|
];
|
||||||
|
|
||||||
|
testWidgets('on a phone, every route on a map above the list', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await saveTwo();
|
||||||
|
await openRoutes(tester);
|
||||||
|
|
||||||
|
expect(lines(tester), hasLength(2));
|
||||||
|
final map = find.descendant(
|
||||||
|
of: find.byType(MapsTab),
|
||||||
|
matching: find.byType(FlutterMap),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.getBottomLeft(map).dy,
|
||||||
|
lessThanOrEqualTo(tester.getTopLeft(find.text('Isar')).dy),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a route tapped on the map opens', (tester) async {
|
||||||
|
await routes.save(
|
||||||
|
RouteContent(
|
||||||
|
name: 'Isar',
|
||||||
|
profile: RouteProfile.run,
|
||||||
|
waypoints: const [LatLng(48.137, 11.575), LatLng(48.12, 11.58)],
|
||||||
|
planned: straightRoute(const [
|
||||||
|
LatLng(48.137, 11.575),
|
||||||
|
LatLng(48.12, 11.58),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await openRoutes(tester);
|
||||||
|
|
||||||
|
// The camera fits the one route, whose middle is the map's.
|
||||||
|
await tapMap(tester, Offset.zero);
|
||||||
|
expect(find.text('Route'), findsOneWidget);
|
||||||
|
expect(find.text('Isar'), findsWidgets);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('from expanded windows on, the list and the map side by '
|
||||||
|
'side', (tester) async {
|
||||||
|
await saveTwo();
|
||||||
|
await openRoutes(tester);
|
||||||
|
tester.view.physicalSize = const Size(1280, 800);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final map = find.descendant(
|
||||||
|
of: find.byType(MapsTab),
|
||||||
|
matching: find.byType(FlutterMap),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(map).dx,
|
||||||
|
greaterThanOrEqualTo(tester.getTopRight(find.text('Isar')).dx),
|
||||||
|
);
|
||||||
|
expect(lines(tester), hasLength(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('without routes, no map', (tester) async {
|
||||||
|
await openRoutes(tester);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(MapsTab),
|
||||||
|
matching: find.byType(FlutterMap),
|
||||||
|
),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
expect(find.text('No routes yet'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('a route is opened, deleted after asking and brought back', (
|
testWidgets('a route is opened, deleted after asking and brought back', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
|
||||||
|
|
@ -1504,6 +1504,11 @@ Strava. What was found, 27 Oct:
|
||||||
English, Material's `firstDayOfWeekIndex`), each workout on the day it started, local
|
English, Material's `firstDayOfWeekIndex`), each workout on the day it started, local
|
||||||
time; over the totals alone and over a chart of several weeks (the developer's,
|
time; over the totals alone and over a chart of several weeks (the developer's,
|
||||||
31 Oct).
|
31 Oct).
|
||||||
|
Maps shows every route on the instance's map (`RoutesMap`), the camera fitting them,
|
||||||
|
one tapped opening its planner: above the list below expanded windows, beside it from
|
||||||
|
expanded windows on, as Material's list–detail layout has two panes there; without a
|
||||||
|
route to draw, the list alone, as there is nothing to centre on without asking the
|
||||||
|
phone where it is (the developer's, 31 Oct).
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue