feat(train): record a ride indoors, without GPS (ADR 0030, D66)

A home trainer's or an exercise bike's ride had no place: Cycling waits
for GPS and pauses by speed. Cycling indoors needs no GPS, route or
device: the time, then whichever strap, speed and cadence sensor or
power meter there is. It goes to Health Connect as stationary biking,
and another app's stationary ride comes in as one.

With no GPS, steps or device the recorder has no foreground service
type, which Android 14 on doesn't allow, so it asks for the physical
activity permission and doesn't start without it.

Whats-New: Record a ride on a home trainer or exercise bike with Cycling indoors, without GPS.
Whats-New-De: Zeichne eine Fahrt auf dem Heimtrainer mit Radfahren drinnen auf, ganz ohne GPS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-05 16:47:23 +01:00
commit 3bd49920db
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
24 changed files with 275 additions and 52 deletions

View file

@ -49,6 +49,7 @@ object HealthRecords {
"walking" -> ExerciseSessionRecord.EXERCISE_TYPE_WALKING
"hiking" -> ExerciseSessionRecord.EXERCISE_TYPE_HIKING
"cycling" -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING
"indoorCycling" -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY
"indoorRowing" -> ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE
"strength" -> ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING
else -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT
@ -57,7 +58,7 @@ object HealthRecords {
/**
* Train's activity for another app's exercise type; null for one Train
* doesn't record, which isn't brought in. A treadmill's run is a run, a
* stationary bike's ride a ride.
* stationary bike's ride a ride indoors (D66).
*/
fun activityOf(exercise: Int): String? =
when (exercise) {
@ -66,9 +67,8 @@ object HealthRecords {
-> "running"
ExerciseSessionRecord.EXERCISE_TYPE_WALKING -> "walking"
ExerciseSessionRecord.EXERCISE_TYPE_HIKING -> "hiking"
ExerciseSessionRecord.EXERCISE_TYPE_BIKING,
ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY,
-> "cycling"
ExerciseSessionRecord.EXERCISE_TYPE_BIKING -> "cycling"
ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY -> "indoorCycling"
ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE -> "indoorRowing"
else -> null
}

View file

@ -258,8 +258,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private fun begin(intent: Intent) {
path = intent.getStringExtra(EXTRA_PATH) ?: return stopSelf()
val activity = intent.getStringExtra(EXTRA_ACTIVITY) ?: return stopSelf()
// No GPS on the rower, whatever location allows.
val located = activity != "indoorRowing" && granted(Manifest.permission.ACCESS_FINE_LOCATION)
// No GPS indoors, whatever location allows.
val indoors = activity == "indoorRowing" || activity == "indoorCycling"
val located = !indoors && granted(Manifest.permission.ACCESS_FINE_LOCATION)
val counted =
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || granted(Manifest.permission.ACTIVITY_RECOGNITION)
// The strap and the rower where they were chosen and connecting is
@ -285,7 +286,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
notification(),
types(
located,
stepping,
// A ride indoors may have no GPS, steps or device (D66); the app
// asked for the permission that allows this type.
stepping || (activity == "indoorCycling" && counted),
linked = listOf(strapAt, rowerAt, loopAt, speedSensorAt, powerMeterAt).any { it != null },
),
)
@ -600,7 +603,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
/**
* The foreground service's types: location with GPS, health with the step
* detector, connectedDevice with the strap or the rower (Android 14 on asks).
* detector or on a ride indoors, connectedDevice with the strap or the rower
* (Android 14 on asks).
*/
private fun types(located: Boolean, stepping: Boolean, linked: Boolean): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return 0

View file

@ -135,10 +135,11 @@ class HealthRecordsTest {
@Test
fun `other apps' exercise types map onto Train's activities, or none`() {
assertEquals("running", HealthRecords.activityOf(ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_TREADMILL))
assertEquals("cycling", HealthRecords.activityOf(ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY))
assertEquals("cycling", HealthRecords.activityOf(ExerciseSessionRecord.EXERCISE_TYPE_BIKING))
assertEquals("indoorCycling", HealthRecords.activityOf(ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY))
assertEquals("indoorRowing", HealthRecords.activityOf(ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE))
assertNull(HealthRecords.activityOf(ExerciseSessionRecord.EXERCISE_TYPE_YOGA))
for (activity in listOf("running", "walking", "hiking", "cycling", "indoorRowing")) {
for (activity in listOf("running", "walking", "hiking", "cycling", "indoorCycling", "indoorRowing")) {
assertEquals(activity, HealthRecords.activityOf(HealthRecords.exerciseOf(activity)))
}
}

View file

@ -15,6 +15,7 @@
"activityHiking": "Wandern",
"activityCycling": "Radfahren",
"activityStrength": "Krafttraining",
"activityIndoorCycling": "Radfahren drinnen",
"activityIndoorRowing": "Rudern drinnen",
"activityOther": "Anderes Training",
"recordAutoPause": "Von selbst pausieren",
@ -28,6 +29,7 @@
"recordingNextStep": "Danach: {step}",
"recordingStepTapLap": "Bis du auf Runde tippst",
"recordLocationRefused": "Train braucht deinen Standort, um das aufzuzeichnen. Du kannst ihn in den Einstellungen des Systems erlauben.",
"recordActivityRefused": "Train braucht die Berechtigung für körperliche Aktivität, um eine Fahrt drinnen aufzuzeichnen. Du kannst sie in den Systemeinstellungen erlauben.",
"recordingWaitingForGps": "Warte auf GPS",
"recordingGpsSilent": "Seit einer Minute kein GPS. Ist der Standort an und darf Train deinen genauen Standort verwenden?",
"recordingWaitingForGpsAccuracy": "Warte auf GPS · ±{metres} m",
@ -55,7 +57,7 @@
"recordingCutOffMessage": "Eine Aufzeichnung von {start} wurde abgebrochen. Bis {end} speichern oder verwerfen?",
"recordingNotificationChannel": "Aufzeichnung",
"recordingNotificationPaused": "{activity} · pausiert",
"workoutName": "{when, select, morning_strength{Krafttraining am Morgen} afternoon_strength{Krafttraining am Nachmittag} evening_strength{Krafttraining am Abend} night_strength{Krafttraining in der Nacht} morning_running{Morgenlauf} morning_walking{Morgenspaziergang} morning_hiking{Morgenwanderung} morning_cycling{Morgenradfahrt} morning_indoorRowing{Rudern am Morgen} afternoon_running{Nachmittagslauf} afternoon_walking{Nachmittagsspaziergang} afternoon_hiking{Nachmittagswanderung} afternoon_cycling{Nachmittagsradfahrt} afternoon_indoorRowing{Rudern am Nachmittag} evening_running{Abendlauf} evening_walking{Abendspaziergang} evening_hiking{Abendwanderung} evening_cycling{Abendradfahrt} evening_indoorRowing{Rudern am Abend} night_running{Nachtlauf} night_walking{Nachtspaziergang} night_hiking{Nachtwanderung} night_cycling{Nachtradfahrt} night_indoorRowing{Rudern in der Nacht} other{Training}}",
"workoutName": "{when, select, morning_strength{Krafttraining am Morgen} afternoon_strength{Krafttraining am Nachmittag} evening_strength{Krafttraining am Abend} night_strength{Krafttraining in der Nacht} morning_running{Morgenlauf} morning_walking{Morgenspaziergang} morning_hiking{Morgenwanderung} morning_cycling{Morgenradfahrt} morning_indoorCycling{Radfahren drinnen am Morgen} afternoon_indoorCycling{Radfahren drinnen am Nachmittag} evening_indoorCycling{Radfahren drinnen am Abend} night_indoorCycling{Radfahren drinnen in der Nacht} morning_indoorRowing{Rudern am Morgen} afternoon_running{Nachmittagslauf} afternoon_walking{Nachmittagsspaziergang} afternoon_hiking{Nachmittagswanderung} afternoon_cycling{Nachmittagsradfahrt} afternoon_indoorRowing{Rudern am Nachmittag} evening_running{Abendlauf} evening_walking{Abendspaziergang} evening_hiking{Abendwanderung} evening_cycling{Abendradfahrt} evening_indoorRowing{Rudern am Abend} night_running{Nachtlauf} night_walking{Nachtspaziergang} night_hiking{Nachtwanderung} night_cycling{Nachtradfahrt} night_indoorRowing{Rudern in der Nacht} other{Training}}",
"workoutLocked": "Gesperrtes Training",
"workoutLockedHint": "Dieses Gerät hat den Schlüssel nicht, mit dem es versiegelt ist.",
"workoutEdit": "Bearbeiten",

View file

@ -21,6 +21,7 @@
"activityHiking": "Hiking",
"activityCycling": "Cycling",
"activityStrength": "Strength training",
"activityIndoorCycling": "Cycling indoors",
"activityIndoorRowing": "Rowing indoors",
"activityOther": "Other workout",
"recordAutoPause": "Pause by itself",
@ -54,6 +55,7 @@
},
"recordingStepTapLap": "Until you tap Lap",
"recordLocationRefused": "Train needs your location to record this. You can allow it in the system's settings.",
"recordActivityRefused": "Train needs the physical activity permission to record a ride indoors. You can allow it in the system's settings.",
"recordingWaitingForGps": "Waiting for GPS",
"recordingGpsSilent": "No GPS for a minute. Is location on, and is Train allowed your precise location?",
"@recordingGpsSilent": {
@ -156,7 +158,7 @@
}
}
},
"workoutName": "{when, select, morning_strength{Morning strength} afternoon_strength{Afternoon strength} evening_strength{Evening strength} night_strength{Night strength} morning_running{Morning run} morning_walking{Morning walk} morning_hiking{Morning hike} morning_cycling{Morning ride} morning_indoorRowing{Morning row} afternoon_running{Afternoon run} afternoon_walking{Afternoon walk} afternoon_hiking{Afternoon hike} afternoon_cycling{Afternoon ride} afternoon_indoorRowing{Afternoon row} evening_running{Evening run} evening_walking{Evening walk} evening_hiking{Evening hike} evening_cycling{Evening ride} evening_indoorRowing{Evening row} night_running{Night run} night_walking{Night walk} night_hiking{Night hike} night_cycling{Night ride} night_indoorRowing{Night row} other{Workout}}",
"workoutName": "{when, select, morning_strength{Morning strength} afternoon_strength{Afternoon strength} evening_strength{Evening strength} night_strength{Night strength} morning_running{Morning run} morning_walking{Morning walk} morning_hiking{Morning hike} morning_cycling{Morning ride} morning_indoorCycling{Morning indoor ride} afternoon_indoorCycling{Afternoon indoor ride} evening_indoorCycling{Evening indoor ride} night_indoorCycling{Night indoor ride} morning_indoorRowing{Morning row} afternoon_running{Afternoon run} afternoon_walking{Afternoon walk} afternoon_hiking{Afternoon hike} afternoon_cycling{Afternoon ride} afternoon_indoorRowing{Afternoon row} evening_running{Evening run} evening_walking{Evening walk} evening_hiking{Evening hike} evening_cycling{Evening ride} evening_indoorRowing{Evening row} night_running{Night run} night_walking{Night walk} night_hiking{Night hike} night_cycling{Night ride} night_indoorRowing{Night row} other{Workout}}",
"@workoutName": {
"description": "A workout's name where the person gave none: the time of day it started (morning 5–12, afternoon 12–17, evening 17–22, night) and its activity, as Strava names them.",
"placeholders": {

View file

@ -190,6 +190,12 @@ abstract class AppLocalizations {
/// **'Strength training'**
String get activityStrength;
/// No description provided for @activityIndoorCycling.
///
/// In en, this message translates to:
/// **'Cycling indoors'**
String get activityIndoorCycling;
/// No description provided for @activityIndoorRowing.
///
/// In en, this message translates to:
@ -268,6 +274,12 @@ abstract class AppLocalizations {
/// **'Train needs your location to record this. You can allow it in the system\'s settings.'**
String get recordLocationRefused;
/// No description provided for @recordActivityRefused.
///
/// In en, this message translates to:
/// **'Train needs the physical activity permission to record a ride indoors. You can allow it in the system\'s settings.'**
String get recordActivityRefused;
/// No description provided for @recordingWaitingForGps.
///
/// In en, this message translates to:
@ -433,7 +445,7 @@ abstract class AppLocalizations {
/// A workout's name where the person gave none: the time of day it started (morning 5–12, afternoon 12–17, evening 17–22, night) and its activity, as Strava names them.
///
/// In en, this message translates to:
/// **'{when, select, morning_strength{Morning strength} afternoon_strength{Afternoon strength} evening_strength{Evening strength} night_strength{Night strength} morning_running{Morning run} morning_walking{Morning walk} morning_hiking{Morning hike} morning_cycling{Morning ride} morning_indoorRowing{Morning row} afternoon_running{Afternoon run} afternoon_walking{Afternoon walk} afternoon_hiking{Afternoon hike} afternoon_cycling{Afternoon ride} afternoon_indoorRowing{Afternoon row} evening_running{Evening run} evening_walking{Evening walk} evening_hiking{Evening hike} evening_cycling{Evening ride} evening_indoorRowing{Evening row} night_running{Night run} night_walking{Night walk} night_hiking{Night hike} night_cycling{Night ride} night_indoorRowing{Night row} other{Workout}}'**
/// **'{when, select, morning_strength{Morning strength} afternoon_strength{Afternoon strength} evening_strength{Evening strength} night_strength{Night strength} morning_running{Morning run} morning_walking{Morning walk} morning_hiking{Morning hike} morning_cycling{Morning ride} morning_indoorCycling{Morning indoor ride} afternoon_indoorCycling{Afternoon indoor ride} evening_indoorCycling{Evening indoor ride} night_indoorCycling{Night indoor ride} morning_indoorRowing{Morning row} afternoon_running{Afternoon run} afternoon_walking{Afternoon walk} afternoon_hiking{Afternoon hike} afternoon_cycling{Afternoon ride} afternoon_indoorRowing{Afternoon row} evening_running{Evening run} evening_walking{Evening walk} evening_hiking{Evening hike} evening_cycling{Evening ride} evening_indoorRowing{Evening row} night_running{Night run} night_walking{Night walk} night_hiking{Night hike} night_cycling{Night ride} night_indoorRowing{Night row} other{Workout}}'**
String workoutName(String when);
/// No description provided for @workoutLocked.

View file

@ -57,6 +57,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get activityStrength => 'Krafttraining';
@override
String get activityIndoorCycling => 'Radfahren drinnen';
@override
String get activityIndoorRowing => 'Rudern drinnen';
@ -102,6 +105,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get recordLocationRefused =>
'Train braucht deinen Standort, um das aufzuzeichnen. Du kannst ihn in den Einstellungen des Systems erlauben.';
@override
String get recordActivityRefused =>
'Train braucht die Berechtigung für körperliche Aktivität, um eine Fahrt drinnen aufzuzeichnen. Du kannst sie in den Systemeinstellungen erlauben.';
@override
String get recordingWaitingForGps => 'Warte auf GPS';
@ -214,6 +221,10 @@ class AppLocalizationsDe extends AppLocalizations {
'morning_walking': 'Morgenspaziergang',
'morning_hiking': 'Morgenwanderung',
'morning_cycling': 'Morgenradfahrt',
'morning_indoorCycling': 'Radfahren drinnen am Morgen',
'afternoon_indoorCycling': 'Radfahren drinnen am Nachmittag',
'evening_indoorCycling': 'Radfahren drinnen am Abend',
'night_indoorCycling': 'Radfahren drinnen in der Nacht',
'morning_indoorRowing': 'Rudern am Morgen',
'afternoon_running': 'Nachmittagslauf',
'afternoon_walking': 'Nachmittagsspaziergang',

View file

@ -56,6 +56,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get activityStrength => 'Strength training';
@override
String get activityIndoorCycling => 'Cycling indoors';
@override
String get activityIndoorRowing => 'Rowing indoors';
@ -101,6 +104,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get recordLocationRefused =>
'Train needs your location to record this. You can allow it in the system\'s settings.';
@override
String get recordActivityRefused =>
'Train needs the physical activity permission to record a ride indoors. You can allow it in the system\'s settings.';
@override
String get recordingWaitingForGps => 'Waiting for GPS';
@ -212,6 +219,10 @@ class AppLocalizationsEn extends AppLocalizations {
'morning_walking': 'Morning walk',
'morning_hiking': 'Morning hike',
'morning_cycling': 'Morning ride',
'morning_indoorCycling': 'Morning indoor ride',
'afternoon_indoorCycling': 'Afternoon indoor ride',
'evening_indoorCycling': 'Evening indoor ride',
'night_indoorCycling': 'Night indoor ride',
'morning_indoorRowing': 'Morning row',
'afternoon_running': 'Afternoon run',
'afternoon_walking': 'Afternoon walk',

View file

@ -179,12 +179,16 @@ class RecordingBloc(
state.copyWith(
locationRefused: false,
bluetoothRefused: false,
activityRefused: false,
rowerMissing: false,
),
);
final activity = event.activity;
final gps = usesGps(activity);
final rows = activity == Activity.indoorRowing;
// With no GPS and perhaps no device, the physical activity's permission
// is what lets Android 14 on run the service (D66).
final indoorRide = activity == Activity.indoorCycling;
final rower = state.rower;
if (rows && rower == null) {
emit(state.copyWith(rowerMissing: true));
@ -198,6 +202,8 @@ class RecordingBloc(
Activity.walking ||
Activity.hiking => AutoPauseBy.steps,
Activity.indoorRowing => AutoPauseBy.strokes,
// Nothing tells a ride indoors from a rest (D66).
Activity.indoorCycling => null,
// Not recorded on the phone (D39): only the Loop brings it; a
// strength session is the app's (ADR 0035).
Activity.other || Activity.strength => null,
@ -209,7 +215,7 @@ class RecordingBloc(
RecorderPermission.notifications,
// Steps on foot whatever pauses it: they are the workout's cadence
// and what its music follows (D48).
if (autoPause == AutoPauseBy.steps || playsMusic(activity))
if (autoPause == AutoPauseBy.steps || playsMusic(activity) || indoorRide)
RecorderPermission.activity,
});
if (gps && !granted.contains(RecorderPermission.location)) {
@ -220,6 +226,10 @@ class RecordingBloc(
emit(state.copyWith(bluetoothRefused: true));
return;
}
if (indoorRide && !granted.contains(RecorderPermission.activity)) {
emit(state.copyWith(activityRefused: true));
return;
}
final music = switch (musicOf) {
final musicOf? => await musicOf(event.music, activity),
null => null,
@ -249,15 +259,11 @@ class RecordingBloc(
notice: notice,
strap: event.useStrap ? state.strap?.address : null,
rower: rows ? rower?.address : null,
speedSensor: activity == Activity.cycling
? state.speedSensor?.address
: null,
wheelMm: activity == Activity.cycling && state.speedSensor != null
speedSensor: activity.rides ? state.speedSensor?.address : null,
wheelMm: activity.rides && state.speedSensor != null
? state.speedSensor?.wheelMm ?? defaultWheelMm
: null,
powerMeter: activity == Activity.cycling
? state.powerMeter?.address
: null,
powerMeter: activity.rides ? state.powerMeter?.address : null,
loop: loop?.call(),
plan: plan,
route: route,
@ -501,6 +507,7 @@ final class const RecordingState({
/// Whether nearby devices were refused as a row was to start (D28).
final bool bluetoothRefused = false,
final bool activityRefused = false,
/// Whether a row was to start without a rower chosen.
final bool rowerMissing = false,
@ -523,6 +530,7 @@ final class const RecordingState({
bool? locationRefused,
bool? saveFailed,
bool? bluetoothRefused,
bool? activityRefused,
bool? rowerMissing,
}) => RecordingState(
phase: phase,
@ -532,6 +540,7 @@ final class const RecordingState({
locationRefused: locationRefused ?? this.locationRefused,
saveFailed: saveFailed ?? this.saveFailed,
bluetoothRefused: bluetoothRefused ?? this.bluetoothRefused,
activityRefused: activityRefused ?? this.activityRefused,
rowerMissing: rowerMissing ?? this.rowerMissing,
devices: devices,
);
@ -545,6 +554,7 @@ final class const RecordingState({
locationRefused: locationRefused,
saveFailed: saveFailed,
bluetoothRefused: bluetoothRefused,
activityRefused: activityRefused,
rowerMissing: rowerMissing,
devices: {
for (final MapEntry(:key, :value) in devices.entries)
@ -562,6 +572,7 @@ final class const RecordingState({
locationRefused,
saveFailed,
bluetoothRefused,
activityRefused,
rowerMissing,
devices,
saved,

View file

@ -5,8 +5,9 @@ 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;
/// Whether [activity] is recorded with GPS: all but what is done indoors.
bool usesGps(Activity activity) =>
activity != Activity.indoorRowing && activity != Activity.indoorCycling;
/// How old a heart rate, a stroke rate or watts may be and still show: a
/// strap and a rower send about once a second.
@ -97,7 +98,9 @@ LiveRecording liveOf(Recording recording, DateTime now) {
return LiveRecording(
activity: recording.activity,
movingTime: movingTimeOf(recording, now),
distanceMetres: gps || rows ? distanceOf(recording) ?? 0 : null,
distanceMetres: gps || rows || recording.speedSensors.isNotEmpty
? distanceOf(recording) ?? 0
: null,
pace: pace,
kmh: kmh,
accuracyMetres: switch (lastFix) {

View file

@ -6,11 +6,14 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
const _distancesSpelled = 100;
/// Every how many metres a recording of [activity] is announced (ADR 0030,
/// D49): every kilometre on foot, every 5 km by bike, never on a rower.
/// D49): every kilometre on foot, every 5 km by bike, never indoors.
int? announceEvery(Activity activity) => switch (activity) {
Activity.running || Activity.walking || Activity.hiking => 1000,
Activity.cycling => 5000,
Activity.indoorRowing || Activity.other || Activity.strength => null,
Activity.indoorCycling ||
Activity.indoorRowing ||
Activity.other ||
Activity.strength => null,
};
/// The announcements' words for [activity] as the recorder takes them, in

View file

@ -9,6 +9,7 @@ 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';
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/live_recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/announce_speech.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/device_sheet.dart';
@ -22,12 +23,13 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The activities a recording may be of: rowing indoors with the rower
/// (D28).
/// (D28), a ride indoors with whatever sensors there are (D66).
const recordedActivities = [
Activity.running,
Activity.walking,
Activity.hiking,
Activity.cycling,
Activity.indoorCycling,
Activity.indoorRowing,
];
@ -103,16 +105,18 @@ class _RecordSheetState() extends State<_RecordSheet> {
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SwitchListTile(
value: _autoPause,
onChanged: (value) => setState(() => _autoPause = value),
title: Text(l10n.recordAutoPause),
subtitle: Text(
_activity == Activity.indoorRowing
? l10n.recordAutoPauseRowingHint
: l10n.recordAutoPauseHint,
// Indoors nothing tells a ride from a rest (D66).
if (_activity != Activity.indoorCycling)
SwitchListTile(
value: _autoPause,
onChanged: (value) => setState(() => _autoPause = value),
title: Text(l10n.recordAutoPause),
subtitle: Text(
_activity == Activity.indoorRowing
? l10n.recordAutoPauseRowingHint
: l10n.recordAutoPauseHint,
),
),
),
if (announceEvery(_activity) != null)
SwitchListTile(
value: _announced.contains(_activity),
@ -151,7 +155,7 @@ class _RecordSheetState() extends State<_RecordSheet> {
onTap: () => _chooseDevice(context, DeviceKind.rower),
),
),
if (_activity == Activity.cycling)
if (_activity.rides)
for (final (kind, title, none, icon) in [
(
DeviceKind.speedSensor,
@ -185,7 +189,7 @@ class _RecordSheetState() extends State<_RecordSheet> {
onTap: () => _chooseDevice(context, kind),
),
),
if (_activity == Activity.cycling)
if (_activity.rides)
BlocBuilder<RecordingBloc, RecordingState>(
bloc: widget.bloc,
buildWhen: (before, now) => before.speedSensor != now.speedSensor,
@ -248,7 +252,7 @@ class _RecordSheetState() extends State<_RecordSheet> {
}
},
),
if (_activity != Activity.indoorRowing)
if (usesGps(_activity))
ListTile(
leading: const Icon(LucideIcons.route),
title: Text(l10n.recordRoute),
@ -278,7 +282,7 @@ class _RecordSheetState() extends State<_RecordSheet> {
autoPause: _autoPause,
useStrap: _useStrap,
plan: _plan,
route: _activity == Activity.indoorRowing ? null : _route,
route: usesGps(_activity) ? _route : null,
music: playsMusic(_activity) ? _music : MusicChoice.off,
announce:
announceEvery(_activity) != null &&
@ -476,14 +480,15 @@ Future<({RouteContent? route})?> _chooseRoute(
);
}
/// [bloc] where its start ended: recording, refused location or nearby
/// devices, or without a rower.
/// [bloc] where its start ended: recording, refused location, nearby
/// devices or the activity's permission, or without a rower.
Future<RecordingState> startedOrRefused(RecordingBloc bloc) =>
bloc.stream.firstWhere(
(state) =>
state.phase == RecordingPhase.recording ||
state.locationRefused ||
state.bluetoothRefused ||
state.activityRefused ||
state.rowerMissing,
);

View file

@ -12,6 +12,7 @@ String activityName(AppLocalizations l10n, Activity activity) =>
Activity.walking => l10n.activityWalking,
Activity.hiking => l10n.activityHiking,
Activity.cycling => l10n.activityCycling,
Activity.indoorCycling => l10n.activityIndoorCycling,
Activity.indoorRowing => l10n.activityIndoorRowing,
Activity.strength => l10n.activityStrength,
Activity.other => l10n.activityOther,
@ -41,7 +42,7 @@ String strokeRateText(String locale, double rate) =>
NumberFormat('0.#', locale).format(rate);
/// Whether [activity] shows its pace (min/km) rather than its speed.
bool showsPace(Activity activity) => activity != Activity.cycling;
bool showsPace(Activity activity) => !activity.rides;
/// 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.

View file

@ -107,6 +107,8 @@ Uint8List fitOf(WorkoutContent content, ExportTrack track) {
final (sport, subSport) = switch (content.activity) {
Activity.running => (1, 0),
Activity.cycling => (2, 0),
// Cycling, indoor cycling (D66).
Activity.indoorCycling => (2, 6),
Activity.walking => (11, 0),
Activity.hiking => (17, 0),
Activity.indoorRowing => (15, 14),

View file

@ -110,7 +110,7 @@ final class const WorkoutAnalysis({
/// How long a split is for [activity].
int splitMetresOf(Activity activity) => switch (activity) {
Activity.cycling => 5000,
Activity.cycling || Activity.indoorCycling => 5000,
Activity.indoorRowing => 500,
_ => 1000,
};
@ -194,7 +194,7 @@ WorkoutAnalysis analyse(WorkoutContent content, List<SeriesChunk> series) {
chart: _chart(
progress,
per: switch (content.activity) {
Activity.cycling => null,
Activity.cycling || Activity.indoorCycling => null,
Activity.indoorRowing => 500,
_ => 1000,
},

View file

@ -400,7 +400,7 @@ String tcxOf(WorkoutContent content, ExportTrack track) {
attributes: {
'Sport': switch (content.activity) {
Activity.running => 'Running',
Activity.cycling => 'Biking',
Activity.cycling || Activity.indoorCycling => 'Biking',
_ => 'Other',
},
},

View file

@ -11,6 +11,10 @@ enum Activity() {
walking,
hiking,
cycling,
/// On a home trainer or an exercise bike (D66): no GPS, no route, what
/// the person's sensors give.
indoorCycling,
indoorRowing,
/// Strength training (ADR 0035): sets of exercises, in the app alone.
@ -18,7 +22,11 @@ enum Activity() {
/// What Train has no activity of its own for: most of what the Loop
/// detects by itself (ADR 0030, D39), which the person can name.
other,
other;
/// Whether it is a ride, out of doors or in, with a bike's sensors and
/// figures.
bool get rides => this == cycling || this == indoorCycling;
}
/// The [WorkoutOrigin.app] of a workout the Polar Loop recorded (D39).

View file

@ -13,6 +13,7 @@ import 'package:halkyon_train_flutter/src/app/train_paths.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/track_camera.dart';
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/live_recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_train_flutter/src/feature/strength/widget/workout_strength_view.dart';
import 'package:halkyon_train_flutter/src/feature/weather/widget/workout_weather_view.dart';
@ -713,10 +714,11 @@ List<Widget> _details(
style: theme.textTheme.bodyMedium,
),
// Another app's route asks the person's consent for each session
// (D31), so it comes only when asked for; a row has none.
// (D31), so it comes only when asked for; what is done indoors has
// none.
if (!hasTrack &&
origin.fromHealthConnect &&
content.activity != Activity.indoorRowing &&
usesGps(content.activity) &&
PersonScope.of<TrainPerson>(context).health != null)
Align(
alignment: AlignmentDirectional.centerStart,
@ -951,7 +953,7 @@ class const _Totals({
value: pace,
label: switch (content.activity) {
Activity.indoorRowing => l10n.workoutAverageSplit,
Activity.cycling => l10n.workoutSpeed,
Activity.cycling || Activity.indoorCycling => l10n.workoutSpeed,
_ => l10n.workoutPace,
},
),

View file

@ -25,7 +25,7 @@ IconData activityIcon(Activity activity) => switch (activity) {
Activity.running => LucideIcons.activity,
Activity.walking => LucideIcons.footprints,
Activity.hiking => LucideIcons.mountain,
Activity.cycling => LucideIcons.bike,
Activity.cycling || Activity.indoorCycling => LucideIcons.bike,
Activity.indoorRowing => LucideIcons.waves,
Activity.strength => LucideIcons.dumbbell,
Activity.other => LucideIcons.heartPulse,

View file

@ -117,6 +117,7 @@ Future<void> recordActivity(BuildContext context, Activity activity) async {
final refused = switch (state) {
RecordingState(locationRefused: true) => l10n.recordLocationRefused,
RecordingState(bluetoothRefused: true) => l10n.recordRowerRefused,
RecordingState(activityRefused: true) => l10n.recordActivityRefused,
RecordingState(rowerMissing: true) => l10n.recordNoRower,
_ => null,
};

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