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:
parent
a8f7c26e69
commit
aea99c68ca
22 changed files with 24035 additions and 12 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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!),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue