feat(train): a recording follows a route, its turns told (ADR 0030, D45)
The Record sheet offers the routes with a way planned; the recorder's service follows the one chosen offline (RouteGuide.kt): turns told 50 m before them (100 m on a bike) after a tone, off the route past 40 m for 10 s a cue with the way back, told again on rejoining, the end told. The recording file keeps the route and the guidance; the recording screen shows the next turn or the way back, and swipes to a map of the route, the track and the position, north or the heading up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
aea99c68ca
commit
b0c0eeb3d5
16 changed files with 2153 additions and 171 deletions
|
|
@ -416,5 +416,40 @@
|
|||
"routeDelete": "Löschen",
|
||||
"routeDeleteTitle": "Diese Route löschen?",
|
||||
"routeDeleteMessage": "Sie wird auf all deinen Geräten gelöscht.",
|
||||
"routeDeleted": "Route gelöscht"
|
||||
"routeDeleted": "Route gelöscht",
|
||||
"turnStraight": "Geradeaus",
|
||||
"turnLeft": "Links abbiegen",
|
||||
"turnSlightLeft": "Leicht links",
|
||||
"turnSharpLeft": "Scharf links",
|
||||
"turnRight": "Rechts abbiegen",
|
||||
"turnSlightRight": "Leicht rechts",
|
||||
"turnSharpRight": "Scharf rechts",
|
||||
"turnKeepLeft": "Links halten",
|
||||
"turnKeepRight": "Rechts halten",
|
||||
"turnAround": "Wenden",
|
||||
"turnRoundabout": "Im Kreisverkehr die {exit}. Ausfahrt nehmen",
|
||||
"turnExitLeft": "Die Ausfahrt links nehmen",
|
||||
"turnExitRight": "Die Ausfahrt rechts nehmen",
|
||||
"routeSayIn": "In {metres} Metern: {turn}",
|
||||
"routeSayOff": "Abseits der Route. Sie liegt {metres} Meter {direction}.",
|
||||
"routeSayOn": "Zurück auf der Route",
|
||||
"routeSayArrived": "Ende der Route",
|
||||
"directionNorth": "nördlich",
|
||||
"directionNorthEast": "nordöstlich",
|
||||
"directionEast": "östlich",
|
||||
"directionSouthEast": "südöstlich",
|
||||
"directionSouth": "südlich",
|
||||
"directionSouthWest": "südwestlich",
|
||||
"directionWest": "westlich",
|
||||
"directionNorthWest": "nordwestlich",
|
||||
"recordRoute": "Route",
|
||||
"recordNoRoute": "Keine",
|
||||
"recordingNextTurn": "In {distance}",
|
||||
"recordingOffRoute": "Abseits der Route: {distance} {direction}",
|
||||
"recordingRouteEnd": "Ende der Route",
|
||||
"recordingFollowRoute": "Route: {name}",
|
||||
"recordingMapHint": "Wischen für die Karte",
|
||||
"recordingFiguresHint": "Zurückwischen für die Werte",
|
||||
"recordingNorthUp": "Norden oben",
|
||||
"recordingHeadingUp": "Bewegungsrichtung oben"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -524,5 +524,46 @@
|
|||
"routeDelete": "Delete",
|
||||
"routeDeleteTitle": "Delete this route?",
|
||||
"routeDeleteMessage": "It's deleted on all your devices.",
|
||||
"routeDeleted": "Route deleted"
|
||||
"routeDeleted": "Route deleted",
|
||||
"turnStraight": "Go straight on",
|
||||
"turnLeft": "Turn left",
|
||||
"turnSlightLeft": "Bear left",
|
||||
"turnSharpLeft": "Turn sharp left",
|
||||
"turnRight": "Turn right",
|
||||
"turnSlightRight": "Bear right",
|
||||
"turnSharpRight": "Turn sharp right",
|
||||
"turnKeepLeft": "Keep left",
|
||||
"turnKeepRight": "Keep right",
|
||||
"turnAround": "Turn around",
|
||||
"turnRoundabout": "At the roundabout, take exit {exit}",
|
||||
"@turnRoundabout": {"placeholders": {"exit": {"type": "int"}}},
|
||||
"turnExitLeft": "Take the exit on the left",
|
||||
"turnExitRight": "Take the exit on the right",
|
||||
"routeSayIn": "In {metres} metres: {turn}",
|
||||
"@routeSayIn": {"description": "Spoken before a turn while following a route.", "placeholders": {"metres": {"type": "int"}, "turn": {"type": "String", "example": "Turn left"}}},
|
||||
"routeSayOff": "Off the route. It lies {metres} metres {direction}.",
|
||||
"@routeSayOff": {"description": "Spoken off the route; the recorder puts the figures in.", "placeholders": {"metres": {"type": "String", "example": "80"}, "direction": {"type": "String", "example": "to the north-east"}}},
|
||||
"routeSayOn": "Back on the route",
|
||||
"routeSayArrived": "End of the route",
|
||||
"directionNorth": "to the north",
|
||||
"directionNorthEast": "to the north-east",
|
||||
"directionEast": "to the east",
|
||||
"directionSouthEast": "to the south-east",
|
||||
"directionSouth": "to the south",
|
||||
"directionSouthWest": "to the south-west",
|
||||
"directionWest": "to the west",
|
||||
"directionNorthWest": "to the north-west",
|
||||
"recordRoute": "Route",
|
||||
"recordNoRoute": "None",
|
||||
"recordingNextTurn": "In {distance}",
|
||||
"@recordingNextTurn": {"placeholders": {"distance": {"type": "String", "example": "320 m"}}},
|
||||
"recordingOffRoute": "Off the route: {distance} {direction}",
|
||||
"@recordingOffRoute": {"placeholders": {"distance": {"type": "String", "example": "80 m"}, "direction": {"type": "String", "example": "to the north-east"}}},
|
||||
"recordingRouteEnd": "End of the route",
|
||||
"recordingFollowRoute": "Following {name}",
|
||||
"@recordingFollowRoute": {"placeholders": {"name": {"type": "String"}}},
|
||||
"recordingMapHint": "Swipe for the map",
|
||||
"recordingFiguresHint": "Swipe back for the figures",
|
||||
"recordingNorthUp": "North up",
|
||||
"recordingHeadingUp": "Heading up"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2523,6 +2523,216 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Route deleted'**
|
||||
String get routeDeleted;
|
||||
|
||||
/// No description provided for @turnStraight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Go straight on'**
|
||||
String get turnStraight;
|
||||
|
||||
/// No description provided for @turnLeft.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Turn left'**
|
||||
String get turnLeft;
|
||||
|
||||
/// No description provided for @turnSlightLeft.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bear left'**
|
||||
String get turnSlightLeft;
|
||||
|
||||
/// No description provided for @turnSharpLeft.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Turn sharp left'**
|
||||
String get turnSharpLeft;
|
||||
|
||||
/// No description provided for @turnRight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Turn right'**
|
||||
String get turnRight;
|
||||
|
||||
/// No description provided for @turnSlightRight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bear right'**
|
||||
String get turnSlightRight;
|
||||
|
||||
/// No description provided for @turnSharpRight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Turn sharp right'**
|
||||
String get turnSharpRight;
|
||||
|
||||
/// No description provided for @turnKeepLeft.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Keep left'**
|
||||
String get turnKeepLeft;
|
||||
|
||||
/// No description provided for @turnKeepRight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Keep right'**
|
||||
String get turnKeepRight;
|
||||
|
||||
/// No description provided for @turnAround.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Turn around'**
|
||||
String get turnAround;
|
||||
|
||||
/// No description provided for @turnRoundabout.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'At the roundabout, take exit {exit}'**
|
||||
String turnRoundabout(int exit);
|
||||
|
||||
/// No description provided for @turnExitLeft.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Take the exit on the left'**
|
||||
String get turnExitLeft;
|
||||
|
||||
/// No description provided for @turnExitRight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Take the exit on the right'**
|
||||
String get turnExitRight;
|
||||
|
||||
/// Spoken before a turn while following a route.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'In {metres} metres: {turn}'**
|
||||
String routeSayIn(int metres, String turn);
|
||||
|
||||
/// Spoken off the route; the recorder puts the figures in.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Off the route. It lies {metres} metres {direction}.'**
|
||||
String routeSayOff(String metres, String direction);
|
||||
|
||||
/// No description provided for @routeSayOn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Back on the route'**
|
||||
String get routeSayOn;
|
||||
|
||||
/// No description provided for @routeSayArrived.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'End of the route'**
|
||||
String get routeSayArrived;
|
||||
|
||||
/// No description provided for @directionNorth.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'to the north'**
|
||||
String get directionNorth;
|
||||
|
||||
/// No description provided for @directionNorthEast.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'to the north-east'**
|
||||
String get directionNorthEast;
|
||||
|
||||
/// No description provided for @directionEast.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'to the east'**
|
||||
String get directionEast;
|
||||
|
||||
/// No description provided for @directionSouthEast.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'to the south-east'**
|
||||
String get directionSouthEast;
|
||||
|
||||
/// No description provided for @directionSouth.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'to the south'**
|
||||
String get directionSouth;
|
||||
|
||||
/// No description provided for @directionSouthWest.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'to the south-west'**
|
||||
String get directionSouthWest;
|
||||
|
||||
/// No description provided for @directionWest.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'to the west'**
|
||||
String get directionWest;
|
||||
|
||||
/// No description provided for @directionNorthWest.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'to the north-west'**
|
||||
String get directionNorthWest;
|
||||
|
||||
/// No description provided for @recordRoute.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Route'**
|
||||
String get recordRoute;
|
||||
|
||||
/// No description provided for @recordNoRoute.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'None'**
|
||||
String get recordNoRoute;
|
||||
|
||||
/// No description provided for @recordingNextTurn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'In {distance}'**
|
||||
String recordingNextTurn(String distance);
|
||||
|
||||
/// No description provided for @recordingOffRoute.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Off the route: {distance} {direction}'**
|
||||
String recordingOffRoute(String distance, String direction);
|
||||
|
||||
/// No description provided for @recordingRouteEnd.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'End of the route'**
|
||||
String get recordingRouteEnd;
|
||||
|
||||
/// No description provided for @recordingFollowRoute.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Following {name}'**
|
||||
String recordingFollowRoute(String name);
|
||||
|
||||
/// No description provided for @recordingMapHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Swipe for the map'**
|
||||
String get recordingMapHint;
|
||||
|
||||
/// No description provided for @recordingFiguresHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Swipe back for the figures'**
|
||||
String get recordingFiguresHint;
|
||||
|
||||
/// No description provided for @recordingNorthUp.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'North up'**
|
||||
String get recordingNorthUp;
|
||||
|
||||
/// No description provided for @recordingHeadingUp.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Heading up'**
|
||||
String get recordingHeadingUp;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -1499,4 +1499,121 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get routeDeleted => 'Route gelöscht';
|
||||
|
||||
@override
|
||||
String get turnStraight => 'Geradeaus';
|
||||
|
||||
@override
|
||||
String get turnLeft => 'Links abbiegen';
|
||||
|
||||
@override
|
||||
String get turnSlightLeft => 'Leicht links';
|
||||
|
||||
@override
|
||||
String get turnSharpLeft => 'Scharf links';
|
||||
|
||||
@override
|
||||
String get turnRight => 'Rechts abbiegen';
|
||||
|
||||
@override
|
||||
String get turnSlightRight => 'Leicht rechts';
|
||||
|
||||
@override
|
||||
String get turnSharpRight => 'Scharf rechts';
|
||||
|
||||
@override
|
||||
String get turnKeepLeft => 'Links halten';
|
||||
|
||||
@override
|
||||
String get turnKeepRight => 'Rechts halten';
|
||||
|
||||
@override
|
||||
String get turnAround => 'Wenden';
|
||||
|
||||
@override
|
||||
String turnRoundabout(int exit) {
|
||||
return 'Im Kreisverkehr die $exit. Ausfahrt nehmen';
|
||||
}
|
||||
|
||||
@override
|
||||
String get turnExitLeft => 'Die Ausfahrt links nehmen';
|
||||
|
||||
@override
|
||||
String get turnExitRight => 'Die Ausfahrt rechts nehmen';
|
||||
|
||||
@override
|
||||
String routeSayIn(int metres, String turn) {
|
||||
return 'In $metres Metern: $turn';
|
||||
}
|
||||
|
||||
@override
|
||||
String routeSayOff(String metres, String direction) {
|
||||
return 'Abseits der Route. Sie liegt $metres Meter $direction.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get routeSayOn => 'Zurück auf der Route';
|
||||
|
||||
@override
|
||||
String get routeSayArrived => 'Ende der Route';
|
||||
|
||||
@override
|
||||
String get directionNorth => 'nördlich';
|
||||
|
||||
@override
|
||||
String get directionNorthEast => 'nordöstlich';
|
||||
|
||||
@override
|
||||
String get directionEast => 'östlich';
|
||||
|
||||
@override
|
||||
String get directionSouthEast => 'südöstlich';
|
||||
|
||||
@override
|
||||
String get directionSouth => 'südlich';
|
||||
|
||||
@override
|
||||
String get directionSouthWest => 'südwestlich';
|
||||
|
||||
@override
|
||||
String get directionWest => 'westlich';
|
||||
|
||||
@override
|
||||
String get directionNorthWest => 'nordwestlich';
|
||||
|
||||
@override
|
||||
String get recordRoute => 'Route';
|
||||
|
||||
@override
|
||||
String get recordNoRoute => 'Keine';
|
||||
|
||||
@override
|
||||
String recordingNextTurn(String distance) {
|
||||
return 'In $distance';
|
||||
}
|
||||
|
||||
@override
|
||||
String recordingOffRoute(String distance, String direction) {
|
||||
return 'Abseits der Route: $distance $direction';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingRouteEnd => 'Ende der Route';
|
||||
|
||||
@override
|
||||
String recordingFollowRoute(String name) {
|
||||
return 'Route: $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingMapHint => 'Wischen für die Karte';
|
||||
|
||||
@override
|
||||
String get recordingFiguresHint => 'Zurückwischen für die Werte';
|
||||
|
||||
@override
|
||||
String get recordingNorthUp => 'Norden oben';
|
||||
|
||||
@override
|
||||
String get recordingHeadingUp => 'Bewegungsrichtung oben';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1491,6 +1491,123 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get routeDeleted => 'Route deleted';
|
||||
|
||||
@override
|
||||
String get turnStraight => 'Go straight on';
|
||||
|
||||
@override
|
||||
String get turnLeft => 'Turn left';
|
||||
|
||||
@override
|
||||
String get turnSlightLeft => 'Bear left';
|
||||
|
||||
@override
|
||||
String get turnSharpLeft => 'Turn sharp left';
|
||||
|
||||
@override
|
||||
String get turnRight => 'Turn right';
|
||||
|
||||
@override
|
||||
String get turnSlightRight => 'Bear right';
|
||||
|
||||
@override
|
||||
String get turnSharpRight => 'Turn sharp right';
|
||||
|
||||
@override
|
||||
String get turnKeepLeft => 'Keep left';
|
||||
|
||||
@override
|
||||
String get turnKeepRight => 'Keep right';
|
||||
|
||||
@override
|
||||
String get turnAround => 'Turn around';
|
||||
|
||||
@override
|
||||
String turnRoundabout(int exit) {
|
||||
return 'At the roundabout, take exit $exit';
|
||||
}
|
||||
|
||||
@override
|
||||
String get turnExitLeft => 'Take the exit on the left';
|
||||
|
||||
@override
|
||||
String get turnExitRight => 'Take the exit on the right';
|
||||
|
||||
@override
|
||||
String routeSayIn(int metres, String turn) {
|
||||
return 'In $metres metres: $turn';
|
||||
}
|
||||
|
||||
@override
|
||||
String routeSayOff(String metres, String direction) {
|
||||
return 'Off the route. It lies $metres metres $direction.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get routeSayOn => 'Back on the route';
|
||||
|
||||
@override
|
||||
String get routeSayArrived => 'End of the route';
|
||||
|
||||
@override
|
||||
String get directionNorth => 'to the north';
|
||||
|
||||
@override
|
||||
String get directionNorthEast => 'to the north-east';
|
||||
|
||||
@override
|
||||
String get directionEast => 'to the east';
|
||||
|
||||
@override
|
||||
String get directionSouthEast => 'to the south-east';
|
||||
|
||||
@override
|
||||
String get directionSouth => 'to the south';
|
||||
|
||||
@override
|
||||
String get directionSouthWest => 'to the south-west';
|
||||
|
||||
@override
|
||||
String get directionWest => 'to the west';
|
||||
|
||||
@override
|
||||
String get directionNorthWest => 'to the north-west';
|
||||
|
||||
@override
|
||||
String get recordRoute => 'Route';
|
||||
|
||||
@override
|
||||
String get recordNoRoute => 'None';
|
||||
|
||||
@override
|
||||
String recordingNextTurn(String distance) {
|
||||
return 'In $distance';
|
||||
}
|
||||
|
||||
@override
|
||||
String recordingOffRoute(String distance, String direction) {
|
||||
return 'Off the route: $distance $direction';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingRouteEnd => 'End of the route';
|
||||
|
||||
@override
|
||||
String recordingFollowRoute(String name) {
|
||||
return 'Following $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingMapHint => 'Swipe for the map';
|
||||
|
||||
@override
|
||||
String get recordingFiguresHint => 'Swipe back for the figures';
|
||||
|
||||
@override
|
||||
String get recordingNorthUp => 'North up';
|
||||
|
||||
@override
|
||||
String get recordingHeadingUp => 'Heading up';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ 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/route/widget/route_speech.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';
|
||||
|
|
@ -153,6 +154,12 @@ class const _Recording({required final Widget child}) extends StatelessWidget {
|
|||
maxHeartRate: planMaxHeartRate(profile, clock.now()),
|
||||
);
|
||||
},
|
||||
routeOf: (route, activity) => recorderRouteOf(
|
||||
AppLocalizations.of(context),
|
||||
Localizations.localeOf(context).toLanguageTag(),
|
||||
route,
|
||||
activity,
|
||||
),
|
||||
)..add(const RecordingEventOpened()),
|
||||
child: child,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platfo
|
|||
import 'package:halkyon_train_flutter/src/feature/recording/data/recording_file.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
|
|
@ -25,7 +26,8 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
|||
/// it is switched off for it (D27), and their rower, which a row needs
|
||||
/// (D28); their Loop, where they have one, gives the heart rate while no
|
||||
/// strap does (D41). A recording may run an interval plan (D42), which the
|
||||
/// recorder runs, its cues given there.
|
||||
/// recorder runs, its cues given there, and follow a route (D45), which the
|
||||
/// recorder guides along.
|
||||
///
|
||||
/// The recorder decides the pauses (D16); this works out what the screen and
|
||||
/// the notification show from the same lines the file holds.
|
||||
|
|
@ -52,6 +54,11 @@ class RecordingBloc(
|
|||
/// A plan as the recorder takes it (`recorderPlanOf`), in the app's
|
||||
/// language and with the person's heart rates.
|
||||
final Future<Map<String, Object?>> Function(PlanContent plan)? planOf,
|
||||
|
||||
/// A route as the recorder takes it (`recorderRouteOf`) for the activity
|
||||
/// recorded, in the app's language.
|
||||
final Map<String, Object?> Function(RouteContent route, Activity activity)?
|
||||
routeOf,
|
||||
}) extends Bloc<RecordingEvent, RecordingState> {
|
||||
this : super(const RecordingState()) {
|
||||
on<RecordingEventOpened>(_onOpened);
|
||||
|
|
@ -185,6 +192,11 @@ class RecordingBloc(
|
|||
(final plan?, final planOf?) => await planOf(plan),
|
||||
_ => null,
|
||||
};
|
||||
final route = switch ((event.route, routeOf)) {
|
||||
(final route?, final routeOf?) when gps && route.planned != null =>
|
||||
routeOf(route, activity),
|
||||
_ => null,
|
||||
};
|
||||
final now = clock.now().toUtc();
|
||||
final notice = _notice(
|
||||
liveOf(Recording(activity: activity, startedAt: now), now),
|
||||
|
|
@ -198,6 +210,7 @@ class RecordingBloc(
|
|||
rower: rows ? rower?.address : null,
|
||||
loop: loop?.call(),
|
||||
plan: plan,
|
||||
route: route,
|
||||
);
|
||||
_shown = _keyOf(notice);
|
||||
_follow();
|
||||
|
|
@ -456,12 +469,14 @@ final class const RecordingEventOpened() extends RecordingEvent;
|
|||
|
||||
/// The person starts recording [activity], pausing by itself where
|
||||
/// [autoPause] (D16), with their strap unless not [useStrap] (D27), running
|
||||
/// [plan] where one was chosen (D42).
|
||||
/// [plan] where one was chosen (D42), following [route] where one was
|
||||
/// (D45).
|
||||
final class const RecordingEventStartRequested(
|
||||
final Activity activity, {
|
||||
final bool autoPause = true,
|
||||
final bool useStrap = true,
|
||||
final PlanContent? plan,
|
||||
final RouteContent? route,
|
||||
}) extends RecordingEvent;
|
||||
|
||||
/// The person chose [device] as their [kind] on this device.
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
|||
String? rower,
|
||||
String? loop,
|
||||
Map<String, Object?>? plan,
|
||||
Map<String, Object?>? route,
|
||||
}) => _channel.invokeMethod<void>('start', {
|
||||
'path': path,
|
||||
'activity': activity.name,
|
||||
|
|
@ -47,6 +48,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
|||
'rower': rower,
|
||||
'loop': loop,
|
||||
'plan': plan == null ? null : jsonEncode(plan),
|
||||
'route': route == null ? null : jsonEncode(route),
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workou
|
|||
import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Whether [activity] is recorded with GPS: all but rowing indoors.
|
||||
bool usesGps(Activity activity) => activity != Activity.indoorRowing;
|
||||
|
|
@ -56,6 +57,11 @@ LiveRecording liveOf(Recording recording, DateTime now) {
|
|||
final lastRowing = recording.rowing.lastOrNull;
|
||||
final rowingFresh =
|
||||
lastRowing != null && now.difference(lastRowing.time) <= freshHeartRate;
|
||||
final track = [
|
||||
for (final fix in recording.fixes)
|
||||
if (fix.accuracyCm <= maxAccuracyCm)
|
||||
LatLng(fix.latE7 / 1e7, fix.lonE7 / 1e7),
|
||||
];
|
||||
return LiveRecording(
|
||||
activity: recording.activity,
|
||||
movingTime: movingTimeOf(recording, now),
|
||||
|
|
@ -83,9 +89,70 @@ LiveRecording liveOf(Recording recording, DateTime now) {
|
|||
strokeRate: rowingFresh && !paused ? lastRowing.strokeRate : null,
|
||||
watts: rowingFresh && !paused ? lastRowing.watts : null,
|
||||
plan: _planOf(recording, now),
|
||||
route: _routeOf(recording, track.lastOrNull),
|
||||
track: track,
|
||||
heading: _headingOf(track),
|
||||
);
|
||||
}
|
||||
|
||||
const _distance = Distance();
|
||||
|
||||
/// Where following [recording]'s route stands (D45), the person last at
|
||||
/// [here]; null without a route or a way planned.
|
||||
LiveRoute? _routeOf(Recording recording, LatLng? here) {
|
||||
final route = recording.route;
|
||||
final planned = route?.planned;
|
||||
if (route == null || planned == null) {
|
||||
return null;
|
||||
}
|
||||
GuideMark? last(Set<GuideKind> kinds) {
|
||||
for (final mark in recording.guidance.reversed) {
|
||||
if (kinds.contains(mark.kind)) {
|
||||
return mark;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final next = switch (last({GuideKind.next})) {
|
||||
GuideMark(:final index) when index < planned.hints.length =>
|
||||
planned.hints[index],
|
||||
_ => null,
|
||||
};
|
||||
final off = switch (last({GuideKind.off, GuideKind.on})) {
|
||||
final mark? when mark.kind == GuideKind.off => mark,
|
||||
_ => null,
|
||||
};
|
||||
final turnAt = next == null
|
||||
? null
|
||||
: planned.points.elementAtOrNull(next.index);
|
||||
return LiveRoute(
|
||||
route: route,
|
||||
next: next,
|
||||
metresToNext: here == null || turnAt == null
|
||||
? null
|
||||
: (_distance(here, turnAt) / 10).round() * 10,
|
||||
offMetres: off?.metres,
|
||||
offBearing: off?.bearing,
|
||||
arrived: recording.guidance.any((mark) => mark.kind == GuideKind.arrived),
|
||||
);
|
||||
}
|
||||
|
||||
/// The heading of [track]'s last stretch of 5 m or more, in degrees from
|
||||
/// north; null where it has none.
|
||||
double? _headingOf(List<LatLng> track) {
|
||||
final last = track.lastOrNull;
|
||||
if (last == null) {
|
||||
return null;
|
||||
}
|
||||
for (final point in track.reversed.skip(1)) {
|
||||
if (_distance(point, last) >= 5) {
|
||||
return (_distance.bearing(point, last) + 360) % 360;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Where [recording]'s plan stands at [now] (D42), from its last step mark:
|
||||
/// the moving time and the distance since, as the recorder counts them; null
|
||||
/// without a plan, or before its first step.
|
||||
|
|
|
|||
|
|
@ -106,6 +106,10 @@ abstract interface class RecorderPlatform() {
|
|||
/// The interval plan it runs, as the recorder takes it (D42,
|
||||
/// `recorderPlanOf`).
|
||||
Map<String, Object?>? plan,
|
||||
|
||||
/// The route it follows, as the recorder takes it (D45,
|
||||
/// `recorderRouteOf`).
|
||||
Map<String, Object?>? route,
|
||||
});
|
||||
|
||||
/// Pauses, or goes on, by hand.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import 'dart:typed_data';
|
|||
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_payload.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/data/route_payload.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
/// The version of the recording file this app writes and reads.
|
||||
|
|
@ -29,6 +31,10 @@ const recordingFileMagic = 'halkyon-train-recording';
|
|||
/// loop <time> connecting|connected|lost
|
||||
/// plan <time> <the plan's payload, JSON>
|
||||
/// step <time> <its index in the plan's run>|done
|
||||
/// route <time> <the route's payload, base64>
|
||||
/// guide <time> next|turn <the route's hint>
|
||||
/// guide <time> off <metres> <bearing>
|
||||
/// guide <time> on|arrived
|
||||
/// end <time>
|
||||
/// ```
|
||||
///
|
||||
|
|
@ -67,6 +73,8 @@ Recording? readRecording(String text) {
|
|||
PlanContent? plan;
|
||||
int? planMaxHeartRate;
|
||||
final planSteps = <StepMark>[];
|
||||
RouteContent? route;
|
||||
final guidance = <GuideMark>[];
|
||||
DateTime? endedAt;
|
||||
for (final line in lines.skip(1)) {
|
||||
final fields = line.split(' ');
|
||||
|
|
@ -85,6 +93,37 @@ Recording? readRecording(String text) {
|
|||
continue;
|
||||
}
|
||||
switch (fields) {
|
||||
case ['route', _, final payload]:
|
||||
route = _routeOf(payload);
|
||||
case ['guide', _, final kind, final index]
|
||||
when kind == 'next' || kind == 'turn':
|
||||
if (int.tryParse(index) case final index? when index >= 0) {
|
||||
guidance.add(
|
||||
GuideMark(
|
||||
time: time,
|
||||
kind: kind == 'next' ? GuideKind.next : GuideKind.turn,
|
||||
index: index,
|
||||
),
|
||||
);
|
||||
}
|
||||
case ['guide', _, 'off', final metres, final bearing]:
|
||||
if ((int.tryParse(metres), int.tryParse(bearing)) case (
|
||||
final metres?,
|
||||
final bearing?,
|
||||
)) {
|
||||
guidance.add(
|
||||
GuideMark(
|
||||
time: time,
|
||||
kind: GuideKind.off,
|
||||
metres: metres,
|
||||
bearing: bearing,
|
||||
),
|
||||
);
|
||||
}
|
||||
case ['guide', _, 'on']:
|
||||
guidance.add(GuideMark(time: time, kind: GuideKind.on));
|
||||
case ['guide', _, 'arrived']:
|
||||
guidance.add(GuideMark(time: time, kind: GuideKind.arrived));
|
||||
case ['step', _, 'done']:
|
||||
planSteps.add(StepMark(time: time));
|
||||
case ['step', _, final index]:
|
||||
|
|
@ -157,12 +196,22 @@ Recording? readRecording(String text) {
|
|||
plan: plan,
|
||||
planSteps: planSteps,
|
||||
planMaxHeartRate: planMaxHeartRate,
|
||||
route: route,
|
||||
guidance: guidance,
|
||||
endedAt: endedAt,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
RouteContent? _routeOf(String payload) {
|
||||
try {
|
||||
return decodeRoute(ByteData.sublistView(base64Decode(payload)));
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Object? _jsonOf(String text) {
|
||||
try {
|
||||
return jsonDecode(text);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.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';
|
||||
|
||||
/// Where a recording's plan stands (D42): the step running, which of how
|
||||
/// many, what is left of it, and the next; no step once the plan is done.
|
||||
|
|
@ -35,6 +37,36 @@ final class const LivePlan({
|
|||
];
|
||||
}
|
||||
|
||||
/// Where following a recording's route stands (D45): the next turn and how
|
||||
/// far it is as the crow flies (to 10 m), the way back while off the route,
|
||||
/// and whether its end was reached.
|
||||
final class const LiveRoute({
|
||||
required final RouteContent route,
|
||||
|
||||
/// The turn to come; null before the recorder placed the person on the
|
||||
/// route, and past its last turn.
|
||||
final TurnHint? next,
|
||||
final int? metresToNext,
|
||||
|
||||
/// While off the route: how far its nearest point is, and its bearing,
|
||||
/// in degrees from north.
|
||||
final int? offMetres,
|
||||
final int? offBearing,
|
||||
final bool arrived = false,
|
||||
}) with Equatable {
|
||||
bool get off => offMetres != null;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
route,
|
||||
next,
|
||||
metresToNext,
|
||||
offMetres,
|
||||
offBearing,
|
||||
arrived,
|
||||
];
|
||||
}
|
||||
|
||||
/// What the recording screen shows of a recording under way (ADR 0030, D21).
|
||||
final class const LiveRecording({
|
||||
required final Activity activity,
|
||||
|
|
@ -83,6 +115,14 @@ final class const LiveRecording({
|
|||
|
||||
/// The plan it runs; null without one (D42).
|
||||
final LivePlan? plan,
|
||||
|
||||
/// The route it follows; null without one (D45).
|
||||
final LiveRoute? route,
|
||||
|
||||
/// The fixes precise enough to count (D20), for the map, and the heading
|
||||
/// of the last few metres, in degrees from north; null before it moved.
|
||||
final List<LatLng> track = const [],
|
||||
final double? heading,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
|
|
@ -103,5 +143,8 @@ final class const LiveRecording({
|
|||
strokeRate,
|
||||
watts,
|
||||
plan,
|
||||
route,
|
||||
track,
|
||||
heading,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
/// A position the phone delivered (ADR 0030, D20: every one, with its
|
||||
|
|
@ -91,6 +92,38 @@ final class const StepMark({
|
|||
List<Object?> get props => [time, index];
|
||||
}
|
||||
|
||||
/// What following a route gave (D45), as the recorder tells it.
|
||||
enum GuideKind() {
|
||||
/// The turn to come is the route's hint [GuideMark.index] now; none where
|
||||
/// the index is past its last.
|
||||
next,
|
||||
|
||||
/// Hint [GuideMark.index] was told, ahead.
|
||||
turn,
|
||||
|
||||
/// Off the route, [GuideMark.metres] from its nearest point, which lies
|
||||
/// [GuideMark.bearing] degrees from north.
|
||||
off,
|
||||
|
||||
/// Back on it.
|
||||
on,
|
||||
|
||||
/// At its end.
|
||||
arrived,
|
||||
}
|
||||
|
||||
/// A mark of following the route at [time].
|
||||
final class const GuideMark({
|
||||
required final DateTime time,
|
||||
required final GuideKind kind,
|
||||
final int index = 0,
|
||||
final int metres = 0,
|
||||
final int bearing = 0,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [time, kind, index, metres, bearing];
|
||||
}
|
||||
|
||||
/// A recording as its file holds it (D18): what the recorder took, in the
|
||||
/// order it took it, finished or cut off.
|
||||
final class const Recording({
|
||||
|
|
@ -121,6 +154,11 @@ final class const Recording({
|
|||
final List<StepMark> planSteps = const [],
|
||||
final int? planMaxHeartRate,
|
||||
|
||||
/// The route it followed, where it followed one, and what following it
|
||||
/// gave (D45).
|
||||
final RouteContent? route,
|
||||
final List<GuideMark> guidance = const [],
|
||||
|
||||
/// When the person stopped it; null for one cut off (D18).
|
||||
final DateTime? endedAt,
|
||||
}) with Equatable {
|
||||
|
|
@ -153,6 +191,8 @@ final class const Recording({
|
|||
plan,
|
||||
planSteps,
|
||||
planMaxHeartRate,
|
||||
route,
|
||||
guidance,
|
||||
endedAt,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.
|
|||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/device_sheet.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/route/model/route.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/widget/route_texts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
@ -34,7 +37,8 @@ bool autoPausesByDefault(Activity activity) =>
|
|||
/// recorded chosen, auto-pause's switch, the heart-rate strap, chosen there
|
||||
/// and switched off for one workout (D27), the Loop named where it gives the
|
||||
/// heart rate (D41), for a row the rower, without which it doesn't start
|
||||
/// (D28), and the interval plan to run, none by default (D42); true once
|
||||
/// (D28), the interval plan to run, none by default (D42), and the route
|
||||
/// to follow, none by default, for an activity with GPS (D45); true once
|
||||
/// Start was pressed, the start asked of [bloc].
|
||||
Future<bool> showRecordSheet(BuildContext context, RecordingBloc bloc) async =>
|
||||
await showAdaptiveSheet<bool>(
|
||||
|
|
@ -62,6 +66,15 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
|||
/// The plan it runs; none by default.
|
||||
PlanContent? _plan;
|
||||
|
||||
/// The route it follows; none by default.
|
||||
RouteContent? _route;
|
||||
|
||||
/// [activity] chosen, with its auto-pause's default.
|
||||
void _choose(Activity activity) {
|
||||
_activity = activity;
|
||||
_autoPause = autoPausesByDefault(activity);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -72,10 +85,7 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
|||
children: [
|
||||
RadioGroup<Activity>(
|
||||
groupValue: _activity,
|
||||
onChanged: (activity) => setState(() {
|
||||
_activity = activity!;
|
||||
_autoPause = autoPausesByDefault(activity);
|
||||
}),
|
||||
onChanged: (activity) => setState(() => _choose(activity!)),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final activity in recordedActivities)
|
||||
|
|
@ -111,7 +121,7 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
|||
},
|
||||
),
|
||||
trailing: const Icon(LucideIcons.chevronRight),
|
||||
onTap: () => _choose(context, DeviceKind.rower),
|
||||
onTap: () => _chooseDevice(context, DeviceKind.rower),
|
||||
),
|
||||
),
|
||||
BlocBuilder<RecordingBloc, RecordingState>(
|
||||
|
|
@ -139,7 +149,7 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
|||
value: _useStrap,
|
||||
onChanged: (value) => setState(() => _useStrap = value),
|
||||
),
|
||||
onTap: () => _choose(context, DeviceKind.strap),
|
||||
onTap: () => _chooseDevice(context, DeviceKind.strap),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -156,13 +166,32 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
|||
// A plan for an activity chooses it.
|
||||
if (chosen.plan?.activity case final activity?
|
||||
when recordedActivities.contains(activity)) {
|
||||
_activity = activity;
|
||||
_autoPause = autoPausesByDefault(activity);
|
||||
_choose(activity);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
if (_activity != Activity.indoorRowing)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.route),
|
||||
title: Text(l10n.recordRoute),
|
||||
subtitle: Text(_route?.name ?? l10n.recordNoRoute),
|
||||
trailing: const Icon(LucideIcons.chevronRight),
|
||||
onTap: () async {
|
||||
final chosen = await _chooseRoute(context, _route);
|
||||
if (chosen != null) {
|
||||
setState(() {
|
||||
_route = chosen.route;
|
||||
// A route for an activity chooses it, as a plan does.
|
||||
if (chosen.route?.activity case final activity?
|
||||
when recordedActivities.contains(activity)) {
|
||||
_choose(activity);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
footer: BlocBuilder<RecordingBloc, RecordingState>(
|
||||
|
|
@ -179,6 +208,7 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
|||
autoPause: _autoPause,
|
||||
useStrap: _useStrap,
|
||||
plan: _plan,
|
||||
route: _activity == Activity.indoorRowing ? null : _route,
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop(true);
|
||||
|
|
@ -189,7 +219,7 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _choose(BuildContext context, DeviceKind kind) =>
|
||||
Future<void> _chooseDevice(BuildContext context, DeviceKind kind) =>
|
||||
showDeviceSheet(
|
||||
context,
|
||||
widget.bloc,
|
||||
|
|
@ -236,6 +266,43 @@ Future<({PlanContent? plan})?> _choosePlan(
|
|||
);
|
||||
}
|
||||
|
||||
/// The routes with a way planned to choose from, with None; null where the
|
||||
/// sheet was left without a choice.
|
||||
Future<({RouteContent? route})?> _chooseRoute(
|
||||
BuildContext context,
|
||||
RouteContent? current,
|
||||
) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
return showAdaptiveSheet<({RouteContent? route})>(
|
||||
context,
|
||||
builder: (context) => SheetScaffold(
|
||||
title: l10n.recordRoute,
|
||||
body: BlocBuilder<RoutesCubit, RoutesState>(
|
||||
builder: (context, state) => RadioGroup<RouteContent?>(
|
||||
groupValue: current,
|
||||
onChanged: (route) => Navigator.of(context).pop((route: route)),
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile<RouteContent?>(
|
||||
value: null,
|
||||
title: Text(l10n.recordNoRoute),
|
||||
),
|
||||
for (final stored in state.visible)
|
||||
if (stored.content case final route? when route.planned != null)
|
||||
RadioListTile<RouteContent?>(
|
||||
value: route,
|
||||
title: Text(route.name),
|
||||
subtitle: Text(routeSummary(l10n, locale, route)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// [bloc] where its start ended: recording, refused location or nearby
|
||||
/// devices, or without a rower.
|
||||
Future<RecordingState> startedOrRefused(RecordingBloc bloc) =>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import 'dart:async';
|
||||
|
||||
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/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
|
||||
|
|
@ -9,8 +13,12 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workou
|
|||
import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/widget/route_speech.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.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';
|
||||
|
||||
|
|
@ -29,8 +37,11 @@ Future<void> openRecordingScreen(BuildContext context) {
|
|||
|
||||
/// A recording under way, or stopped (ADR 0030, D21): the moving time, the
|
||||
/// distance and the pace now, GPS's state, the plan's step and Lap where it
|
||||
/// runs one (D42), Pause or Resume and Stop held down; once stopped, Save or Discard. It closes once the recording is
|
||||
/// saved or discarded; back leaves the recording running.
|
||||
/// runs one (D42), the route's next turn or the way back to it where it
|
||||
/// follows one (D45), Pause or Resume and Stop held down, and swiped to, a
|
||||
/// map of the track so far (D45); once stopped, Save or Discard. It closes
|
||||
/// once the recording is saved or discarded; back leaves the recording
|
||||
/// running.
|
||||
class const RecordingScreen({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
|
|
@ -49,34 +60,101 @@ class const RecordingScreen({super.key}) extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: readableContentMaxWidth,
|
||||
),
|
||||
child: switch (state.phase) {
|
||||
RecordingPhase.stopped ||
|
||||
RecordingPhase.cutOff => _Stopped(state: state),
|
||||
_ => _Recording(live: state.live),
|
||||
},
|
||||
child: switch (state.phase) {
|
||||
RecordingPhase.stopped || RecordingPhase.cutOff => _Readable(
|
||||
child: _Stopped(state: state),
|
||||
),
|
||||
),
|
||||
_ => _Recording(live: state.live),
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// [child] no wider than reads well, in the middle.
|
||||
class const _Readable({required final Widget child}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The recording's figures, and swiped to, its map (D45), with which of the
|
||||
/// two shows marked above.
|
||||
class const _Recording({required final LiveRecording? live})
|
||||
extends StatefulWidget {
|
||||
@override
|
||||
State<_Recording> createState() => _RecordingState();
|
||||
}
|
||||
|
||||
class _RecordingState() extends State<_Recording> {
|
||||
final _pages = PageController();
|
||||
var _page = 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pages.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final live = widget.live;
|
||||
if (live == null) {
|
||||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
Semantics(
|
||||
label: _page == 0 ? l10n.recordingMapHint : l10n.recordingFiguresHint,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (final page in [0, 1])
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: page == _page
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PageView(
|
||||
controller: _pages,
|
||||
onPageChanged: (page) => setState(() => _page = page),
|
||||
children: [
|
||||
_Readable(child: _Figures(live: live)),
|
||||
_RecordingMap(live: live),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class const _Figures({required final LiveRecording live})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final theme = Theme.of(context);
|
||||
final live = this.live;
|
||||
if (live == null) {
|
||||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
final gps = switch (live) {
|
||||
LiveRecording(waitingForGps: true, :final accuracyMetres?) =>
|
||||
l10n.recordingWaitingForGpsAccuracy(accuracyMetres),
|
||||
|
|
@ -110,147 +188,406 @@ class const _Recording({required final LiveRecording? live})
|
|||
(l10n.recordingStrapLost, true),
|
||||
_ => (gps, false),
|
||||
};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
if (status != null)
|
||||
Text(
|
||||
status,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: warning
|
||||
? theme.colorScheme.tertiary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
// Scrolled where a plan's and a route's panels leave too little room.
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: IntrinsicHeight(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
if (status != null)
|
||||
Text(
|
||||
status,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: warning
|
||||
? theme.colorScheme.tertiary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (live.route case final route?) ...[
|
||||
const SizedBox(height: 16),
|
||||
_RoutePanel(route: route),
|
||||
],
|
||||
if (live.plan case final plan?) ...[
|
||||
const SizedBox(height: 16),
|
||||
_PlanPanel(plan: plan),
|
||||
],
|
||||
const Spacer(),
|
||||
_Figure(
|
||||
value: clockTime(live.movingTime),
|
||||
label: l10n.recordingMovingTime,
|
||||
large: true,
|
||||
),
|
||||
if (paused != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
paused,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.tertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
if (rows) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Figure(
|
||||
value: '${live.distanceMetres ?? 0}',
|
||||
label: l10n.recordingMetres,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _Figure(
|
||||
value: switch (live.split) {
|
||||
final split? => clockTime(split),
|
||||
null => '–',
|
||||
},
|
||||
label: l10n.recordingSplit,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Figure(
|
||||
value: switch (live.strokeRate) {
|
||||
final rate? => strokeRateText(locale, rate),
|
||||
null => '–',
|
||||
},
|
||||
label: l10n.recordingStrokeRate,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _Figure(
|
||||
value: live.watts?.toString() ?? '–',
|
||||
label: l10n.recordingWatts,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else if (live.distanceMetres case final metres?)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Figure(
|
||||
value: kilometres(locale, metres),
|
||||
label: l10n.recordingDistance,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _Figure(
|
||||
value: switch ((pace, live.pace, live.kmh)) {
|
||||
(true, final pace?, _) => l10n.recordingPaceValue(
|
||||
clockTime(pace),
|
||||
),
|
||||
(false, _, final kmh?) =>
|
||||
l10n.recordingSpeedValue(
|
||||
speed(locale, kmh),
|
||||
),
|
||||
_ => '–',
|
||||
},
|
||||
label: pace
|
||||
? l10n.recordingPace
|
||||
: l10n.recordingSpeed,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (live.strap != null || live.loop != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
_Figure(
|
||||
value: live.bpm?.toString() ?? '–',
|
||||
label: l10n.recordingHeartRate,
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
if (live.plan case LivePlan(done: false)) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.read<RecordingBloc>().add(
|
||||
const RecordingEventLapRequested(),
|
||||
),
|
||||
icon: const Icon(LucideIcons.flag),
|
||||
label: Text(l10n.recordingLap),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Row(
|
||||
// Level with Stop, whose hint shows above it.
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: live.paused
|
||||
? FilledButton.icon(
|
||||
onPressed: () =>
|
||||
context.read<RecordingBloc>().add(
|
||||
const RecordingEventResumeRequested(),
|
||||
),
|
||||
icon: const Icon(LucideIcons.play),
|
||||
label: Text(l10n.recordingResume),
|
||||
)
|
||||
: FilledButton.tonalIcon(
|
||||
onPressed: () =>
|
||||
context.read<RecordingBloc>().add(
|
||||
const RecordingEventPauseRequested(),
|
||||
),
|
||||
icon: const Icon(LucideIcons.pause),
|
||||
label: Text(l10n.recordingPause),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const Expanded(child: _StopButton()),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (live.plan case final plan?) ...[
|
||||
const SizedBox(height: 16),
|
||||
_PlanPanel(plan: plan),
|
||||
],
|
||||
const Spacer(),
|
||||
_Figure(
|
||||
value: clockTime(live.movingTime),
|
||||
label: l10n.recordingMovingTime,
|
||||
large: true,
|
||||
),
|
||||
if (paused != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
paused,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.tertiary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Following the route (D45): the way back while off it, its end once
|
||||
/// reached, else the next turn and how far it is.
|
||||
class const _RoutePanel({required final LiveRoute 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 (icon, title, detail, warning) = switch (route) {
|
||||
LiveRoute(:final offMetres?, :final offBearing) => (
|
||||
LucideIcons.triangleAlert,
|
||||
l10n.recordingOffRoute(
|
||||
_near(l10n, locale, offMetres),
|
||||
directionName(l10n, offBearing ?? 0),
|
||||
),
|
||||
null,
|
||||
true,
|
||||
),
|
||||
LiveRoute(arrived: true) => (
|
||||
LucideIcons.flag,
|
||||
l10n.recordingRouteEnd,
|
||||
null,
|
||||
false,
|
||||
),
|
||||
LiveRoute(:final next?, :final metresToNext) => (
|
||||
_turnIcon(next.kind),
|
||||
turnText(l10n, next) ?? '',
|
||||
switch (metresToNext) {
|
||||
final metres? => l10n.recordingNextTurn(
|
||||
_near(l10n, locale, metres),
|
||||
),
|
||||
null => null,
|
||||
},
|
||||
false,
|
||||
),
|
||||
_ => (
|
||||
LucideIcons.route,
|
||||
l10n.recordingFollowRoute(route.route.name),
|
||||
null,
|
||||
false,
|
||||
),
|
||||
};
|
||||
final color = warning
|
||||
? theme.colorScheme.tertiary
|
||||
: theme.colorScheme.onSurface;
|
||||
return Card.outlined(
|
||||
child: ListTile(
|
||||
leading: Icon(icon, color: color, size: 32),
|
||||
title: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(color: color),
|
||||
),
|
||||
subtitle: detail == null
|
||||
? null
|
||||
: Text(
|
||||
detail,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
if (rows) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Figure(
|
||||
value: '${live.distanceMetres ?? 0}',
|
||||
label: l10n.recordingMetres,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _Figure(
|
||||
value: switch (live.split) {
|
||||
final split? => clockTime(split),
|
||||
null => '–',
|
||||
},
|
||||
label: l10n.recordingSplit,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Figure(
|
||||
value: switch (live.strokeRate) {
|
||||
final rate? => strokeRateText(locale, rate),
|
||||
null => '–',
|
||||
},
|
||||
label: l10n.recordingStrokeRate,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _Figure(
|
||||
value: live.watts?.toString() ?? '–',
|
||||
label: l10n.recordingWatts,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else if (live.distanceMetres case final metres?)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Figure(
|
||||
value: kilometres(locale, metres),
|
||||
label: l10n.recordingDistance,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _Figure(
|
||||
value: switch ((pace, live.pace, live.kmh)) {
|
||||
(true, final pace?, _) => l10n.recordingPaceValue(
|
||||
clockTime(pace),
|
||||
),
|
||||
(false, _, final kmh?) => l10n.recordingSpeedValue(
|
||||
speed(locale, kmh),
|
||||
),
|
||||
_ => '–',
|
||||
},
|
||||
label: pace ? l10n.recordingPace : l10n.recordingSpeed,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (live.strap != null || live.loop != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
_Figure(
|
||||
value: live.bpm?.toString() ?? '–',
|
||||
label: l10n.recordingHeartRate,
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
if (live.plan case LivePlan(done: false)) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.read<RecordingBloc>().add(
|
||||
const RecordingEventLapRequested(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [metres] as metres below a kilometre, else as kilometres.
|
||||
String _near(AppLocalizations l10n, String locale, int metres) => metres < 1000
|
||||
? l10n.stepMetres(metres)
|
||||
: distanceText(l10n, locale, metres);
|
||||
|
||||
IconData _turnIcon(TurnKind kind) => switch (kind) {
|
||||
TurnKind.left || TurnKind.sharpLeft => LucideIcons.cornerUpLeft,
|
||||
TurnKind.right || TurnKind.sharpRight => LucideIcons.cornerUpRight,
|
||||
TurnKind.slightLeft ||
|
||||
TurnKind.keepLeft ||
|
||||
TurnKind.exitLeft => LucideIcons.arrowUpLeft,
|
||||
TurnKind.slightRight ||
|
||||
TurnKind.keepRight ||
|
||||
TurnKind.exitRight => LucideIcons.arrowUpRight,
|
||||
TurnKind.uTurnLeft ||
|
||||
TurnKind.uTurnRight ||
|
||||
TurnKind.uTurn => LucideIcons.undo2,
|
||||
TurnKind.roundabout || TurnKind.roundaboutLeft => LucideIcons.rotateCw,
|
||||
TurnKind.straight ||
|
||||
TurnKind.offRoute ||
|
||||
TurnKind.beeline => LucideIcons.arrowUp,
|
||||
};
|
||||
|
||||
/// The map of the recording (D45): the route where it follows one, the
|
||||
/// track so far and where the person is, the map following them, north or
|
||||
/// the heading up as tapped; zoomed with two fingers, as a swipe goes back
|
||||
/// to the figures.
|
||||
class const _RecordingMap({required final LiveRecording live})
|
||||
extends StatefulWidget {
|
||||
@override
|
||||
State<_RecordingMap> createState() => _RecordingMapState();
|
||||
}
|
||||
|
||||
class _RecordingMapState() extends State<_RecordingMap> {
|
||||
final _map = MapController();
|
||||
Future<MapSource?>? _source;
|
||||
var _ready = false;
|
||||
var _headingUp = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_source ??= PersonScope.of<TrainPerson>(context).maps.source();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_RecordingMap old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.live.track.lastOrNull != widget.live.track.lastOrNull ||
|
||||
old.live.heading != widget.live.heading) {
|
||||
_follow();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_map.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Where the map centres: where the person is, else the route's start.
|
||||
LatLng? get _here =>
|
||||
widget.live.track.lastOrNull ??
|
||||
widget.live.route?.route.planned?.points.firstOrNull;
|
||||
|
||||
void _follow() {
|
||||
final here = _here;
|
||||
if (!_ready || here == null) {
|
||||
return;
|
||||
}
|
||||
_map.moveAndRotate(
|
||||
here,
|
||||
_map.camera.zoom,
|
||||
_headingUp ? -(widget.live.heading ?? 0) : 0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final train = PersonScope.of<TrainPerson>(context);
|
||||
final live = widget.live;
|
||||
final here = live.track.lastOrNull;
|
||||
return FutureBuilder(
|
||||
future: _source,
|
||||
builder: (context, snapshot) => Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
mapController: _map,
|
||||
options: MapOptions(
|
||||
initialCenter: _here ?? const LatLng(50, 10),
|
||||
initialZoom: _here == null ? 4 : 16,
|
||||
interactionOptions: const InteractionOptions(
|
||||
flags:
|
||||
InteractiveFlag.pinchZoom | InteractiveFlag.doubleTapZoom,
|
||||
),
|
||||
icon: const Icon(LucideIcons.flag),
|
||||
label: Text(l10n.recordingLap),
|
||||
onMapReady: () {
|
||||
_ready = true;
|
||||
_follow();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Row(
|
||||
// Level with Stop, whose hint shows above it.
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: live.paused
|
||||
? FilledButton.icon(
|
||||
onPressed: () => context.read<RecordingBloc>().add(
|
||||
const RecordingEventResumeRequested(),
|
||||
),
|
||||
icon: const Icon(LucideIcons.play),
|
||||
label: Text(l10n.recordingResume),
|
||||
)
|
||||
: FilledButton.tonalIcon(
|
||||
onPressed: () => context.read<RecordingBloc>().add(
|
||||
const RecordingEventPauseRequested(),
|
||||
),
|
||||
icon: const Icon(LucideIcons.pause),
|
||||
label: Text(l10n.recordingPause),
|
||||
if (snapshot.data case final source?) train.mapTiles(source),
|
||||
PolylineLayer(
|
||||
polylines: [
|
||||
if (live.route?.route.planned case final planned?)
|
||||
Polyline(
|
||||
points: planned.points,
|
||||
// The map's colours are the source's, not the app's.
|
||||
color: PaletteColor.cobalt.background.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
strokeWidth: 6,
|
||||
),
|
||||
if (live.track.length > 1)
|
||||
Polyline(
|
||||
points: live.track,
|
||||
color: PaletteColor.copper.background,
|
||||
strokeWidth: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const Expanded(child: _StopButton()),
|
||||
if (here != null)
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
point: here,
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: PaletteColor.copper.background,
|
||||
border: Border.all(color: Colors.white, width: 3),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (snapshot.data case final source?)
|
||||
RichAttributionWidget(
|
||||
attributions: [TextSourceAttribution(source.attribution)],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (live.route case final route?)
|
||||
Positioned(
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: 8,
|
||||
child: _RoutePanel(route: route),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 40,
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: () {
|
||||
setState(() => _headingUp = !_headingUp);
|
||||
_follow();
|
||||
},
|
||||
icon: Icon(
|
||||
_headingUp ? LucideIcons.navigation2 : LucideIcons.compass,
|
||||
),
|
||||
label: Text(
|
||||
_headingUp ? l10n.recordingHeadingUp : l10n.recordingNorthUp,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/data/route_payload.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';
|
||||
|
||||
/// How far before a turn it is told (D45): on a bike sooner, as it comes
|
||||
/// faster.
|
||||
int turnLeadMetres(Activity activity) =>
|
||||
activity == Activity.cycling ? 100 : 50;
|
||||
|
||||
/// [route] as the recorder follows it (ADR 0030, D45) while recording
|
||||
/// [activity], in the app's language [locale] (a BCP 47 tag): `route`, its
|
||||
/// payload (base64), which the recording file keeps; `points`, its way's
|
||||
/// latitudes and longitudes in turn; `turns`, each with its point's index
|
||||
/// and what is said ahead of it, those that aren't turns (BRouter's
|
||||
/// beelines and off-route marks) left out; `lead`, how far ahead; `say`,
|
||||
/// the words off the route, `{metres}` and `{direction}` for the recorder
|
||||
/// to fill in (one of `directions`, from north on, clockwise), back on it
|
||||
/// and at its end.
|
||||
Map<String, Object?> recorderRouteOf(
|
||||
AppLocalizations l10n,
|
||||
String locale,
|
||||
RouteContent route,
|
||||
Activity activity,
|
||||
) {
|
||||
final planned = route.planned;
|
||||
final lead = turnLeadMetres(activity);
|
||||
return {
|
||||
'route': base64Encode(encodeRoute(route).buffer.asUint8List()),
|
||||
'points': [
|
||||
for (final point in planned?.points ?? const <LatLng>[]) ...[
|
||||
point.latitude,
|
||||
point.longitude,
|
||||
],
|
||||
],
|
||||
'turns': [
|
||||
for (final hint in planned?.hints ?? const <TurnHint>[])
|
||||
if (turnText(l10n, hint) case final turn?)
|
||||
{'index': hint.index, 'say': l10n.routeSayIn(lead, turn)},
|
||||
],
|
||||
'lead': lead,
|
||||
'locale': locale,
|
||||
'say': {
|
||||
'off': l10n.routeSayOff('{metres}', '{direction}'),
|
||||
'on': l10n.routeSayOn,
|
||||
'arrived': l10n.routeSayArrived,
|
||||
'directions': directionNames(l10n),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// The eight directions, from north on, clockwise: "to the north-east".
|
||||
List<String> directionNames(AppLocalizations l10n) => [
|
||||
l10n.directionNorth,
|
||||
l10n.directionNorthEast,
|
||||
l10n.directionEast,
|
||||
l10n.directionSouthEast,
|
||||
l10n.directionSouth,
|
||||
l10n.directionSouthWest,
|
||||
l10n.directionWest,
|
||||
l10n.directionNorthWest,
|
||||
];
|
||||
|
||||
/// The direction of [bearing], in degrees from north.
|
||||
String directionName(AppLocalizations l10n, int bearing) =>
|
||||
directionNames(l10n)[((bearing % 360 + 22) ~/ 45) % 8];
|
||||
|
||||
/// What [hint] says to do: "Turn left"; null for what isn't a turn.
|
||||
String? turnText(AppLocalizations l10n, TurnHint hint) => switch (hint.kind) {
|
||||
TurnKind.straight => l10n.turnStraight,
|
||||
TurnKind.left => l10n.turnLeft,
|
||||
TurnKind.slightLeft => l10n.turnSlightLeft,
|
||||
TurnKind.sharpLeft => l10n.turnSharpLeft,
|
||||
TurnKind.right => l10n.turnRight,
|
||||
TurnKind.slightRight => l10n.turnSlightRight,
|
||||
TurnKind.sharpRight => l10n.turnSharpRight,
|
||||
TurnKind.keepLeft => l10n.turnKeepLeft,
|
||||
TurnKind.keepRight => l10n.turnKeepRight,
|
||||
TurnKind.uTurnLeft ||
|
||||
TurnKind.uTurnRight ||
|
||||
TurnKind.uTurn => l10n.turnAround,
|
||||
TurnKind.roundabout ||
|
||||
TurnKind.roundaboutLeft => l10n.turnRoundabout(hint.exit),
|
||||
TurnKind.exitLeft => l10n.turnExitLeft,
|
||||
TurnKind.exitRight => l10n.turnExitRight,
|
||||
TurnKind.offRoute || TurnKind.beeline => null,
|
||||
};
|
||||
Loading…
Reference in a new issue