feat(train): Maps shows every route on the map, above the list or beside it (ADR 0030, D60)
All checks were successful
CI / check (push) Successful in 41m20s
CI / deploy (push) Successful in 58s

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:
Tizian 「ティツィアーン」 2026-10-31 12:25:38 +01:00
commit 220816884c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 263 additions and 6 deletions

View file

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

View file

@ -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/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_map.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';
/// 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 {
static const double _listWidth = 400;
@override
Widget build(BuildContext context) => TabScreen(
title: AppLocalizations.of(context).shellTabMaps,
body: const RoutesList(),
);
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,
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,
),
),
],
),
},
);
}
}