feat(train): recording a workout on Android (ADR 0030, D13, D16, D19, D21)

RecorderService, a foreground service of the types location and health
started from the open app, takes fixes from LocationManager's GPS and
steps from the step detector, pauses by itself (AutoPause, tested on the
JVM) and appends each sample to the person's recording file, flushed
every five seconds. MainActivity asks for location, notifications and,
for auto-pause on foot, physical activity, never background location
(D19), and bridges the service to the app.

The app follows the file's lines with RecordingBloc and shows the moving
time, the distance, the pace or speed and GPS's state; the notification
shows the time and the distance with Pause or Resume. Stop is held down;
a stopped recording is saved as a workout and its series, or discarded
after asking, and one cut off or left unsaved is offered as the app
opens. Home has the Record button where the device records.

Three choices of the implementation's are recorded under D21 for review:
rowing waits for 5f, auto-pause on foot without steps goes by GPS speed
(below 2 km/h, on above 3 km/h), and Stop's hint shows above the button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-28 13:24:56 +01:00
commit 7df1cb1740
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
17 changed files with 3360 additions and 27 deletions

View file

@ -12,5 +12,45 @@
"accountTitle": "Konto",
"accountDeleteMessage": "Dein Konto wird auf dem Server und auf all deinen Geräten gelöscht. Dies kann nicht rückgängig gemacht werden.",
"aboutTitle": "Über"
"aboutTitle": "Über",
"homeRecordHint": "Zeichne mit dem Knopf unten ein Training auf. Die Liste deiner Trainings kommt mit der nächsten Version von Halkyon Train.",
"recordAction": "Aufzeichnen",
"recordBack": "Zurück zur Aufzeichnung",
"recordSheetTitle": "Training aufzeichnen",
"activityRunning": "Laufen",
"activityWalking": "Gehen",
"activityHiking": "Wandern",
"activityCycling": "Radfahren",
"activityIndoorRowing": "Rudern drinnen",
"recordAutoPause": "Von selbst pausieren",
"recordAutoPauseHint": "Wenn du stehst, und weiter, sobald du dich wieder bewegst",
"recordStart": "Start",
"recordLocationRefused": "Train braucht deinen Standort, um das aufzuzeichnen. Du kannst ihn in den Einstellungen des Systems erlauben.",
"recordingWaitingForGps": "Warte auf GPS",
"recordingWaitingForGpsAccuracy": "Warte auf GPS · ±{metres} m",
"recordingGpsAccuracy": "GPS ±{metres} m",
"recordingMovingTime": "Zeit in Bewegung",
"recordingDistance": "Strecke",
"recordingPace": "Tempo",
"recordingSpeed": "Geschwindigkeit",
"recordingDistanceValue": "{km} km",
"recordingPaceValue": "{pace} /km",
"recordingSpeedValue": "{kmh} km/h",
"recordingPausedAuto": "Pausiert: keine Bewegung",
"recordingPausedByHand": "Pausiert",
"recordingPause": "Pause",
"recordingResume": "Weiter",
"recordingStop": "Stopp",
"recordingHoldToStop": "Halte Stopp gedrückt, um das Training zu beenden",
"recordingStoppedTitle": "Dieses Training speichern?",
"recordingSave": "Speichern",
"recordingDiscard": "Verwerfen",
"recordingDiscardTitle": "Dieses Training verwerfen?",
"recordingDiscardMessage": "Seine Zeit und Strecke werden von diesem Gerät gelöscht. Dies kann nicht rückgängig gemacht werden.",
"recordingSaveFailed": "Es kann auf diesem Gerät nicht gespeichert werden, da es die Schlüssel deines Kontos nicht hat. Es bleibt hier, bis es gespeichert werden kann.",
"recordingCutOffTitle": "Eine Aufzeichnung wurde abgebrochen",
"recordingCutOffMessage": "Eine Aufzeichnung von {start} wurde abgebrochen. Bis {end} speichern oder verwerfen?",
"recordingNotificationChannel": "Aufzeichnung",
"recordingNotificationPaused": "{activity} · pausiert"
}

View file

@ -14,5 +14,56 @@
"accountTitle": "Account",
"accountDeleteMessage": "Your account is deleted on the server and on all your devices. This can't be undone.",
"aboutTitle": "About"
"aboutTitle": "About",
"homeRecordHint": "Record a workout with the button below. The list of your workouts comes with the next version of Halkyon Train.",
"recordAction": "Record",
"recordBack": "Back to the recording",
"recordSheetTitle": "Record a workout",
"activityRunning": "Running",
"activityWalking": "Walking",
"activityHiking": "Hiking",
"activityCycling": "Cycling",
"activityIndoorRowing": "Rowing indoors",
"recordAutoPause": "Pause by itself",
"@recordAutoPause": {"description": "The switch for auto-pause, as the recording starts."},
"recordAutoPauseHint": "When you stand still, and going on as you move again",
"recordStart": "Start",
"recordLocationRefused": "Train needs your location to record this. You can allow it in the system's settings.",
"recordingWaitingForGps": "Waiting for GPS",
"recordingWaitingForGpsAccuracy": "Waiting for GPS · ±{metres} m",
"@recordingWaitingForGpsAccuracy": {"description": "Before a fix precise enough: how far off the last one may be.", "placeholders": {"metres": {"type": "int"}}},
"recordingGpsAccuracy": "GPS ±{metres} m",
"@recordingGpsAccuracy": {"description": "How far off the last fix may be.", "placeholders": {"metres": {"type": "int"}}},
"recordingMovingTime": "moving time",
"@recordingMovingTime": {"description": "Under the time, lower case: the time moved, pauses left out."},
"recordingDistance": "distance",
"recordingPace": "pace",
"recordingSpeed": "speed",
"recordingDistanceValue": "{km} km",
"@recordingDistanceValue": {"placeholders": {"km": {"type": "String", "example": "5.12"}}},
"recordingPaceValue": "{pace} /km",
"@recordingPaceValue": {"description": "Minutes and seconds a kilometre takes.", "placeholders": {"pace": {"type": "String", "example": "6:18"}}},
"recordingSpeedValue": "{kmh} km/h",
"@recordingSpeedValue": {"placeholders": {"kmh": {"type": "String", "example": "24.3"}}},
"recordingPausedAuto": "Paused: not moving",
"@recordingPausedAuto": {"description": "Train paused the recording by itself (auto-pause)."},
"recordingPausedByHand": "Paused",
"recordingPause": "Pause",
"recordingResume": "Resume",
"recordingStop": "Stop",
"recordingHoldToStop": "Hold Stop to end the workout",
"recordingStoppedTitle": "Save this workout?",
"recordingSave": "Save",
"recordingDiscard": "Discard",
"recordingDiscardTitle": "Discard this workout?",
"recordingDiscardMessage": "Its time and track are deleted from this device. This can't be undone.",
"recordingSaveFailed": "It can't be saved on this device, which doesn't hold your account's keys. It stays here until it can be.",
"recordingCutOffTitle": "A recording was cut off",
"recordingCutOffMessage": "A recording from {start} was cut off. Save it up to {end}, or discard it?",
"@recordingCutOffMessage": {"description": "A recording found unfinished as the app opened: when it started and its last sample.", "placeholders": {"start": {"type": "String", "example": "07:30"}, "end": {"type": "String", "example": "08:02"}}},
"recordingNotificationChannel": "Recording",
"@recordingNotificationChannel": {"description": "The name of the recording's notification channel, in the system's settings."},
"recordingNotificationPaused": "{activity} · paused",
"@recordingNotificationPaused": {"description": "The notification's title while paused.", "placeholders": {"activity": {"type": "String", "example": "Running"}}}
}

View file

@ -147,6 +147,240 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'About'**
String get aboutTitle;
/// No description provided for @homeRecordHint.
///
/// In en, this message translates to:
/// **'Record a workout with the button below. The list of your workouts comes with the next version of Halkyon Train.'**
String get homeRecordHint;
/// No description provided for @recordAction.
///
/// In en, this message translates to:
/// **'Record'**
String get recordAction;
/// No description provided for @recordBack.
///
/// In en, this message translates to:
/// **'Back to the recording'**
String get recordBack;
/// No description provided for @recordSheetTitle.
///
/// In en, this message translates to:
/// **'Record a workout'**
String get recordSheetTitle;
/// No description provided for @activityRunning.
///
/// In en, this message translates to:
/// **'Running'**
String get activityRunning;
/// No description provided for @activityWalking.
///
/// In en, this message translates to:
/// **'Walking'**
String get activityWalking;
/// No description provided for @activityHiking.
///
/// In en, this message translates to:
/// **'Hiking'**
String get activityHiking;
/// No description provided for @activityCycling.
///
/// In en, this message translates to:
/// **'Cycling'**
String get activityCycling;
/// No description provided for @activityIndoorRowing.
///
/// In en, this message translates to:
/// **'Rowing indoors'**
String get activityIndoorRowing;
/// The switch for auto-pause, as the recording starts.
///
/// In en, this message translates to:
/// **'Pause by itself'**
String get recordAutoPause;
/// No description provided for @recordAutoPauseHint.
///
/// In en, this message translates to:
/// **'When you stand still, and going on as you move again'**
String get recordAutoPauseHint;
/// No description provided for @recordStart.
///
/// In en, this message translates to:
/// **'Start'**
String get recordStart;
/// No description provided for @recordLocationRefused.
///
/// In en, this message translates to:
/// **'Train needs your location to record this. You can allow it in the system\'s settings.'**
String get recordLocationRefused;
/// No description provided for @recordingWaitingForGps.
///
/// In en, this message translates to:
/// **'Waiting for GPS'**
String get recordingWaitingForGps;
/// Before a fix precise enough: how far off the last one may be.
///
/// In en, this message translates to:
/// **'Waiting for GPS · ±{metres} m'**
String recordingWaitingForGpsAccuracy(int metres);
/// How far off the last fix may be.
///
/// In en, this message translates to:
/// **'GPS ±{metres} m'**
String recordingGpsAccuracy(int metres);
/// Under the time, lower case: the time moved, pauses left out.
///
/// In en, this message translates to:
/// **'moving time'**
String get recordingMovingTime;
/// No description provided for @recordingDistance.
///
/// In en, this message translates to:
/// **'distance'**
String get recordingDistance;
/// No description provided for @recordingPace.
///
/// In en, this message translates to:
/// **'pace'**
String get recordingPace;
/// No description provided for @recordingSpeed.
///
/// In en, this message translates to:
/// **'speed'**
String get recordingSpeed;
/// No description provided for @recordingDistanceValue.
///
/// In en, this message translates to:
/// **'{km} km'**
String recordingDistanceValue(String km);
/// Minutes and seconds a kilometre takes.
///
/// In en, this message translates to:
/// **'{pace} /km'**
String recordingPaceValue(String pace);
/// No description provided for @recordingSpeedValue.
///
/// In en, this message translates to:
/// **'{kmh} km/h'**
String recordingSpeedValue(String kmh);
/// Train paused the recording by itself (auto-pause).
///
/// In en, this message translates to:
/// **'Paused: not moving'**
String get recordingPausedAuto;
/// No description provided for @recordingPausedByHand.
///
/// In en, this message translates to:
/// **'Paused'**
String get recordingPausedByHand;
/// No description provided for @recordingPause.
///
/// In en, this message translates to:
/// **'Pause'**
String get recordingPause;
/// No description provided for @recordingResume.
///
/// In en, this message translates to:
/// **'Resume'**
String get recordingResume;
/// No description provided for @recordingStop.
///
/// In en, this message translates to:
/// **'Stop'**
String get recordingStop;
/// No description provided for @recordingHoldToStop.
///
/// In en, this message translates to:
/// **'Hold Stop to end the workout'**
String get recordingHoldToStop;
/// No description provided for @recordingStoppedTitle.
///
/// In en, this message translates to:
/// **'Save this workout?'**
String get recordingStoppedTitle;
/// No description provided for @recordingSave.
///
/// In en, this message translates to:
/// **'Save'**
String get recordingSave;
/// No description provided for @recordingDiscard.
///
/// In en, this message translates to:
/// **'Discard'**
String get recordingDiscard;
/// No description provided for @recordingDiscardTitle.
///
/// In en, this message translates to:
/// **'Discard this workout?'**
String get recordingDiscardTitle;
/// No description provided for @recordingDiscardMessage.
///
/// In en, this message translates to:
/// **'Its time and track are deleted from this device. This can\'t be undone.'**
String get recordingDiscardMessage;
/// No description provided for @recordingSaveFailed.
///
/// In en, this message translates to:
/// **'It can\'t be saved on this device, which doesn\'t hold your account\'s keys. It stays here until it can be.'**
String get recordingSaveFailed;
/// No description provided for @recordingCutOffTitle.
///
/// In en, this message translates to:
/// **'A recording was cut off'**
String get recordingCutOffTitle;
/// A recording found unfinished as the app opened: when it started and its last sample.
///
/// In en, this message translates to:
/// **'A recording from {start} was cut off. Save it up to {end}, or discard it?'**
String recordingCutOffMessage(String start, String end);
/// The name of the recording's notification channel, in the system's settings.
///
/// In en, this message translates to:
/// **'Recording'**
String get recordingNotificationChannel;
/// The notification's title while paused.
///
/// In en, this message translates to:
/// **'{activity} · paused'**
String recordingNotificationPaused(String activity);
}
class _AppLocalizationsDelegate

View file

@ -35,4 +35,141 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get aboutTitle => 'Über';
@override
String get homeRecordHint =>
'Zeichne mit dem Knopf unten ein Training auf. Die Liste deiner Trainings kommt mit der nächsten Version von Halkyon Train.';
@override
String get recordAction => 'Aufzeichnen';
@override
String get recordBack => 'Zurück zur Aufzeichnung';
@override
String get recordSheetTitle => 'Training aufzeichnen';
@override
String get activityRunning => 'Laufen';
@override
String get activityWalking => 'Gehen';
@override
String get activityHiking => 'Wandern';
@override
String get activityCycling => 'Radfahren';
@override
String get activityIndoorRowing => 'Rudern drinnen';
@override
String get recordAutoPause => 'Von selbst pausieren';
@override
String get recordAutoPauseHint =>
'Wenn du stehst, und weiter, sobald du dich wieder bewegst';
@override
String get recordStart => 'Start';
@override
String get recordLocationRefused =>
'Train braucht deinen Standort, um das aufzuzeichnen. Du kannst ihn in den Einstellungen des Systems erlauben.';
@override
String get recordingWaitingForGps => 'Warte auf GPS';
@override
String recordingWaitingForGpsAccuracy(int metres) {
return 'Warte auf GPS · ±$metres m';
}
@override
String recordingGpsAccuracy(int metres) {
return 'GPS ±$metres m';
}
@override
String get recordingMovingTime => 'Zeit in Bewegung';
@override
String get recordingDistance => 'Strecke';
@override
String get recordingPace => 'Tempo';
@override
String get recordingSpeed => 'Geschwindigkeit';
@override
String recordingDistanceValue(String km) {
return '$km km';
}
@override
String recordingPaceValue(String pace) {
return '$pace /km';
}
@override
String recordingSpeedValue(String kmh) {
return '$kmh km/h';
}
@override
String get recordingPausedAuto => 'Pausiert: keine Bewegung';
@override
String get recordingPausedByHand => 'Pausiert';
@override
String get recordingPause => 'Pause';
@override
String get recordingResume => 'Weiter';
@override
String get recordingStop => 'Stopp';
@override
String get recordingHoldToStop =>
'Halte Stopp gedrückt, um das Training zu beenden';
@override
String get recordingStoppedTitle => 'Dieses Training speichern?';
@override
String get recordingSave => 'Speichern';
@override
String get recordingDiscard => 'Verwerfen';
@override
String get recordingDiscardTitle => 'Dieses Training verwerfen?';
@override
String get recordingDiscardMessage =>
'Seine Zeit und Strecke werden von diesem Gerät gelöscht. Dies kann nicht rückgängig gemacht werden.';
@override
String get recordingSaveFailed =>
'Es kann auf diesem Gerät nicht gespeichert werden, da es die Schlüssel deines Kontos nicht hat. Es bleibt hier, bis es gespeichert werden kann.';
@override
String get recordingCutOffTitle => 'Eine Aufzeichnung wurde abgebrochen';
@override
String recordingCutOffMessage(String start, String end) {
return 'Eine Aufzeichnung von $start wurde abgebrochen. Bis $end speichern oder verwerfen?';
}
@override
String get recordingNotificationChannel => 'Aufzeichnung';
@override
String recordingNotificationPaused(String activity) {
return '$activity · pausiert';
}
}

View file

@ -35,6 +35,142 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aboutTitle => 'About';
@override
String get homeRecordHint =>
'Record a workout with the button below. The list of your workouts comes with the next version of Halkyon Train.';
@override
String get recordAction => 'Record';
@override
String get recordBack => 'Back to the recording';
@override
String get recordSheetTitle => 'Record a workout';
@override
String get activityRunning => 'Running';
@override
String get activityWalking => 'Walking';
@override
String get activityHiking => 'Hiking';
@override
String get activityCycling => 'Cycling';
@override
String get activityIndoorRowing => 'Rowing indoors';
@override
String get recordAutoPause => 'Pause by itself';
@override
String get recordAutoPauseHint =>
'When you stand still, and going on as you move again';
@override
String get recordStart => 'Start';
@override
String get recordLocationRefused =>
'Train needs your location to record this. You can allow it in the system\'s settings.';
@override
String get recordingWaitingForGps => 'Waiting for GPS';
@override
String recordingWaitingForGpsAccuracy(int metres) {
return 'Waiting for GPS · ±$metres m';
}
@override
String recordingGpsAccuracy(int metres) {
return 'GPS ±$metres m';
}
@override
String get recordingMovingTime => 'moving time';
@override
String get recordingDistance => 'distance';
@override
String get recordingPace => 'pace';
@override
String get recordingSpeed => 'speed';
@override
String recordingDistanceValue(String km) {
return '$km km';
}
@override
String recordingPaceValue(String pace) {
return '$pace /km';
}
@override
String recordingSpeedValue(String kmh) {
return '$kmh km/h';
}
@override
String get recordingPausedAuto => 'Paused: not moving';
@override
String get recordingPausedByHand => 'Paused';
@override
String get recordingPause => 'Pause';
@override
String get recordingResume => 'Resume';
@override
String get recordingStop => 'Stop';
@override
String get recordingHoldToStop => 'Hold Stop to end the workout';
@override
String get recordingStoppedTitle => 'Save this workout?';
@override
String get recordingSave => 'Save';
@override
String get recordingDiscard => 'Discard';
@override
String get recordingDiscardTitle => 'Discard this workout?';
@override
String get recordingDiscardMessage =>
'Its time and track are deleted from this device. This can\'t be undone.';
@override
String get recordingSaveFailed =>
'It can\'t be saved on this device, which doesn\'t hold your account\'s keys. It stays here until it can be.';
@override
String get recordingCutOffTitle => 'A recording was cut off';
@override
String recordingCutOffMessage(String start, String end) {
return 'A recording from $start was cut off. Save it up to $end, or discard it?';
}
@override
String get recordingNotificationChannel => 'Recording';
@override
String recordingNotificationPaused(String activity) {
return '$activity · paused';
}
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -4,6 +4,8 @@ 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/app/train_frame.dart';
import 'package:halkyon_train_flutter/src/app/train_languages.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/home/home_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -70,9 +72,36 @@ class const TrainPersonApp({
child: BlocProvider(
create: (context) =>
OperatingCubit(AppScope.of(context).instanceOperators),
child: child,
child: _Recording(child: child!),
),
),
home: const HomeScreen(),
);
}
/// The person's recording (ADR 0030, D21) above [child], where the device
/// records: a recording still running, cut off or stopped is looked for as
/// it opens.
class const _Recording({required final Widget child}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final train = PersonScope.of<TrainPerson>(context);
final recording = train.recording;
if (recording == null) {
return child;
}
return BlocProvider(
create: (context) => RecordingBloc(
recording.platform,
train.workouts,
recording.file,
notice: (live) => noticeOf(
AppLocalizations.of(context),
Localizations.localeOf(context).toString(),
live,
),
)..add(const RecordingEventOpened()),
child: child,
);
}
}

View file

@ -8,6 +8,8 @@ import 'package:halkyon_train_client/halkyon_train_client.dart'
show Client, UuidValue;
import 'package:halkyon_train_flutter/src/core/privacy/sync_content_key_gateway.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/android_recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.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';
@ -17,16 +19,25 @@ import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
import 'package:serverpod_flutter/serverpod_flutter.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// Where a device records workouts (ADR 0030, D13, D18): its recorder, and
/// the person's recording file in their folder.
typedef RecordingDevice = ({RecorderPlatform platform, File file});
/// A signed-in person's dependencies: who they are and their workouts, on
/// their own local database in their folder, which the sync engine keeps in
/// step with their other devices (ADR 0030, D7 and D14). halkyon_app's
/// `PersonScope` opens and disposes them with the person.
/// step with their other devices (ADR 0030, D7 and D14), and where the device
/// records. halkyon_app's `PersonScope` opens and disposes them with the
/// person.
class TrainPerson(
/// Whose they are.
final SignedIn person, {
/// Their workouts, sealed and synced.
required final WorkoutRepository workouts,
/// Where this device records; null where it doesn't (iOS until 5d, the
/// desktop, which shows and plans, D17).
final RecordingDevice? recording,
final Future<void> Function()? _onDispose,
}) implements Disposable {
/// Closes what was opened for the person.
@ -264,6 +275,12 @@ Future<TrainPerson> _openPersonData(
return TrainPerson(
person,
workouts: workouts,
recording: Platform.isAndroid
? (
platform: AndroidRecorderPlatform(),
file: File(p.join(folder.path, 'recording.txt')),
)
: null,
onDispose: () async {
try {
await workouts.dispose();

View file

@ -0,0 +1,372 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/live_recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
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/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// Recording a workout (ADR 0030, D21): asks the permissions as it starts
/// (D19), starts the platform's recorder into the person's recording file
/// (D18), follows its lines, and saves the recording as a workout or throws
/// it away once stopped. As it opens, it finds a recording still running (the
/// app was gone, the service not), one cut off, or one stopped and not saved.
///
/// The recorder decides the pauses (D16); this works out what the screen and
/// the notification show from the same lines the file holds.
class RecordingBloc(
final RecorderPlatform _platform,
final WorkoutRepository _workouts,
/// The person's recording file, in their folder.
final File _file, {
/// What the notification shows of [LiveRecording], in the app's language.
required final RecorderNotice Function(LiveRecording live) notice,
/// A second passing, while it records; every second by default.
Stream<void>? ticks,
}) extends Bloc<RecordingEvent, RecordingState> {
this : super(const RecordingState()) {
on<RecordingEventOpened>(_onOpened);
on<RecordingEventStartRequested>(_onStart);
on<RecordingEventPauseRequested>((event, emit) => _platform.pause());
on<RecordingEventResumeRequested>((event, emit) => _platform.resume());
on<RecordingEventStopRequested>(_onStop);
on<RecordingEventSaveRequested>(_onSave);
on<RecordingEventDiscardRequested>(_onDiscard);
on<RecordingEventLineWritten>((event, emit) {
_lines.writeln(event.line);
// A pause, its end or the end show at once; samples with the next
// second.
if (!_sample.hasMatch(event.line)) {
_refresh(emit);
}
});
on<RecordingEventTicked>((event, emit) => _refresh(emit));
}
final RecorderNotice Function(LiveRecording live) _notice = notice;
final Stream<void> _ticks =
ticks ?? Stream<void>.periodic(const Duration(seconds: 1));
static final _sample = RegExp('^[phs] ');
/// The lines the recorder wrote so far.
final _lines = StringBuffer();
StreamSubscription<String>? _following;
StreamSubscription<void>? _ticking;
/// What the notification shows now, so it is told only of changes.
(String, String, bool)? _shown;
Future<void> _onOpened(
RecordingEventOpened event,
Emitter<RecordingState> emit,
) async {
final lastActivity = await _lastActivity();
if (await _platform.isRecording()) {
_follow();
emit(
RecordingState(
phase: RecordingPhase.recording,
lastActivity: lastActivity,
),
);
return;
}
final kept = _kept();
emit(
RecordingState(
phase: switch (kept) {
null => RecordingPhase.idle,
Recording(finished: true) => RecordingPhase.stopped,
_ => RecordingPhase.cutOff,
},
lastActivity: lastActivity,
recording: kept,
),
);
}
Future<void> _onStart(
RecordingEventStartRequested event,
Emitter<RecordingState> emit,
) async {
// Asked anew: a refusal before says nothing of this one.
emit(state.copyWith(locationRefused: false));
final activity = event.activity;
final gps = usesGps(activity);
final autoPause = !event.autoPause
? null
: switch (activity) {
Activity.cycling => AutoPauseBy.bike,
Activity.running ||
Activity.walking ||
Activity.hiking => AutoPauseBy.steps,
Activity.indoorRowing => null,
};
final granted = await _platform.ask({
if (gps) RecorderPermission.location,
RecorderPermission.notifications,
if (autoPause == AutoPauseBy.steps) RecorderPermission.activity,
});
if (gps && !granted.contains(RecorderPermission.location)) {
emit(state.copyWith(locationRefused: true));
return;
}
final now = clock.now().toUtc();
final notice = _notice(
liveOf(Recording(activity: activity, startedAt: now), now),
);
await _platform.start(
path: _file.path,
activity: activity,
autoPause: autoPause,
notice: notice,
);
_shown = _keyOf(notice);
_follow();
emit(
RecordingState(
phase: RecordingPhase.recording,
lastActivity: activity,
live: liveOf(Recording(activity: activity, startedAt: now), now),
),
);
}
Future<void> _onStop(
RecordingEventStopRequested event,
Emitter<RecordingState> emit,
) async {
await _platform.stop();
_unfollow();
final recording = _kept();
emit(
RecordingState(
phase: recording == null ? RecordingPhase.idle : RecordingPhase.stopped,
lastActivity: state.lastActivity,
recording: recording,
),
);
}
Future<void> _onSave(
RecordingEventSaveRequested event,
Emitter<RecordingState> emit,
) async {
final recording = state.recording;
if (recording == null) {
return;
}
final (content, series) = recordedWorkout(recording);
try {
await _workouts.save(content, series);
} on Object catch (error) {
// The file stays, to be saved once it can be.
log('A recording not saved: ${error.runtimeType}', name: 'Recording');
emit(state.copyWith(saveFailed: true));
return;
}
await _forget();
emit(
RecordingState(
phase: RecordingPhase.idle,
lastActivity: recording.activity,
),
);
}
Future<void> _onDiscard(
RecordingEventDiscardRequested event,
Emitter<RecordingState> emit,
) async {
await _forget();
emit(
RecordingState(
phase: RecordingPhase.idle,
lastActivity: state.lastActivity,
),
);
}
/// Follows the recorder's lines, from the file's first.
void _follow() {
_lines.clear();
_following = _platform.lines().listen(
(line) => add(RecordingEventLineWritten(line)),
);
_ticking = _ticks.listen((_) => add(const RecordingEventTicked()));
}
/// Stops following the recorder; nothing waits for that to be done, as
/// nothing more is read from it.
void _unfollow() {
unawaited(_following?.cancel());
unawaited(_ticking?.cancel());
_following = null;
_ticking = null;
_shown = null;
}
/// Works out what shows from the lines so far, and tells the notification
/// where that changed.
void _refresh(Emitter<RecordingState> emit) {
if (state.phase != RecordingPhase.recording) {
return;
}
final recording = readRecording(_lines.toString());
if (recording == null) {
return;
}
final live = liveOf(recording, clock.now().toUtc());
emit(state.copyWith(live: live));
final notice = _notice(live);
final key = _keyOf(notice);
if (key != _shown) {
_shown = key;
unawaited(_platform.show(notice));
}
}
/// What of a notice is worth telling: the time counts on by itself.
static (String, String, bool) _keyOf(RecorderNotice notice) =>
(notice.title, notice.text, notice.moving == null);
/// The recording the file holds, if it holds one; a file it can't read is
/// thrown away.
Recording? _kept() {
if (!_file.existsSync()) {
return null;
}
final recording = readRecording(_file.readAsStringSync());
if (recording == null) {
_file.deleteSync();
}
return recording;
}
Future<void> _forget() async {
_unfollow();
if (_file.existsSync()) {
_file.deleteSync();
}
}
/// The activity of the workout last recorded, to be chosen again.
Future<Activity> _lastActivity() async {
final latest = [
for (final workout in await _workouts.all()) ?workout.content,
]..sort((a, b) => a.startedAt.compareTo(b.startedAt));
return latest.lastOrNull?.activity ?? Activity.running;
}
@override
Future<void> close() async {
_unfollow();
await super.close();
}
}
/// Where a recording stands.
enum RecordingPhase() {
/// Looking for one, as the screen opens.
loading,
/// None: one may start.
idle,
/// One runs.
recording,
/// Stopped by the person, to be saved or discarded.
stopped,
/// Found cut off as the app opened (D18), to be saved up to its last
/// sample or discarded.
cutOff,
}
/// What the recording screen shows.
final class const RecordingState({
final RecordingPhase phase = RecordingPhase.loading,
/// The activity to choose first: the one last recorded.
final Activity lastActivity = Activity.running,
/// What shows while it records.
final LiveRecording? live,
/// The recording stopped or cut off.
final Recording? recording,
/// Whether location was refused as a GPS activity was to start.
final bool locationRefused = false,
/// Whether saving failed (as on a device without the account's keys).
final bool saveFailed = false,
}) with Equatable {
RecordingState copyWith({
LiveRecording? live,
bool? locationRefused,
bool? saveFailed,
}) => RecordingState(
phase: phase,
lastActivity: lastActivity,
live: live ?? this.live,
recording: recording,
locationRefused: locationRefused ?? this.locationRefused,
saveFailed: saveFailed ?? this.saveFailed,
);
@override
List<Object?> get props => [
phase,
lastActivity,
live,
recording,
locationRefused,
saveFailed,
];
}
/// What happens to a recording.
sealed class const RecordingEvent();
/// The screen opened: it looks for a recording.
final class const RecordingEventOpened() extends RecordingEvent;
/// The person starts recording [activity], pausing by itself where
/// [autoPause] (D16).
final class const RecordingEventStartRequested(
final Activity activity, {
final bool autoPause = true,
}) extends RecordingEvent;
final class const RecordingEventPauseRequested() extends RecordingEvent;
final class const RecordingEventResumeRequested() extends RecordingEvent;
final class const RecordingEventStopRequested() extends RecordingEvent;
/// The recording stopped or cut off is saved as a workout.
final class const RecordingEventSaveRequested() extends RecordingEvent;
/// The recording stopped or cut off is thrown away.
final class const RecordingEventDiscardRequested() extends RecordingEvent;
/// The recorder wrote a line; the bloc's own, as it follows the recorder.
final class const RecordingEventLineWritten(final String line)
extends RecordingEvent;
/// A second passed; the bloc's own, as it records.
final class const RecordingEventTicked() extends RecordingEvent;

View file

@ -0,0 +1,70 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// Recording on Android, through the app's own `RecorderService` (Kotlin,
/// `android/app/src/main/kotlin/`, ADR 0030, D13): LocationManager's GPS and
/// the step detector in a foreground service, auto-pause decided there, the
/// file appended to there.
class AndroidRecorderPlatform() implements RecorderPlatform {
static const _channel = MethodChannel('app.halkyon.train/recorder');
static const _lines = EventChannel('app.halkyon.train/recorder/lines');
@override
Future<Set<RecorderPermission>> ask(Set<RecorderPermission> wanted) async {
final granted = await _channel.invokeMapMethod<String, bool>('ask', [
for (final permission in wanted) permission.name,
]);
return {
for (final permission in wanted)
if (granted?[permission.name] ?? false) permission,
};
}
@override
Future<void> start({
required String path,
required Activity activity,
required AutoPauseBy? autoPause,
required RecorderNotice notice,
}) => _channel.invokeMethod<void>('start', {
'path': path,
'activity': activity.name,
'autoPause': autoPause?.name,
'notice': noticeJson(notice),
});
@override
Future<void> pause() => _channel.invokeMethod<void>('pause');
@override
Future<void> resume() => _channel.invokeMethod<void>('resume');
@override
Future<void> stop() => _channel.invokeMethod<void>('stop');
@override
Future<void> show(RecorderNotice notice) =>
_channel.invokeMethod<void>('show', noticeJson(notice));
@override
Future<bool> isRecording() async =>
await _channel.invokeMethod<bool>('isRecording') ?? false;
@override
Stream<String> lines() => _lines.receiveBroadcastStream().cast<String>();
}
/// [notice] as the service reads it, JSON: times in milliseconds, since 1970
/// for a moment.
String noticeJson(RecorderNotice notice) => jsonEncode({
'title': notice.title,
'text': notice.text,
if (notice.moving case final moving?) 'movingMs': moving.inMilliseconds,
'shownAt': notice.shownAt.millisecondsSinceEpoch,
'pause': notice.pause,
'resume': notice.resume,
'channelName': notice.channelName,
});

View file

@ -0,0 +1,39 @@
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.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/workout/model/workout.dart';
/// Whether [activity] is recorded with GPS: all but rowing indoors.
bool usesGps(Activity activity) => activity != Activity.indoorRowing;
/// What the recording screen shows of [recording] at [now] (ADR 0030, D21),
/// worked out as a saved workout's totals are (D16, D20).
LiveRecording liveOf(Recording recording, DateTime now) {
final gps = usesGps(recording.activity);
final last = recording.pauses.lastOrNull;
final paused = last?.paused ?? false;
final recent = paused ? null : recentOf(recording, now);
final (pace, kmh) = switch (recent) {
(final metres, final time) when metres >= 10 && time.inSeconds >= 10 => (
Duration(milliseconds: (time.inMilliseconds * 1000 / metres).round()),
metres / time.inMilliseconds * 3600,
),
_ => (null, null),
};
final lastFix = recording.fixes.lastOrNull;
return LiveRecording(
activity: recording.activity,
movingTime: movingTimeOf(recording, now),
distanceMetres: gps ? distanceOf(recording) ?? 0 : null,
pace: pace,
kmh: kmh,
accuracyMetres: switch (lastFix) {
final fix? => (fix.accuracyCm / 100).round(),
null => null,
},
waitingForGps:
gps && !recording.fixes.any((fix) => fix.accuracyCm <= maxAccuracyCm),
paused: paused,
autoPaused: paused && last!.auto,
);
}

View file

@ -19,8 +19,8 @@ const maxAccuracyCm = 2000;
activity: recording.activity,
startedAt: recording.startedAt,
endedAt: endedAt,
distanceMetres: _distance(recording),
movingTime: _movingTime(recording, endedAt),
distanceMetres: distanceOf(recording),
movingTime: movingTimeOf(recording, endedAt),
),
_series(recording),
);
@ -28,7 +28,12 @@ const maxAccuracyCm = 2000;
/// The track's length in metres, null without fixes: from fix to fix while
/// it moves, the imprecise ones left out, starting afresh after each pause.
int? _distance(Recording recording) {
int? distanceOf(Recording recording) => switch (_metres(recording)) {
final metres? => metres.round(),
null => null,
};
double? _metres(Recording recording) {
if (recording.fixes.isEmpty) {
return null;
}
@ -51,12 +56,38 @@ int? _distance(Recording recording) {
}
last = fix;
}
return metres.round();
return metres;
}
/// How far [recording] went over its last [window] up to [now], and how
/// long that took from its first fix there to its last: the pace now.
(double metres, Duration time)? recentOf(
Recording recording,
DateTime now, {
Duration window = const Duration(seconds: 30),
}) {
final from = now.subtract(window);
final fixes = [
for (final fix in recording.fixes)
if (!fix.time.isBefore(from) && fix.accuracyCm <= maxAccuracyCm) fix,
];
if (fixes.length < 2) {
return null;
}
final metres = _metres(
Recording(
activity: recording.activity,
startedAt: recording.startedAt,
fixes: fixes,
pauses: recording.pauses,
),
)!;
return (metres, fixes.last.time.difference(fixes.first.time));
}
/// How long it moved: from start to [endedAt], each pause's time left out, one
/// still open at the end running to it.
Duration _movingTime(Recording recording, DateTime endedAt) {
Duration movingTimeOf(Recording recording, DateTime endedAt) {
var moving = endedAt.difference(recording.startedAt);
DateTime? pausedAt;
for (final mark in recording.pauses) {

View file

@ -0,0 +1,87 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// What a recording may ask the system for (ADR 0030, D19).
enum RecorderPermission() {
/// Precise location, for GPS.
location,
/// Showing the recording's notification (Android 13 on).
notifications,
/// Physical activity, for the step detector's auto-pause on foot (D16).
activity,
}
/// How a recording pauses by itself (D16), decided by the recorder.
enum AutoPauseBy() {
/// On foot: by steps, or by GPS speed where steps aren't allowed (D19).
steps,
/// By bike: by GPS speed.
bike,
}
/// What the recording's notification shows (D21), in the app's language.
final class const RecorderNotice({
/// The activity, and whether it is paused: "Running", "Running · paused".
required final String title,
/// The distance so far, as "5.12 km"; empty without GPS.
required final String text,
/// How long it moved when the notice was made ([shownAt]), from which the
/// notification counts on by itself; null while paused.
required final Duration? moving,
required final DateTime shownAt,
/// The buttons' labels, and the notifications channel's name.
required final String pause,
required final String resume,
required final String channelName,
}) with Equatable {
@override
List<Object?> get props => [
title,
text,
moving,
shownAt,
pause,
resume,
channelName,
];
}
/// The system's side of recording (D13): a service that records into a file
/// (D18) while the app may be gone, and its notification.
abstract interface class RecorderPlatform() {
/// Asks for the permissions [wanted] not granted yet, and answers those
/// granted now.
Future<Set<RecorderPermission>> ask(Set<RecorderPermission> wanted);
/// Starts recording [activity] into the file at [path], pausing by itself
/// by [autoPause] (null: never).
Future<void> start({
required String path,
required Activity activity,
required AutoPauseBy? autoPause,
required RecorderNotice notice,
});
/// Pauses, or goes on, by hand.
Future<void> pause();
Future<void> resume();
/// Ends the recording: its file is finished and closed when this completes.
Future<void> stop();
/// Shows [notice] in the recording's notification.
Future<void> show(RecorderNotice notice);
/// Whether a recording runs, as after the app was gone and came back.
Future<bool> isRecording();
/// Every line of the file written so far, then each new one as it is
/// written, while a recording runs.
Stream<String> lines();
}

View file

@ -0,0 +1,43 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// What the recording screen shows of a recording under way (ADR 0030, D21).
final class const LiveRecording({
required final Activity activity,
/// How long it moved so far, its pauses left out (D16).
required final Duration movingTime,
/// How far it went so far; null for an activity without GPS.
required final int? distanceMetres,
/// How long a kilometre takes at the pace of the last half minute; null
/// while paused, or without fixes enough.
final Duration? pace,
/// The speed of the last half minute, in km/h.
final double? kmh,
/// How far off the last fix may be, in whole metres; null before one.
final int? accuracyMetres,
/// Whether it waits for a fix precise enough (D20) to count distance.
final bool waitingForGps = false,
/// Whether it is paused now, and whether Train paused it by itself.
final bool paused = false,
final bool autoPaused = false,
}) with Equatable {
@override
List<Object?> get props => [
activity,
movingTime,
distanceMetres,
pace,
kmh,
accuracyMetres,
waitingForGps,
paused,
autoPaused,
];
}

View file

@ -0,0 +1,95 @@
import 'package:halkyon_train_flutter/l10n/app_localizations.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/workout/model/workout.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// The activities a recording may be of on this version: rowing indoors
/// waits for the rower (5f), which gives its foreground service a type.
const recordedActivities = [
Activity.running,
Activity.walking,
Activity.hiking,
Activity.cycling,
];
/// Whether auto-pause is on by default for [activity] (ADR 0030, D16).
bool autoPausesByDefault(Activity activity) =>
activity == Activity.running || activity == Activity.cycling;
/// The sheet a recording starts from (D21): the activities, the one last
/// recorded chosen, and auto-pause's switch; true once Start was pressed,
/// the start asked of [bloc].
Future<bool> showRecordSheet(BuildContext context, RecordingBloc bloc) async =>
await showAdaptiveSheet<bool>(
context,
builder: (context) => _RecordSheet(bloc: bloc),
) ??
false;
class const _RecordSheet({required final RecordingBloc bloc})
extends StatefulWidget {
@override
State<_RecordSheet> createState() => _RecordSheetState();
}
class _RecordSheetState() extends State<_RecordSheet> {
late var _activity =
recordedActivities.contains(widget.bloc.state.lastActivity)
? widget.bloc.state.lastActivity
: Activity.running;
late var _autoPause = autoPausesByDefault(_activity);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return SheetScaffold(
title: l10n.recordSheetTitle,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
RadioGroup<Activity>(
groupValue: _activity,
onChanged: (activity) => setState(() {
_activity = activity!;
_autoPause = autoPausesByDefault(activity);
}),
child: Column(
children: [
for (final activity in recordedActivities)
RadioListTile<Activity>(
value: activity,
title: Text(activityName(l10n, activity)),
),
],
),
),
const Divider(),
SwitchListTile(
value: _autoPause,
onChanged: (value) => setState(() => _autoPause = value),
title: Text(l10n.recordAutoPause),
subtitle: Text(l10n.recordAutoPauseHint),
),
],
),
footer: FilledButton(
onPressed: () {
widget.bloc.add(
RecordingEventStartRequested(_activity, autoPause: _autoPause),
);
Navigator.of(context).pop(true);
},
child: Text(l10n.recordStart),
),
);
}
}
/// [bloc] where its start ended: recording, or refused location.
Future<RecordingState> startedOrRefused(RecordingBloc bloc) =>
bloc.stream.firstWhere(
(state) =>
state.phase == RecordingPhase.recording || state.locationRefused,
);

View file

@ -0,0 +1,334 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Opens the recording screen over [context]'s, with its bloc.
Future<void> openRecordingScreen(BuildContext context) {
final bloc = context.read<RecordingBloc>();
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => BlocProvider.value(
value: bloc,
child: const RecordingScreen(),
),
),
);
}
/// A recording under way, or stopped (ADR 0030, D21): the moving time, the
/// distance and the pace now, GPS's state, 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.
class const RecordingScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) =>
BlocConsumer<RecordingBloc, RecordingState>(
listenWhen: (before, now) => now.phase == RecordingPhase.idle,
listener: (context, state) => Navigator.of(context).maybePop(),
builder: (context, state) => Scaffold(
appBar: AppBar(
title: Text(
activityName(
AppLocalizations.of(context),
state.live?.activity ??
state.recording?.activity ??
state.lastActivity,
),
),
),
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),
},
),
),
),
),
);
}
class const _Recording({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),
LiveRecording(waitingForGps: true) => l10n.recordingWaitingForGps,
LiveRecording(:final accuracyMetres?) => l10n.recordingGpsAccuracy(
accuracyMetres,
),
_ => null,
};
final paused = switch (live) {
LiveRecording(autoPaused: true) => l10n.recordingPausedAuto,
LiveRecording(paused: true) => l10n.recordingPausedByHand,
_ => null,
};
final pace = showsPace(live.activity);
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
if (gps != null)
Text(
gps,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
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 (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,
),
),
],
),
const Spacer(),
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()),
],
),
],
),
);
}
}
/// Stop, held down so that no pocket ends a run (D21); a tap says so above
/// it for a few seconds, where nothing covers the button.
class const _StopButton() extends StatefulWidget {
@override
State<_StopButton> createState() => _StopButtonState();
}
class _StopButtonState() extends State<_StopButton> {
Timer? _hint;
@override
void dispose() {
_hint?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedOpacity(
opacity: _hint == null ? 0 : 1,
duration: const Duration(milliseconds: 150),
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
l10n.recordingHoldToStop,
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
),
),
GestureDetector(
onLongPress: () => context.read<RecordingBloc>().add(
const RecordingEventStopRequested(),
),
child: OutlinedButton.icon(
onPressed: () {
_hint?.cancel();
setState(() {
_hint = Timer(
const Duration(seconds: 3),
() => setState(() => _hint = null),
);
});
},
icon: const Icon(LucideIcons.square),
label: Text(l10n.recordingStop),
),
),
],
);
}
}
/// A recording stopped, or found cut off: its totals, Save, and Discard,
/// which asks first.
class const _Stopped({required final RecordingState state})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final theme = Theme.of(context);
final recording = state.recording!;
final (content, _) = recordedWorkout(recording);
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.recordingStoppedTitle,
style: theme.textTheme.titleLarge,
textAlign: TextAlign.center,
),
const Spacer(),
_Figure(
value: clockTime(content.movingTime ?? Duration.zero),
label: l10n.recordingMovingTime,
large: true,
),
if (content.distanceMetres case final metres?) ...[
const SizedBox(height: 32),
_Figure(
value: kilometres(locale, metres),
label: l10n.recordingDistance,
),
],
const Spacer(),
if (state.saveFailed) ...[
Text(
l10n.recordingSaveFailed,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
),
const SizedBox(height: 16),
],
FilledButton(
onPressed: () => context.read<RecordingBloc>().add(
const RecordingEventSaveRequested(),
),
child: Text(l10n.recordingSave),
),
const SizedBox(height: 8),
TextButton(
onPressed: () async {
final bloc = context.read<RecordingBloc>();
if (await confirmDestructiveAction(
context,
title: l10n.recordingDiscardTitle,
message: l10n.recordingDiscardMessage,
confirmLabel: l10n.recordingDiscard,
)) {
bloc.add(const RecordingEventDiscardRequested());
}
},
child: Text(l10n.recordingDiscard),
),
],
),
);
}
}
/// A figure with its label beneath.
class const _Figure({
required final String value,
required final String label,
final bool large = false,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
children: [
Text(
value,
style:
(large
? theme.textTheme.displayLarge
: theme.textTheme.headlineMedium)
?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
),
),
Text(
label,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
}

View file

@ -0,0 +1,60 @@
import 'package:clock/clock.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:intl/intl.dart';
/// [activity]'s name in the app's language.
String activityName(AppLocalizations l10n, Activity activity) =>
switch (activity) {
Activity.running => l10n.activityRunning,
Activity.walking => l10n.activityWalking,
Activity.hiking => l10n.activityHiking,
Activity.cycling => l10n.activityCycling,
Activity.indoorRowing => l10n.activityIndoorRowing,
};
/// [time] as a clock shows it: `32:14`, `1:02:03` from an hour on.
String clockTime(Duration time) {
String two(int n) => n.toString().padLeft(2, '0');
final (h, m, s) = (
time.inHours,
time.inMinutes.remainder(60),
time.inSeconds.remainder(60),
);
return h > 0 ? '$h:${two(m)}:${two(s)}' : '$m:${two(s)}';
}
/// [metres] in kilometres, two decimals in the app's language: `5.12`.
String kilometres(String locale, int metres) =>
NumberFormat('0.00', locale).format(metres / 1000);
/// [kmh] with one decimal in the app's language: `24.3`.
String speed(String locale, double kmh) =>
NumberFormat('0.0', locale).format(kmh);
/// Whether [activity] shows its pace (min/km) rather than its speed.
bool showsPace(Activity activity) => activity != Activity.cycling;
/// What the recording's notification shows of [live] (ADR 0030, D21): the
/// activity, paused or not, and the distance; the time counts on by itself.
RecorderNotice noticeOf(
AppLocalizations l10n,
String locale,
LiveRecording live,
) {
final name = activityName(l10n, live.activity);
return RecorderNotice(
title: live.paused ? l10n.recordingNotificationPaused(name) : name,
text: switch (live.distanceMetres) {
final metres? => l10n.recordingDistanceValue(kilometres(locale, metres)),
null => '',
},
moving: live.paused ? null : live.movingTime,
shownAt: clock.now(),
pause: l10n.recordingPause,
resume: l10n.recordingResume,
channelName: l10n.recordingNotificationChannel,
);
}

View file

@ -1,18 +1,28 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/account/account_sheet.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/record_sheet.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A signed-in person's home: nothing yet, as Train keeps nothing until its
/// first model, and the way to the account; a reset of the person's keys
/// waiting above it (`KeysResetBanner`).
/// A signed-in person's home: the way to record a workout where the device
/// records (ADR 0030, D21), or back to the recording under way; the list of
/// workouts comes with 5c. The way to the account, and a reset of the
/// person's keys waiting above it (`KeysResetBanner`). A recording found cut
/// off as it opens is offered to be saved or discarded.
class const HomeScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
final records = PersonScope.of<TrainPerson>(context).recording != null;
final scaffold = Scaffold(
appBar: AppBar(
title: Text(l10n.appTitle),
actions: [
@ -33,12 +43,116 @@ class const HomeScreen({super.key}) extends StatelessWidget {
child: EmptyListMessage(
icon: LucideIcons.listChecks,
title: l10n.homeEmptyTitle,
hint: l10n.homeEmptyHint,
hint: records ? l10n.homeRecordHint : l10n.homeEmptyHint,
),
),
),
],
),
floatingActionButton: records ? const _RecordButton() : null,
);
if (!records) {
return scaffold;
}
return BlocListener<RecordingBloc, RecordingState>(
listenWhen: (before, now) =>
before.phase != now.phase && now.phase == RecordingPhase.cutOff,
listener: (context, state) => _offerCutOff(context, state.recording!),
child: scaffold,
);
}
}
/// Record, or back to the recording under way or stopped.
class const _RecordButton() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return BlocBuilder<RecordingBloc, RecordingState>(
buildWhen: (before, now) => before.phase != now.phase,
builder: (context, state) => switch (state.phase) {
RecordingPhase.loading => const SizedBox.shrink(),
RecordingPhase.recording ||
RecordingPhase.stopped => FloatingActionButton.extended(
onPressed: () => openRecordingScreen(context),
icon: const Icon(LucideIcons.activity),
label: Text(l10n.recordBack),
),
RecordingPhase.idle ||
RecordingPhase.cutOff => FloatingActionButton.extended(
onPressed: () => _record(context),
icon: const Icon(LucideIcons.play),
label: Text(l10n.recordAction),
),
},
);
}
Future<void> _record(BuildContext context) async {
final bloc = context.read<RecordingBloc>();
final started = startedOrRefused(bloc);
if (!await showRecordSheet(context, bloc) || !context.mounted) {
return;
}
final state = await started;
if (!context.mounted) {
return;
}
if (state.locationRefused) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).recordLocationRefused),
),
);
return;
}
await openRecordingScreen(context);
}
}
/// Offers [recording], found cut off, to be saved up to its last sample or
/// discarded, which asks first (D18).
Future<void> _offerCutOff(BuildContext context, Recording recording) async {
final l10n = AppLocalizations.of(context);
final bloc = context.read<RecordingBloc>();
final time = DateFormat.Hm(Localizations.localeOf(context).toString());
final save = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(l10n.recordingCutOffTitle),
content: Text(
l10n.recordingCutOffMessage(
time.format(recording.startedAt.toLocal()),
time.format(recording.lastSampleAt.toLocal()),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.recordingDiscard),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.recordingSave),
),
],
),
);
if (!context.mounted) {
return;
}
if (save ?? false) {
bloc.add(const RecordingEventSaveRequested());
} else if (await confirmDestructiveAction(
context,
title: l10n.recordingDiscardTitle,
message: l10n.recordingDiscardMessage,
confirmLabel: l10n.recordingDiscard,
)) {
bloc.add(const RecordingEventDiscardRequested());
} else if (context.mounted) {
// Kept: offered again.
await _offerCutOff(context, recording);
}
}