feat(train): routes kept as sealed rows, planned on the map (ADR 0030, D44)

Routes are synced `route` rows (the model `SavedRoute`, as Flutter has a
`Route`), sealed as plans are; the migration `routes` only creates the
table, its space index and its cascade to the sync engine's spaces. A
route's payload holds its name, activity, profile, waypoints and the way
planned, points and elevations delta-coded and gzipped.

Home gets a fourth tab, Routes, and the planner: waypoints tapped on the
map, held and dragged, the last undone, a new route's first where the
phone is (GPS through the recorder's channel), the way planned by the
instance's BRouter after each change, an earlier answer dropped; its
distance, climb, descent, elevation and surfaces under the map.

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-29 00:37:44 +01:00
commit aea99c68ca
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
22 changed files with 24035 additions and 12 deletions

View file

@ -376,5 +376,45 @@
"dayBreathing": "{rate} Atemzüge pro Minute",
"daySkin": "Hauttemperatur",
"dayCelsius": "{value} °C",
"daySkinDeviation": "{value} °C gegenüber deinen Nächten sonst"
"daySkinDeviation": "{value} °C gegenüber deinen Nächten sonst",
"homeRoutes": "Routen",
"routesNew": "Neue Route",
"routesEmptyTitle": "Noch keine Routen",
"routesEmptyHint": "Tipp auf die Karte, um Wegpunkte zu setzen, und deine Instanz plant den Weg dazwischen über Wege und Straßen, mit Anstieg und Untergrund.",
"routeLocked": "Eine Route, die sich hier nicht öffnet",
"routeTitle": "Route",
"routeNewTitle": "Neue Route",
"routeName": "Name",
"routeActivity": "Für",
"routeActivityAny": "Jede Aktivität",
"routeProfile": "Geplant für",
"routeProfileRun": "Laufen",
"routeProfileHike": "Wandern",
"routeProfileRoadBike": "Rennrad",
"routeProfileTrekkingBike": "Trekkingrad",
"routeProfileGravel": "Gravelbike",
"routeProfileMtb": "Mountainbike",
"routeTapHint": "Tipp auf die Karte, um einen Wegpunkt hinzuzufügen; halt einen gedrückt und zieh ihn, um ihn zu verschieben.",
"routeUndo": "Letzten Wegpunkt entfernen",
"routePlanning": "Wird geplant …",
"routeRefusedInvalid": "Ein Wegpunkt zu viel, oder einer außerhalb der Karte.",
"routeRefusedTooOften": "Zu oft geplant: versuch es in einer Minute wieder.",
"routeRefusedNoRoute": "Dort wurde kein Weg gefunden: Ein Wegpunkt liegt vielleicht abseits der Wege oder außerhalb des Gebiets, in dem deine Instanz plant.",
"routeRefusedUnreachable": "Deine Instanz ist nicht erreichbar, um den Weg zu planen.",
"routeNotOffered": "Deine Instanz plant keine Routen.",
"routeAscent": "{metres} m bergauf",
"routeDescent": "{metres} m bergab",
"routeSummary": "{distance} · {ascent}",
"routeSurfaces": "Untergrund",
"surfacePaved": "Befestigt",
"surfaceCompacted": "Verdichtet",
"surfaceGravel": "Schotter",
"surfaceNatural": "Naturboden",
"surfaceUnknown": "Unbekannt",
"routeSave": "Speichern",
"routeNotSaved": "Die Route wurde nicht gespeichert.",
"routeDelete": "Löschen",
"routeDeleteTitle": "Diese Route löschen?",
"routeDeleteMessage": "Sie wird auf all deinen Geräten gelöscht.",
"routeDeleted": "Route gelöscht"
}

View file

@ -481,5 +481,48 @@
"dayCelsius": "{value} °C",
"@dayCelsius": {"placeholders": {"value": {"type": "String", "example": "33.4"}}},
"daySkinDeviation": "{value} °C from your usual at night",
"@daySkinDeviation": {"placeholders": {"value": {"type": "String", "example": "−0.2"}}}
"@daySkinDeviation": {"placeholders": {"value": {"type": "String", "example": "−0.2"}}},
"homeRoutes": "Routes",
"routesNew": "New route",
"routesEmptyTitle": "No routes yet",
"routesEmptyHint": "Tap the map to set waypoints, and your instance plans the way between them on paths and roads, with its climb and surfaces.",
"routeLocked": "A route that doesn't open here",
"routeTitle": "Route",
"routeNewTitle": "New route",
"routeName": "Name",
"routeActivity": "For",
"routeActivityAny": "Any activity",
"routeProfile": "Planned for",
"routeProfileRun": "Running",
"routeProfileHike": "Hiking",
"routeProfileRoadBike": "Road bike",
"routeProfileTrekkingBike": "Trekking bike",
"routeProfileGravel": "Gravel bike",
"routeProfileMtb": "Mountain bike",
"routeTapHint": "Tap the map to add a waypoint; hold one and drag it to move it.",
"routeUndo": "Take the last waypoint off",
"routePlanning": "Planning…",
"routeRefusedInvalid": "A waypoint too many, or one off the map.",
"routeRefusedTooOften": "Planned too often: try again in a minute.",
"routeRefusedNoRoute": "No way found there: a waypoint may be off the paths, or outside the region your instance plans in.",
"routeRefusedUnreachable": "Your instance can't be reached to plan the way.",
"routeNotOffered": "Your instance doesn't plan routes.",
"routeAscent": "{metres} m up",
"@routeAscent": {"placeholders": {"metres": {"type": "int"}}},
"routeDescent": "{metres} m down",
"@routeDescent": {"placeholders": {"metres": {"type": "int"}}},
"routeSummary": "{distance} · {ascent}",
"@routeSummary": {"description": "A route in the list: its distance and climb.", "placeholders": {"distance": {"type": "String", "example": "12.3 km"}, "ascent": {"type": "String", "example": "120 m up"}}},
"routeSurfaces": "Surfaces",
"surfacePaved": "Paved",
"surfaceCompacted": "Compacted",
"surfaceGravel": "Gravel",
"surfaceNatural": "Natural ground",
"surfaceUnknown": "Not known",
"routeSave": "Save",
"routeNotSaved": "The route wasn't saved.",
"routeDelete": "Delete",
"routeDeleteTitle": "Delete this route?",
"routeDeleteMessage": "It's deleted on all your devices.",
"routeDeleted": "Route deleted"
}

View file

@ -2283,6 +2283,246 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'{value} °C from your usual at night'**
String daySkinDeviation(String value);
/// No description provided for @homeRoutes.
///
/// In en, this message translates to:
/// **'Routes'**
String get homeRoutes;
/// No description provided for @routesNew.
///
/// In en, this message translates to:
/// **'New route'**
String get routesNew;
/// No description provided for @routesEmptyTitle.
///
/// In en, this message translates to:
/// **'No routes yet'**
String get routesEmptyTitle;
/// No description provided for @routesEmptyHint.
///
/// In en, this message translates to:
/// **'Tap the map to set waypoints, and your instance plans the way between them on paths and roads, with its climb and surfaces.'**
String get routesEmptyHint;
/// No description provided for @routeLocked.
///
/// In en, this message translates to:
/// **'A route that doesn\'t open here'**
String get routeLocked;
/// No description provided for @routeTitle.
///
/// In en, this message translates to:
/// **'Route'**
String get routeTitle;
/// No description provided for @routeNewTitle.
///
/// In en, this message translates to:
/// **'New route'**
String get routeNewTitle;
/// No description provided for @routeName.
///
/// In en, this message translates to:
/// **'Name'**
String get routeName;
/// No description provided for @routeActivity.
///
/// In en, this message translates to:
/// **'For'**
String get routeActivity;
/// No description provided for @routeActivityAny.
///
/// In en, this message translates to:
/// **'Any activity'**
String get routeActivityAny;
/// No description provided for @routeProfile.
///
/// In en, this message translates to:
/// **'Planned for'**
String get routeProfile;
/// No description provided for @routeProfileRun.
///
/// In en, this message translates to:
/// **'Running'**
String get routeProfileRun;
/// No description provided for @routeProfileHike.
///
/// In en, this message translates to:
/// **'Hiking'**
String get routeProfileHike;
/// No description provided for @routeProfileRoadBike.
///
/// In en, this message translates to:
/// **'Road bike'**
String get routeProfileRoadBike;
/// No description provided for @routeProfileTrekkingBike.
///
/// In en, this message translates to:
/// **'Trekking bike'**
String get routeProfileTrekkingBike;
/// No description provided for @routeProfileGravel.
///
/// In en, this message translates to:
/// **'Gravel bike'**
String get routeProfileGravel;
/// No description provided for @routeProfileMtb.
///
/// In en, this message translates to:
/// **'Mountain bike'**
String get routeProfileMtb;
/// No description provided for @routeTapHint.
///
/// In en, this message translates to:
/// **'Tap the map to add a waypoint; hold one and drag it to move it.'**
String get routeTapHint;
/// No description provided for @routeUndo.
///
/// In en, this message translates to:
/// **'Take the last waypoint off'**
String get routeUndo;
/// No description provided for @routePlanning.
///
/// In en, this message translates to:
/// **'Planning…'**
String get routePlanning;
/// No description provided for @routeRefusedInvalid.
///
/// In en, this message translates to:
/// **'A waypoint too many, or one off the map.'**
String get routeRefusedInvalid;
/// No description provided for @routeRefusedTooOften.
///
/// In en, this message translates to:
/// **'Planned too often: try again in a minute.'**
String get routeRefusedTooOften;
/// No description provided for @routeRefusedNoRoute.
///
/// In en, this message translates to:
/// **'No way found there: a waypoint may be off the paths, or outside the region your instance plans in.'**
String get routeRefusedNoRoute;
/// No description provided for @routeRefusedUnreachable.
///
/// In en, this message translates to:
/// **'Your instance can\'t be reached to plan the way.'**
String get routeRefusedUnreachable;
/// No description provided for @routeNotOffered.
///
/// In en, this message translates to:
/// **'Your instance doesn\'t plan routes.'**
String get routeNotOffered;
/// No description provided for @routeAscent.
///
/// In en, this message translates to:
/// **'{metres} m up'**
String routeAscent(int metres);
/// No description provided for @routeDescent.
///
/// In en, this message translates to:
/// **'{metres} m down'**
String routeDescent(int metres);
/// A route in the list: its distance and climb.
///
/// In en, this message translates to:
/// **'{distance} · {ascent}'**
String routeSummary(String distance, String ascent);
/// No description provided for @routeSurfaces.
///
/// In en, this message translates to:
/// **'Surfaces'**
String get routeSurfaces;
/// No description provided for @surfacePaved.
///
/// In en, this message translates to:
/// **'Paved'**
String get surfacePaved;
/// No description provided for @surfaceCompacted.
///
/// In en, this message translates to:
/// **'Compacted'**
String get surfaceCompacted;
/// No description provided for @surfaceGravel.
///
/// In en, this message translates to:
/// **'Gravel'**
String get surfaceGravel;
/// No description provided for @surfaceNatural.
///
/// In en, this message translates to:
/// **'Natural ground'**
String get surfaceNatural;
/// No description provided for @surfaceUnknown.
///
/// In en, this message translates to:
/// **'Not known'**
String get surfaceUnknown;
/// No description provided for @routeSave.
///
/// In en, this message translates to:
/// **'Save'**
String get routeSave;
/// No description provided for @routeNotSaved.
///
/// In en, this message translates to:
/// **'The route wasn\'t saved.'**
String get routeNotSaved;
/// No description provided for @routeDelete.
///
/// In en, this message translates to:
/// **'Delete'**
String get routeDelete;
/// No description provided for @routeDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete this route?'**
String get routeDeleteTitle;
/// No description provided for @routeDeleteMessage.
///
/// In en, this message translates to:
/// **'It\'s deleted on all your devices.'**
String get routeDeleteMessage;
/// No description provided for @routeDeleted.
///
/// In en, this message translates to:
/// **'Route deleted'**
String get routeDeleted;
}
class _AppLocalizationsDelegate

View file

@ -1367,4 +1367,136 @@ class AppLocalizationsDe extends AppLocalizations {
String daySkinDeviation(String value) {
return '$value °C gegenüber deinen Nächten sonst';
}
@override
String get homeRoutes => 'Routen';
@override
String get routesNew => 'Neue Route';
@override
String get routesEmptyTitle => 'Noch keine Routen';
@override
String get routesEmptyHint =>
'Tipp auf die Karte, um Wegpunkte zu setzen, und deine Instanz plant den Weg dazwischen über Wege und Straßen, mit Anstieg und Untergrund.';
@override
String get routeLocked => 'Eine Route, die sich hier nicht öffnet';
@override
String get routeTitle => 'Route';
@override
String get routeNewTitle => 'Neue Route';
@override
String get routeName => 'Name';
@override
String get routeActivity => 'Für';
@override
String get routeActivityAny => 'Jede Aktivität';
@override
String get routeProfile => 'Geplant für';
@override
String get routeProfileRun => 'Laufen';
@override
String get routeProfileHike => 'Wandern';
@override
String get routeProfileRoadBike => 'Rennrad';
@override
String get routeProfileTrekkingBike => 'Trekkingrad';
@override
String get routeProfileGravel => 'Gravelbike';
@override
String get routeProfileMtb => 'Mountainbike';
@override
String get routeTapHint =>
'Tipp auf die Karte, um einen Wegpunkt hinzuzufügen; halt einen gedrückt und zieh ihn, um ihn zu verschieben.';
@override
String get routeUndo => 'Letzten Wegpunkt entfernen';
@override
String get routePlanning => 'Wird geplant …';
@override
String get routeRefusedInvalid =>
'Ein Wegpunkt zu viel, oder einer außerhalb der Karte.';
@override
String get routeRefusedTooOften =>
'Zu oft geplant: versuch es in einer Minute wieder.';
@override
String get routeRefusedNoRoute =>
'Dort wurde kein Weg gefunden: Ein Wegpunkt liegt vielleicht abseits der Wege oder außerhalb des Gebiets, in dem deine Instanz plant.';
@override
String get routeRefusedUnreachable =>
'Deine Instanz ist nicht erreichbar, um den Weg zu planen.';
@override
String get routeNotOffered => 'Deine Instanz plant keine Routen.';
@override
String routeAscent(int metres) {
return '$metres m bergauf';
}
@override
String routeDescent(int metres) {
return '$metres m bergab';
}
@override
String routeSummary(String distance, String ascent) {
return '$distance · $ascent';
}
@override
String get routeSurfaces => 'Untergrund';
@override
String get surfacePaved => 'Befestigt';
@override
String get surfaceCompacted => 'Verdichtet';
@override
String get surfaceGravel => 'Schotter';
@override
String get surfaceNatural => 'Naturboden';
@override
String get surfaceUnknown => 'Unbekannt';
@override
String get routeSave => 'Speichern';
@override
String get routeNotSaved => 'Die Route wurde nicht gespeichert.';
@override
String get routeDelete => 'Löschen';
@override
String get routeDeleteTitle => 'Diese Route löschen?';
@override
String get routeDeleteMessage => 'Sie wird auf all deinen Geräten gelöscht.';
@override
String get routeDeleted => 'Route gelöscht';
}

View file

@ -1360,6 +1360,137 @@ class AppLocalizationsEn extends AppLocalizations {
String daySkinDeviation(String value) {
return '$value °C from your usual at night';
}
@override
String get homeRoutes => 'Routes';
@override
String get routesNew => 'New route';
@override
String get routesEmptyTitle => 'No routes yet';
@override
String get routesEmptyHint =>
'Tap the map to set waypoints, and your instance plans the way between them on paths and roads, with its climb and surfaces.';
@override
String get routeLocked => 'A route that doesn\'t open here';
@override
String get routeTitle => 'Route';
@override
String get routeNewTitle => 'New route';
@override
String get routeName => 'Name';
@override
String get routeActivity => 'For';
@override
String get routeActivityAny => 'Any activity';
@override
String get routeProfile => 'Planned for';
@override
String get routeProfileRun => 'Running';
@override
String get routeProfileHike => 'Hiking';
@override
String get routeProfileRoadBike => 'Road bike';
@override
String get routeProfileTrekkingBike => 'Trekking bike';
@override
String get routeProfileGravel => 'Gravel bike';
@override
String get routeProfileMtb => 'Mountain bike';
@override
String get routeTapHint =>
'Tap the map to add a waypoint; hold one and drag it to move it.';
@override
String get routeUndo => 'Take the last waypoint off';
@override
String get routePlanning => 'Planning…';
@override
String get routeRefusedInvalid => 'A waypoint too many, or one off the map.';
@override
String get routeRefusedTooOften =>
'Planned too often: try again in a minute.';
@override
String get routeRefusedNoRoute =>
'No way found there: a waypoint may be off the paths, or outside the region your instance plans in.';
@override
String get routeRefusedUnreachable =>
'Your instance can\'t be reached to plan the way.';
@override
String get routeNotOffered => 'Your instance doesn\'t plan routes.';
@override
String routeAscent(int metres) {
return '$metres m up';
}
@override
String routeDescent(int metres) {
return '$metres m down';
}
@override
String routeSummary(String distance, String ascent) {
return '$distance · $ascent';
}
@override
String get routeSurfaces => 'Surfaces';
@override
String get surfacePaved => 'Paved';
@override
String get surfaceCompacted => 'Compacted';
@override
String get surfaceGravel => 'Gravel';
@override
String get surfaceNatural => 'Natural ground';
@override
String get surfaceUnknown => 'Not known';
@override
String get routeSave => 'Save';
@override
String get routeNotSaved => 'The route wasn\'t saved.';
@override
String get routeDelete => 'Delete';
@override
String get routeDeleteTitle => 'Delete this route?';
@override
String get routeDeleteMessage => 'It\'s deleted on all your devices.';
@override
String get routeDeleted => 'Route deleted';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -11,6 +11,7 @@ import 'package:halkyon_train_flutter/src/feature/plan/bloc/plans_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_speech.dart';
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_train_flutter/src/feature/route/bloc/routes_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
import 'package:halkyon_train_flutter/src/home/home_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -100,6 +101,12 @@ class const TrainPersonApp({
return PlansCubit(train.plans, train.planDeletions)..open();
},
),
BlocProvider(
create: (context) {
final train = PersonScope.of<TrainPerson>(context);
return RoutesCubit(train.routes, train.routeDeletions)..open();
},
),
],
child: _Recording(child: child!),
),

View file

@ -26,6 +26,9 @@ import 'package:halkyon_train_flutter/src/feature/profile/data/sync_profile_gate
import 'package:halkyon_train_flutter/src/feature/recording/data/android_recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/routing_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/sync_route_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/sync_workout_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:package_info_plus/package_info_plus.dart';
@ -67,6 +70,12 @@ class TrainPerson(
/// Their interval plans (D42), sealed and synced.
required final PlanRepository plans,
/// Their routes (D44), sealed and synced.
required final RouteRepository routes,
/// The instance's planning of routes (D44).
required final RoutingGateway routing,
/// The instance's map source (D22).
required final MapSourceRepository maps,
@ -93,11 +102,18 @@ class TrainPerson(
/// Plans deleted, to be undone for a moment, as workouts are.
late final planDeletions = DelayedDeletions(plans.delete, logName: 'Plans');
/// Routes deleted, to be undone for a moment, as workouts are.
late final routeDeletions = DelayedDeletions(
routes.delete,
logName: 'Routes',
);
/// Closes what was opened for the person.
@override
Future<void> dispose() async {
await deletions.dispose();
await planDeletions.dispose();
await routeDeletions.dispose();
await health?.dispose();
await loop?.dispose();
await _onDispose?.call();
@ -340,6 +356,7 @@ Future<TrainPerson> _openPersonData(
person: person.authUserId,
);
final plans = PlanRepository(SyncPlanGateway(db, sync), keys);
final routes = RouteRepository(SyncRouteGateway(db, sync), keys);
// Writes what changed while the app was closed, as it opens (D31).
final health = Platform.isAndroid
? (HealthConnect(
@ -367,6 +384,8 @@ Future<TrainPerson> _openPersonData(
profile: profile,
days: days,
plans: plans,
routes: routes,
routing: ServerpodRoutingGateway(client),
maps: MapSourceRepository(ServerpodMapSourceGateway(client)),
recording: recorder != null
? (
@ -384,6 +403,7 @@ Future<TrainPerson> _openPersonData(
await profile.dispose();
await days.dispose();
await plans.dispose();
await routes.dispose();
await sync.dispose();
await db.close();
} finally {

View file

@ -69,6 +69,13 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
Future<bool> isRecording() async =>
await _channel.invokeMethod<bool>('isRecording') ?? false;
@override
Future<({double lat, double lon})?> position() async =>
switch (await _channel.invokeMapMethod<String, double>('position')) {
{'lat': final lat, 'lon': final lon} => (lat: lat, lon: lon),
_ => null,
};
@override
Stream<String> lines() => _lines.receiveBroadcastStream().cast<String>();

View file

@ -124,6 +124,10 @@ abstract interface class RecorderPlatform() {
/// Whether a recording runs, as after the app was gone and came back.
Future<bool> isRecording();
/// Where the phone is, for a route's first waypoint (D44); null without
/// the location permission ([ask] first) or a fix within 15 s.
Future<({double lat, double lon})?> position();
/// Every line of the file written so far, then each new one as it is
/// written, while a recording runs.
Stream<String> lines();

View file

@ -0,0 +1,188 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/routing_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:latlong2/latlong.dart';
/// A route being planned or changed (ADR 0030, D44): waypoints tapped,
/// dragged and the last undone, a new route's first where the phone is
/// (`here`, null where the device can't tell); the instance plans the way
/// through them after each change, an answer to an earlier change dropped
/// once a later one is asked. Kept once named and planned; [id] null for a
/// new one.
class RouteEditorCubit(
final RouteRepository _routes,
final RoutingGateway _routing, {
required final RouteContent route,
final String? id,
final Future<LatLng?> Function()? _here,
}) extends Cubit<RouteEditorState> {
this : super(RouteEditorState(route: route, id: id));
/// Counts the plans asked, so that only the latest's answer counts.
var _asked = 0;
/// Asks whether the instance plans routes, and starts a new route where
/// the phone is.
Future<void> open() async {
final offered = await _routing.offered();
if (isClosed) {
return;
}
emit(state._with(route: state.route, offered: offered));
if (!offered || state.id != null || state.route.waypoints.isNotEmpty) {
return;
}
final here = await _here?.call();
if (here != null && !isClosed && state.route.waypoints.isEmpty) {
emit(state._with(route: _route(waypoints: [here])));
}
}
void rename(String name) => emit(state._with(route: _route(name: name)));
/// Sets what the route is for, and the profile that goes with it.
Future<void> setActivity(Activity? activity) => _replan(
RouteContent(
name: state.route.name,
activity: activity,
profile: RouteProfile.of(activity),
waypoints: state.route.waypoints,
),
);
Future<void> setProfile(RouteProfile profile) =>
_replan(_route(profile: profile));
/// Adds a waypoint at the end.
Future<void> add(LatLng point) =>
_replan(_route(waypoints: [...state.route.waypoints, point]));
/// Moves waypoint [index] to [point] (dragged).
Future<void> move(int index, LatLng point) =>
_replan(_route(waypoints: [...state.route.waypoints]..[index] = point));
/// Takes the last waypoint off.
Future<void> undo() async {
final waypoints = state.route.waypoints;
if (waypoints.isNotEmpty) {
await _replan(
_route(waypoints: waypoints.sublist(0, waypoints.length - 1)),
);
}
}
/// Keeps the route; false where it can't be (no name, no way planned, or
/// this device can't seal).
Future<bool> save() async {
if (!state.canSave) {
return false;
}
final named = _route(name: state.route.name.trim(), keepPlanned: true);
try {
if (state.id case final id?) {
await _routes.update(id, named);
} else {
final id = await _routes.save(named);
if (!isClosed) {
emit(RouteEditorState(route: named, id: id, offered: state.offered));
}
}
} on KeysMissing {
return false;
}
return true;
}
/// [route], its way planned anew where it has two waypoints at least.
Future<void> _replan(RouteContent route) async {
final asked = ++_asked;
if (route.waypoints.length < 2) {
emit(state._with(route: route, planning: false));
return;
}
emit(state._with(route: route, planning: true));
RouteGeometry? planned;
RoutingRefusal? refusal;
try {
planned = await _routing.plan(route.waypoints, route.profile);
} on RoutingRefused catch (refused) {
refusal = refused.reason;
}
if (asked != _asked || isClosed) {
return;
}
emit(
RouteEditorState(
route: RouteContent(
name: state.route.name,
activity: route.activity,
profile: route.profile,
waypoints: route.waypoints,
planned: planned,
),
id: state.id,
offered: state.offered,
refusal: refusal,
),
);
}
/// The route with what is given changed; its way planned dropped unless
/// [keepPlanned] or only the name changes, as it is planned again.
RouteContent _route({
String? name,
RouteProfile? profile,
List<LatLng>? waypoints,
bool keepPlanned = false,
}) {
final route = state.route;
return RouteContent(
name: name ?? route.name,
activity: route.activity,
profile: profile ?? route.profile,
waypoints: waypoints ?? route.waypoints,
planned: keepPlanned || (profile == null && waypoints == null)
? route.planned
: null,
);
}
}
final class const RouteEditorState({
required final RouteContent route,
final String? id,
/// Whether the instance plans routes; null until asked.
final bool? offered,
/// Whether the instance is planning the way after a change.
final bool planning = false,
/// Why the instance planned no way after the last change; null: it did,
/// or nothing was asked.
final RoutingRefusal? refusal,
}) with Equatable {
/// Whether it has what a route needs: a name and a way planned.
bool get canSave =>
route.name.trim().isNotEmpty && route.planned != null && !planning;
/// A refusal stays only until the next change is planned.
RouteEditorState _with({
required RouteContent route,
bool? planning,
bool? offered,
}) => RouteEditorState(
route: route,
id: id,
offered: offered ?? this.offered,
planning: planning ?? this.planning,
refusal: (planning ?? false) || route.waypoints.length < 2 ? null : refusal,
);
@override
List<Object?> get props => [route, id, offered, planning, refusal];
}

View file

@ -0,0 +1,88 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
/// The person's routes as home lists them (ADR 0030, D44): by name, those
/// this device can't open last; read again whenever they change, here or on
/// another device. A deletion leaves the list at once and can be undone for
/// a moment ([DelayedDeletions]).
class RoutesCubit(
final RouteRepository _routes,
final DelayedDeletions _deletions,
) extends Cubit<RoutesState> {
this : super(const RoutesState());
StreamSubscription<void>? _changes;
StreamSubscription<Set<String>>? _pending;
Future<void> open() async {
_changes = _routes.changes.listen((_) => unawaited(_read()));
_pending = _deletions.watchPending().listen((pending) {
if (!isClosed) {
emit(state._with(pending: pending));
}
});
await _read();
}
void delete(String id) => _deletions.delete(id);
void undo(String id) => _deletions.undo(id);
Future<void> _read() async {
final all = await _routes.all();
final opened =
[
for (final route in all)
if (route.content != null) route,
]..sort(
(a, b) => a.content!.name.toLowerCase().compareTo(
b.content!.name.toLowerCase(),
),
);
if (!isClosed) {
emit(
state._with(
all: [
...opened,
for (final route in all)
if (route.content == null) route,
],
),
);
}
}
@override
Future<void> close() async {
await _changes?.cancel();
await _pending?.cancel();
await super.close();
}
}
final class const RoutesState({
final bool loaded = false,
final List<StoredRoute> _all = const [],
final Set<String> _pending = const {},
}) with Equatable {
/// The routes to list: all but those being deleted.
List<StoredRoute> get visible => [
for (final route in _all)
if (!_pending.contains(route.id)) route,
];
RoutesState _with({List<StoredRoute>? all, Set<String>? pending}) =>
RoutesState(
loaded: loaded || all != null,
all: all ?? _all,
pending: pending ?? _pending,
);
@override
List<Object?> get props => [loaded, _all, _pending];
}

View file

@ -0,0 +1,85 @@
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
import 'package:latlong2/latlong.dart';
/// What a route runs on, as its page groups OpenStreetMap's `surface` tags
/// (ADR 0030, D44).
enum SurfaceKind() {
/// Asphalt, concrete, paving stones, setts.
paved,
/// Compacted and fine gravel: firm, if not paved.
compacted,
/// Gravel and pebbles.
gravel,
/// Ground, dirt, grass, sand, wood chips: no surface laid.
natural,
/// Not tagged, or tagged as nothing above.
unknown;
/// The group of the OpenStreetMap `surface` [tag].
static SurfaceKind of(String? tag) => switch (tag) {
'asphalt' ||
'paved' ||
'concrete' ||
'concrete:plates' ||
'concrete:lanes' ||
'paving_stones' ||
'sett' ||
'cobblestone' ||
'unhewn_cobblestone' ||
'chipseal' ||
'metal' ||
'wood' => paved,
'compacted' || 'fine_gravel' => compacted,
'gravel' || 'pebblestone' || 'rock' => gravel,
'unpaved' ||
'ground' ||
'dirt' ||
'earth' ||
'grass' ||
'grass_paver' ||
'mud' ||
'sand' ||
'woodchips' => natural,
_ => unknown,
};
}
/// The route's elevation over its distance, in kilometres; empty where
/// BRouter gave no elevations.
List<ChartPoint> elevationProfile(RouteGeometry route) {
if (route.elevations.length != route.points.length) {
return const [];
}
const distance = Distance();
var metres = 0.0;
return [
for (var i = 0; i < route.points.length; i++)
(
x:
(metres += i == 0
? 0
: distance(route.points[i - 1], route.points[i])) /
1000,
value: route.elevations[i],
),
];
}
/// How many metres of the route run on each kind of surface, the most
/// first; those it doesn't run on left out.
List<(SurfaceKind, int)> surfaceShares(RouteGeometry route) {
final metres = <SurfaceKind, int>{};
for (final stretch in route.stretches) {
final kind = SurfaceKind.of(stretch.surface);
metres[kind] = (metres[kind] ?? 0) + stretch.metres;
}
return [
for (final MapEntry(:key, :value) in metres.entries)
if (value > 0) (key, value),
]..sort((a, b) => b.$2.compareTo(a.$2));
}

View file

@ -0,0 +1,21 @@
import 'package:halkyon_app/halkyon_app.dart';
/// A `route` row as kept: its id and its envelope, sealed (ADR 0030, D7).
typedef RouteRow = ({String id, Envelope envelope});
/// Where the person's routes are kept: their synced `route` rows
/// (D44). The repository seals and opens; this only keeps.
abstract interface class RouteGateway() {
Future<List<RouteRow>> routes();
Future<void> insert(RouteRow route);
/// Replaces the route [id]'s envelope (an edit); nothing for one gone.
Future<void> update(String id, Envelope envelope);
/// Deletes the route [id], its payload blanked first (ADR 0002, rule 2).
Future<void> delete(String id);
/// Fires when a sync brought changes from the person's other devices.
Stream<void> get remoteChanges;
}

View file

@ -0,0 +1,163 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:latlong2/latlong.dart';
/// The schema of a route's payload this app writes and reads.
const routeSchemaVersion = 1;
/// [content] as its row's payload, before sealing (ADR 0030, D44): JSON,
/// the points' latitudes and longitudes in micro-degrees and their
/// elevations in decimetres, each as differences from the one before, and
/// gzipped, since a planned route has thousands of points and sealed bytes
/// compress no more.
ByteData encodeRoute(RouteContent content) {
final json = {
'schemaVersion': routeSchemaVersion,
'name': content.name,
'activity': ?content.activity?.name,
'profile': content.profile.name,
'waypoints': _pointsJson(content.waypoints),
if (content.planned case final planned?)
'planned': {
..._pointsJson(planned.points),
'ele': _differences([
for (final metres in planned.elevations) (metres * 10).round(),
]),
'distance': planned.distanceMetres,
'ascent': planned.ascentMetres,
'descent': planned.descentMetres,
'hints': [
for (final hint in planned.hints)
{
'index': hint.index,
'kind': hint.kind.name,
if (hint.exit != 0) 'exit': hint.exit,
'metres': hint.metres,
if (hint.angle != 0) 'angle': hint.angle,
},
],
'stretches': [
for (final stretch in planned.stretches)
{
'metres': stretch.metres,
'surface': ?stretch.surface,
'highway': ?stretch.highway,
},
],
},
};
return ByteData.sublistView(
Uint8List.fromList(
GZipCodec(level: 9).encode(utf8.encode(jsonEncode(json))),
),
);
}
/// The route [payload] holds, opened; null for a blank one (a row being
/// deleted), one of a newer schema, or one it can't read. A profile it
/// doesn't know is the activity's; a turn it doesn't know is left out.
RouteContent? decodeRoute(ByteData payload) {
if (payload.lengthInBytes == 0) {
return null;
}
try {
final json = jsonDecode(
utf8.decode(GZipCodec().decode(Uint8List.sublistView(payload))),
) as Map<String, Object?>;
final version = json['schemaVersion']! as int;
if (version > routeSchemaVersion) {
log('A route of schema $version skipped', name: 'Routes');
return null;
}
final activity = Activity.values.asNameMap()[json['activity']];
return RouteContent(
name: json['name']! as String,
activity: activity,
profile:
RouteProfile.values.asNameMap()[json['profile']] ??
RouteProfile.of(activity),
waypoints: _pointsOf(json['waypoints']! as Map<String, Object?>),
planned: switch (json['planned']) {
final Map<String, Object?> planned => _plannedOf(planned),
_ => null,
},
);
} on Object catch (error) {
log('A route not read: ${error.runtimeType}', name: 'Routes');
return null;
}
}
RouteGeometry _plannedOf(Map<String, Object?> json) => RouteGeometry(
points: _pointsOf(json),
elevations: [
for (final decimetres in _sums((json['ele']! as List).cast<int>()))
decimetres / 10,
],
distanceMetres: json['distance']! as int,
ascentMetres: json['ascent'] as int? ?? 0,
descentMetres: json['descent'] as int? ?? 0,
hints: [
for (final hint in json['hints'] as List<Object?>? ?? const [])
if (hint
case final Map<String, Object?> json &&
{
'index': final int index,
'kind': final String name,
'metres': final int metres,
})
if (TurnKind.values.asNameMap()[name] case final kind?)
TurnHint(
index: index,
kind: kind,
exit: json['exit'] as int? ?? 0,
metres: metres,
angle: json['angle'] as int? ?? 0,
),
],
stretches: [
for (final stretch in json['stretches'] as List<Object?>? ?? const [])
if (stretch
case final Map<String, Object?> json && {'metres': final int metres})
RouteStretch(
metres: metres,
surface: json['surface'] as String?,
highway: json['highway'] as String?,
),
],
);
Map<String, Object?> _pointsJson(List<LatLng> points) => {
'lat': _differences([
for (final point in points) (point.latitude * 1e6).round(),
]),
'lon': _differences([
for (final point in points) (point.longitude * 1e6).round(),
]),
};
List<LatLng> _pointsOf(Map<String, Object?> json) {
final lats = _sums((json['lat']! as List).cast<int>());
final lons = _sums((json['lon']! as List).cast<int>());
return [
for (var i = 0; i < lats.length && i < lons.length; i++)
LatLng(lats[i] / 1e6, lons[i] / 1e6),
];
}
/// Each value as its difference from the one before; the first as it is.
List<int> _differences(List<int> values) => [
for (var i = 0; i < values.length; i++)
values[i] - (i == 0 ? 0 : values[i - 1]),
];
/// [_differences] undone.
List<int> _sums(List<int> differences) {
var last = 0;
return [for (final difference in differences) last += difference];
}

View file

@ -0,0 +1,82 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show Uuid;
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_payload.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
/// The person's routes (ADR 0030, D44), sealed with their key for
/// their row as workouts are (D7); a readable row is one the instance made
/// up, and shows locked.
class RouteRepository(final RouteGateway _gateway, final TrainKeys _keys) {
StreamSubscription<void>? _remote;
/// This device's changes, and those a sync brings while anyone listens.
late final StreamController<void> _changes = StreamController.broadcast(
onListen: () => _remote = _gateway.remoteChanges.listen(_changes.add),
onCancel: () => _remote?.cancel(),
);
late final _envelope = PayloadEnvelope(_keys.cipher);
/// Fires when the routes changed, here or on the person's other devices.
Stream<void> get changes => _changes.stream;
/// Every route, in the order they were made; one that doesn't open here
/// with its content null.
Future<List<StoredRoute>> all() async {
await _keys.refresh();
final rows = await _gateway.routes()
..sort((a, b) => a.id.compareTo(b.id));
return [
for (final row in rows)
StoredRoute(row.id, switch (_open(row.envelope, row.id)) {
final payload? => decodeRoute(payload),
null => null,
}),
];
}
/// Keeps a new route and returns its id; throws [KeysMissing] where this
/// device can't seal.
Future<String> save(RouteContent content) async {
final id = const Uuid().v7();
await _gateway.insert((id: id, envelope: await _sealed(id, content)));
_changes.add(null);
return id;
}
Future<void> update(String id, RouteContent content) async {
await _gateway.update(id, await _sealed(id, content));
_changes.add(null);
}
Future<void> delete(String id) async {
await _gateway.delete(id);
_changes.add(null);
}
/// Closes what it holds open.
Future<void> dispose() => _changes.close();
Future<Envelope> _sealed(String id, RouteContent content) async =>
_envelope.seal(
encodeRoute(content),
keyId: await _keys.keyToWrite(),
table: 'route',
id: id,
);
ByteData? _open(Envelope envelope, String id) {
if (envelope.keyId == null) {
return null;
}
return switch (_envelope.open(envelope, table: 'route', id: id)) {
Readable(:final payload) => payload,
Locked() => null,
};
}
}

View file

@ -0,0 +1,134 @@
import 'dart:developer';
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:latlong2/latlong.dart';
/// Why the instance planned no route (ADR 0030, D44).
enum RoutingRefusal() {
/// The instance plans none: no BRouter beside it.
off,
/// Too many waypoints, or one outside the world.
invalid,
/// More plans a minute than the instance allows.
tooOften,
/// No way between the waypoints: one off the map's ways, or outside the
/// region the instance's BRouter covers.
noRoute,
/// The instance, or its BRouter, can't be reached now.
unreachable,
}
/// The instance refused to plan, or couldn't.
final class const RoutingRefused(final RoutingRefusal reason)
implements Exception;
/// Plans a route through the instance's BRouter (D11, D44).
abstract interface class RoutingGateway() {
/// Whether the instance plans routes; false where it can't be asked.
Future<bool> offered();
/// The way through [waypoints] for [profile]; [RoutingRefused] where there
/// is none.
Future<RouteGeometry> plan(List<LatLng> waypoints, RouteProfile profile);
}
/// [RoutingGateway] on Train's server (`routing`).
class ServerpodRoutingGateway(final protocol.Client _client)
implements RoutingGateway {
@override
Future<bool> offered() async {
try {
return await _client.routing.offered();
} on Object catch (error) {
log('Routing not asked: ${error.runtimeType}', name: 'Routes');
return false;
}
}
@override
Future<RouteGeometry> plan(
List<LatLng> waypoints,
RouteProfile profile,
) async {
try {
return routeGeometryOf(
await _client.routing.plan(
[
for (final point in waypoints)
protocol.RoutePoint(lat: point.latitude, lon: point.longitude),
],
protocol.RouteProfile.values.byName(profile.name),
),
);
} on protocol.RouteRefused catch (refused) {
throw RoutingRefused(
RoutingRefusal.values.asNameMap()[refused.reason] ??
RoutingRefusal.unreachable,
);
} on Exception catch (error) {
log('No route planned: ${error.runtimeType}', name: 'Routes');
throw const RoutingRefused(RoutingRefusal.unreachable);
}
}
}
/// [planned] as the app keeps it, its turns by BRouter's commands (timode
/// 2); a turn of a command it doesn't know left out.
RouteGeometry routeGeometryOf(protocol.PlannedRoute planned) => RouteGeometry(
points: [for (final point in planned.points) LatLng(point.lat, point.lon)],
elevations: planned.elevations,
distanceMetres: planned.distanceMetres,
ascentMetres: planned.ascentMetres,
descentMetres: planned.descentMetres,
hints: [
for (final hint in planned.hints)
if (turnKindOf(hint.command) case final kind?)
TurnHint(
index: hint.index,
kind: kind,
exit: hint.exit,
metres: hint.metres,
angle: hint.angle,
),
],
stretches: [
for (final stretch in planned.stretches)
RouteStretch(
metres: stretch.metres,
surface: stretch.surface,
highway: stretch.highway,
),
],
);
/// BRouter's voice hint [command] as a turn: 1 straight on, 2 left, 3
/// slightly, 4 sharply left, 5 right, 6 slightly, 7 sharply right, 8 and 9
/// keep left and right, 10 and 11 U-turns left and right, 12 off route, 13
/// a roundabout, 14 one turning left, 15 a U-turn, 16 a beeline, 17 and 18
/// exits left and right.
TurnKind? turnKindOf(int command) => switch (command) {
1 => TurnKind.straight,
2 => TurnKind.left,
3 => TurnKind.slightLeft,
4 => TurnKind.sharpLeft,
5 => TurnKind.right,
6 => TurnKind.slightRight,
7 => TurnKind.sharpRight,
8 => TurnKind.keepLeft,
9 => TurnKind.keepRight,
10 => TurnKind.uTurnLeft,
11 => TurnKind.uTurnRight,
12 => TurnKind.offRoute,
13 => TurnKind.roundabout,
14 => TurnKind.roundaboutLeft,
15 => TurnKind.uTurn,
16 => TurnKind.beeline,
17 => TurnKind.exitLeft,
18 => TurnKind.exitRight,
_ => null,
};

View file

@ -0,0 +1,99 @@
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
import 'package:halkyon_train_flutter/src/feature/route/data/route_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// [RouteGateway] on the person's local database, which the sync engine
/// keeps in step with their other devices.
class SyncRouteGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
) implements RouteGateway {
@override
Future<List<RouteRow>> routes() async => [
for (final route in await protocol.SavedRoute.db.find(
_db,
orderBy: (t) => t.id,
))
(
id: route.id!.uuid,
envelope: (
keyId: route.keyId,
nonce: route.nonce,
payload: route.payload,
),
),
];
@override
Future<void> insert(RouteRow route) async {
await protocol.SavedRoute.db.insertRow(
_db,
protocol.SavedRoute(
id: protocol.UuidValue.withValidation(route.id),
keyId: route.envelope.keyId,
nonce: route.envelope.nonce,
payload: route.envelope.payload,
),
);
_sync.requestSync();
}
@override
Future<void> update(String id, Envelope envelope) async {
if (await protocol.SavedRoute.db.findById(
_db,
protocol.UuidValue.withValidation(id),
)
case final route?) {
// Only the named columns change (ADR 0001, rule 1).
await protocol.SavedRoute.db.updateRow(
_db,
route.copyWith(
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
_sync.requestSync();
}
}
@override
Future<void> delete(String id) async {
final uuid = protocol.UuidValue.withValidation(id);
await _db.db.transaction((transaction) async {
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
// they last were, until the space goes.
if (await protocol.SavedRoute.db.findById(
_db,
uuid,
transaction: transaction,
)
case final route?) {
await protocol.SavedRoute.db.updateRow(
_db,
protocol.SavedRoute(
id: route.id,
spaceId: route.spaceId,
payload: ByteData(0),
),
columns: (t) => [t.keyId, t.nonce, t.payload],
transaction: transaction,
);
}
await protocol.SavedRoute.db.deleteWhere(
_db,
where: (t) => t.id.equals(uuid),
transaction: transaction,
);
});
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
}

View file

@ -0,0 +1,115 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:latlong2/latlong.dart';
/// What a route is planned for (ADR 0030, D44): the instance's BRouter
/// profile each names.
enum RouteProfile() {
/// Train's own: paths, parks and quiet roads first, any surface.
run,
hike,
roadBike,
trekkingBike,
gravel,
mtb;
/// The profile a route for [activity] is planned with unless the person
/// picks another.
static RouteProfile of(Activity? activity) => switch (activity) {
Activity.hiking => hike,
Activity.cycling => trekkingBike,
_ => run,
};
}
/// What a turn is, as BRouter tells it (its voice hints, D45).
enum TurnKind() {
straight,
left,
slightLeft,
sharpLeft,
right,
slightRight,
sharpRight,
keepLeft,
keepRight,
uTurnLeft,
uTurnRight,
uTurn,
roundabout,
roundaboutLeft,
exitLeft,
exitRight,
offRoute,
beeline,
}
/// A turn at the route's point [index]: what it is, the roundabout's exit
/// (0 for none), how far it is to the next one and the angle turned.
final class const TurnHint({
required final int index,
required final TurnKind kind,
final int exit = 0,
required final int metres,
final int angle = 0,
}) with Equatable {
@override
List<Object?> get props => [index, kind, exit, metres, angle];
}
/// A stretch of the route on one surface and one kind of way, as
/// OpenStreetMap tags them (`surface`, `highway`); null where untagged.
final class const RouteStretch({
required final int metres,
final String? surface,
final String? highway,
}) with Equatable {
@override
List<Object?> get props => [metres, surface, highway];
}
/// The way BRouter planned through a route's waypoints: its points and
/// their elevations (as many, empty where it had none), its distance, climb
/// and descent, its turns and its stretches.
final class const RouteGeometry({
required final List<LatLng> points,
final List<double> elevations = const [],
required final int distanceMetres,
final int ascentMetres = 0,
final int descentMetres = 0,
final List<TurnHint> hints = const [],
final List<RouteStretch> stretches = const [],
}) with Equatable {
@override
List<Object?> get props => [
points,
elevations,
distanceMetres,
ascentMetres,
descentMetres,
hints,
stretches,
];
}
/// A route as the person keeps it (D44): its name, what it is for, the
/// profile it is planned with, the waypoints tapped and the way planned
/// through them ([planned] null until two waypoints are).
final class const RouteContent({
required final String name,
final Activity? activity,
required final RouteProfile profile,
required final List<LatLng> waypoints,
final RouteGeometry? planned,
}) with Equatable {
@override
List<Object?> get props => [name, activity, profile, waypoints, planned];
}
/// A route as the person's devices keep it: its id and what it is, or
/// [content] null where it doesn't open here: shown as locked, never half.
final class const StoredRoute(final String id, final RouteContent? content)
with Equatable {
@override
List<Object?> get props => [id, content];
}

View file

@ -0,0 +1,526 @@
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/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_train_flutter/src/feature/route/bloc/route_editor_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/route/bloc/routes_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_analysis.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_texts.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart'
show ChartPoint;
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_chart.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:latlong2/latlong.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// What a route is planned for: those on foot or by bike.
const routeActivities = [
Activity.running,
Activity.walking,
Activity.hiking,
Activity.cycling,
];
/// Opens [route] to be changed, or planned where [id] is null; a new one
/// starts where the phone is, where the device can tell (D44).
Future<void> openRouteScreen(
BuildContext context,
RouteContent route, {
String? id,
}) {
final train = PersonScope.of<TrainPerson>(context);
final routes = context.read<RoutesCubit>();
final platform = train.recording?.platform;
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => MultiBlocProvider(
providers: [
BlocProvider.value(value: routes),
BlocProvider(
create: (context) => RouteEditorCubit(
train.routes,
train.routing,
route: route,
id: id,
here: platform == null ? null : () => _here(platform),
)..open(),
),
],
child: RouteScreen(maps: train.maps.source()),
),
),
);
}
/// Where the phone is, the location permission asked for first.
Future<LatLng?> _here(RecorderPlatform platform) async {
if (!(await platform.ask({
RecorderPermission.location,
})).contains(RecorderPermission.location)) {
return null;
}
return switch (await platform.position()) {
(:final lat, :final lon) => LatLng(lat, lon),
null => null,
};
}
/// A route being planned or changed (ADR 0030, D44): the map, tapped for
/// waypoints, a waypoint held and dragged to move it, the last undone;
/// under it the route's name, what it is for and its profile, and the way
/// planned: its distance, climb and descent, its elevation and its
/// surfaces. Saved with the button, deleted after asking.
class const RouteScreen({required final Future<MapSource?> maps, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return BlocBuilder<RouteEditorCubit, RouteEditorState>(
buildWhen: (before, now) =>
before.id != now.id || before.canSave != now.canSave,
builder: (context, state) => Scaffold(
appBar: AppBar(
title: Text(state.id == null ? l10n.routeNewTitle : l10n.routeTitle),
actions: [
if (state.id != null)
IconButton(
tooltip: l10n.routeDelete,
icon: const Icon(LucideIcons.trash2),
onPressed: () => _delete(context),
),
TextButton(
onPressed: state.canSave ? () => _save(context) : null,
child: Text(l10n.routeSave),
),
],
),
body: SafeArea(
child: Column(
children: [
Expanded(
flex: 3,
child: FutureBuilder(
future: maps,
builder: (context, snapshot) => switch (snapshot) {
AsyncSnapshot(:final MapSource data) => _RouteMap(
source: data,
),
AsyncSnapshot(connectionState: ConnectionState.done) =>
Center(child: Text(l10n.workoutMapUnavailable)),
_ => const Center(child: AppLoadingIndicator()),
},
),
),
const Expanded(flex: 2, child: _Details()),
],
),
),
),
);
}
Future<void> _save(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
if (await context.read<RouteEditorCubit>().save()) {
navigator.pop();
} else {
messenger.showSnackBar(SnackBar(content: Text(l10n.routeNotSaved)));
}
}
Future<void> _delete(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final routes = context.read<RoutesCubit>();
final id = context.read<RouteEditorCubit>().state.id!;
final navigator = Navigator.of(context);
if (!await confirmDestructiveAction(
context,
title: l10n.routeDeleteTitle,
message: l10n.routeDeleteMessage,
confirmLabel: l10n.routeDelete,
)) {
return;
}
routes.delete(id);
navigator.pop();
if (context.mounted) {
showUndoSnackBar(
context,
message: l10n.routeDeleted,
onUndo: () => routes.undo(id),
);
}
}
}
/// The map: the way planned as a line (straight lines between the
/// waypoints until it is), the waypoints numbered, the first green and the
/// last red; a tap adds one, one held and dragged moves it, the button
/// above takes the last off. The camera fits the route as it opens, and
/// goes to a new route's first waypoint once it is known.
class const _RouteMap({required final MapSource source})
extends StatefulWidget {
@override
State<_RouteMap> createState() => _RouteMapState();
}
class _RouteMapState() extends State<_RouteMap> {
final _map = MapController();
final _area = GlobalKey();
/// The waypoint being dragged and where it is now.
(int, LatLng)? _dragged;
/// What the camera fits as the map opens: the route as it was opened.
late final List<LatLng> _fit;
@override
void initState() {
super.initState();
final route = context.read<RouteEditorCubit>().state.route;
_fit = [...?route.planned?.points, ...route.waypoints];
}
@override
void dispose() {
_map.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final train = PersonScope.of<TrainPerson>(context);
RouteEditorCubit cubit() => context.read<RouteEditorCubit>();
return BlocConsumer<RouteEditorCubit, RouteEditorState>(
listenWhen: (before, now) =>
before.route.waypoints.isEmpty && now.route.waypoints.length == 1,
listener: (context, state) {
// A new route's first waypoint, from the phone's position or a tap
// on the whole of Europe: closer.
if (_map.camera.zoom < 10) {
_map.move(state.route.waypoints.single, 14);
}
},
builder: (context, state) {
final route = state.route;
final waypoints = [
for (final (index, point) in route.waypoints.indexed)
if (_dragged case (final dragged, final at) when dragged == index)
at
else
point,
];
return Stack(
key: _area,
children: [
FlutterMap(
mapController: _map,
options: MapOptions(
initialCameraFit: _fit.length > 1
? CameraFit.coordinates(
coordinates: _fit,
padding: const EdgeInsets.all(48),
maxZoom: 16,
)
: null,
initialCenter: _fit.firstOrNull ?? const LatLng(50, 10),
initialZoom: _fit.isEmpty ? 4 : 14,
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
),
onTap: (_, point) => cubit().add(point),
),
children: [
train.mapTiles(widget.source),
PolylineLayer(
polylines: [
if (route.planned case final planned? when _dragged == null)
Polyline(
points: planned.points,
// Cobalt in either theme: the map's colours are the
// source's, not the app's.
color: PaletteColor.cobalt.background,
strokeWidth: 5,
)
else if (waypoints.length > 1)
Polyline(
points: waypoints,
color: PaletteColor.cobalt.background.withValues(
alpha: 0.5,
),
strokeWidth: 3,
),
],
),
MarkerLayer(
markers: [
for (final (index, point) in waypoints.indexed)
_waypoint(
index,
point,
last: index == waypoints.length - 1,
),
],
),
RichAttributionWidget(
attributions: [
TextSourceAttribution(widget.source.attribution),
],
),
],
),
if (state.planning)
const Positioned(
top: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(),
),
Positioned(
top: 8,
right: 8,
child: IconButton.filledTonal(
tooltip: l10n.routeUndo,
icon: const Icon(LucideIcons.undo2),
onPressed: route.waypoints.isEmpty
? null
: () => cubit().undo(),
),
),
if (route.waypoints.length < 2 && state.offered != false)
Positioned(
left: 16,
right: 64,
top: 12,
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(l10n.routeTapHint),
),
),
),
],
);
},
);
}
Marker _waypoint(int index, LatLng point, {required bool last}) {
final color = switch (index) {
0 => PaletteColor.forest.background,
_ when last => PaletteColor.cerise.background,
_ => PaletteColor.cobalt.background,
};
return Marker(
point: point,
width: 28,
height: 28,
child: Builder(
builder: (context) => GestureDetector(
// Held first, so that dragging the map never moves a waypoint.
onLongPressMoveUpdate: (details) {
final area = _area.currentContext?.findRenderObject();
if (area is! RenderBox) {
return;
}
final at = MapCamera.of(
context,
).screenOffsetToLatLng(area.globalToLocal(details.globalPosition));
setState(() => _dragged = (index, at));
},
onLongPressEnd: (_) {
final dragged = _dragged;
setState(() => _dragged = null);
if (dragged != null) {
context.read<RouteEditorCubit>().move(dragged.$1, dragged.$2);
}
},
child: DecoratedBox(
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: Center(
child: Text(
'${index + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
),
),
),
);
}
}
/// Under the map: why no way was planned, the way's figures, the route's
/// name, activity and profile, its elevation and its surfaces.
class const _Details() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final theme = Theme.of(context);
RouteEditorCubit cubit() => context.read<RouteEditorCubit>();
return BlocBuilder<RouteEditorCubit, RouteEditorState>(
builder: (context, state) {
final route = state.route;
final planned = route.planned;
final why = state.offered == false
? l10n.routeNotOffered
: switch (state.refusal) {
final refusal? => refusalText(l10n, refusal),
null => null,
};
final elevation = planned == null
? const <ChartPoint>[]
: elevationProfile(planned);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
if (why != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
why,
style: TextStyle(color: theme.colorScheme.error),
),
),
if (planned != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Wrap(
spacing: 24,
runSpacing: 8,
children: [
Text(
distanceText(l10n, locale, planned.distanceMetres),
style: theme.textTheme.titleLarge,
),
Text(
l10n.routeAscent(planned.ascentMetres),
style: theme.textTheme.titleLarge,
),
Text(
l10n.routeDescent(planned.descentMetres),
style: theme.textTheme.titleLarge,
),
],
),
),
TextFormField(
initialValue: route.name,
decoration: InputDecoration(labelText: l10n.routeName),
textCapitalization: TextCapitalization.sentences,
onChanged: (name) => cubit().rename(name),
),
const SizedBox(height: 16),
DropdownButtonFormField<Activity?>(
initialValue: route.activity,
decoration: InputDecoration(labelText: l10n.routeActivity),
items: [
DropdownMenuItem(child: Text(l10n.routeActivityAny)),
for (final activity in routeActivities)
DropdownMenuItem(
value: activity,
child: Text(activityName(l10n, activity)),
),
],
onChanged: (activity) => cubit().setActivity(activity),
),
const SizedBox(height: 16),
// Keyed by the profile, as the activity changes it.
DropdownButtonFormField<RouteProfile>(
key: ValueKey(route.profile),
initialValue: route.profile,
decoration: InputDecoration(labelText: l10n.routeProfile),
items: [
for (final profile in RouteProfile.values)
DropdownMenuItem(
value: profile,
child: Text(profileName(l10n, profile)),
),
],
onChanged: (profile) {
if (profile != null) {
cubit().setProfile(profile);
}
},
),
if (elevation.length > 1) ...[
const SizedBox(height: 24),
WorkoutChart(
title: l10n.workoutElevationChart,
points: elevation,
label: (metres) => l10n.workoutClimbValue(metres.round()),
),
],
if (planned != null && planned.stretches.isNotEmpty)
_Surfaces(planned),
],
),
),
);
},
);
}
}
/// How much of the way runs on each kind of surface, the most first.
class const _Surfaces(final RouteGeometry _route) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final theme = Theme.of(context);
final shares = surfaceShares(_route);
final total = shares.fold(0, (sum, share) => sum + share.$2);
return Padding(
padding: const EdgeInsets.only(top: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.routeSurfaces, style: theme.textTheme.titleMedium),
for (final (kind, metres) in shares)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(child: Text(surfaceName(l10n, kind))),
Text(distanceText(l10n, locale, metres)),
],
),
const SizedBox(height: 4),
LinearProgressIndicator(value: metres / total),
],
),
),
],
),
);
}
}

View file

@ -0,0 +1,46 @@
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_analysis.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/routing_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_texts.dart';
/// A profile, as the app names it.
String profileName(AppLocalizations l10n, RouteProfile profile) =>
switch (profile) {
RouteProfile.run => l10n.routeProfileRun,
RouteProfile.hike => l10n.routeProfileHike,
RouteProfile.roadBike => l10n.routeProfileRoadBike,
RouteProfile.trekkingBike => l10n.routeProfileTrekkingBike,
RouteProfile.gravel => l10n.routeProfileGravel,
RouteProfile.mtb => l10n.routeProfileMtb,
};
/// A kind of surface, as the app names it.
String surfaceName(AppLocalizations l10n, SurfaceKind kind) => switch (kind) {
SurfaceKind.paved => l10n.surfacePaved,
SurfaceKind.compacted => l10n.surfaceCompacted,
SurfaceKind.gravel => l10n.surfaceGravel,
SurfaceKind.natural => l10n.surfaceNatural,
SurfaceKind.unknown => l10n.surfaceUnknown,
};
/// Why no way was planned, as the planner says it.
String refusalText(AppLocalizations l10n, RoutingRefusal refusal) =>
switch (refusal) {
RoutingRefusal.off => l10n.routeNotOffered,
RoutingRefusal.invalid => l10n.routeRefusedInvalid,
RoutingRefusal.tooOften => l10n.routeRefusedTooOften,
RoutingRefusal.noRoute => l10n.routeRefusedNoRoute,
RoutingRefusal.unreachable => l10n.routeRefusedUnreachable,
};
/// A route in the list: its distance and climb, or its profile while it has
/// no way planned.
String routeSummary(AppLocalizations l10n, String locale, RouteContent route) =>
switch (route.planned) {
final planned? => l10n.routeSummary(
distanceText(l10n, locale, planned.distanceMetres),
l10n.routeAscent(planned.ascentMetres),
),
null => profileName(l10n, route.profile),
};

Some files were not shown because too many files have changed in this diff Show more