feat(train): a workout keeps its plan's steps as laps, shown on its page (ADR 0030, D43)

As a recording with a plan is saved, each step run becomes a lap in the
workout's sealed payload: the step, its round, when it started and ended,
its moving time and distance, with the highest heart rate the zones ran
with. The page lists the steps with their pace, speed or split, stroke
rate, watts and heart rate, marks whether each held its target, and the
charts shade the work steps.

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 23:12:34 +01:00
commit e1304975d6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
16 changed files with 840 additions and 38 deletions

View file

@ -83,6 +83,9 @@
"workoutClimb": "Aufstieg",
"workoutClimbValue": "{metres} m",
"workoutSplits": "Abschnitte",
"workoutSteps": "Schritte aus {plan}",
"workoutOnTarget": "Im Ziel",
"workoutOffTarget": "Neben dem Ziel",
"workoutSplitKm": "km {number}",
"workoutSplitFiveKm": "{from}–{to} km",
"workoutPaceChart": "Tempo",

View file

@ -103,6 +103,10 @@
"workoutClimbValue": "{metres} m",
"@workoutClimbValue": {"placeholders": {"metres": {"type": "int"}}},
"workoutSplits": "Splits",
"workoutSteps": "Steps of {plan}",
"@workoutSteps": {"placeholders": {"plan": {"type": "String"}}},
"workoutOnTarget": "On target",
"workoutOffTarget": "Off target",
"workoutSplitKm": "km {number}",
"@workoutSplitKm": {"description": "A kilometre's split, its number.", "placeholders": {"number": {"type": "int"}}},
"workoutSplitFiveKm": "{from}–{to} km",

View file

@ -556,6 +556,24 @@ abstract class AppLocalizations {
/// **'Splits'**
String get workoutSplits;
/// No description provided for @workoutSteps.
///
/// In en, this message translates to:
/// **'Steps of {plan}'**
String workoutSteps(String plan);
/// No description provided for @workoutOnTarget.
///
/// In en, this message translates to:
/// **'On target'**
String get workoutOnTarget;
/// No description provided for @workoutOffTarget.
///
/// In en, this message translates to:
/// **'Off target'**
String get workoutOffTarget;
/// A kilometre's split, its number.
///
/// In en, this message translates to:

View file

@ -294,6 +294,17 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get workoutSplits => 'Abschnitte';
@override
String workoutSteps(String plan) {
return 'Schritte aus $plan';
}
@override
String get workoutOnTarget => 'Im Ziel';
@override
String get workoutOffTarget => 'Neben dem Ziel';
@override
String workoutSplitKm(int number) {
return 'km $number';

View file

@ -292,6 +292,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get workoutSplits => 'Splits';
@override
String workoutSteps(String plan) {
return 'Steps of $plan';
}
@override
String get workoutOnTarget => 'On target';
@override
String get workoutOffTarget => 'Off target';
@override
String workoutSplitKm(int number) {
return 'km $number';

View file

@ -73,7 +73,7 @@ PlanContent? decodePlan(ByteData payload) {
when repeat > 0)
PlanBlock(
repeat: repeat,
steps: [for (final step in steps) ?_stepOf(step)],
steps: [for (final step in steps) ?planStepOf(step)],
),
],
);
@ -83,7 +83,9 @@ PlanContent? decodePlan(ByteData payload) {
}
}
PlanStep? _stepOf(Object? json) {
/// The step [json] holds, as [planStepJson] writes it; null where it can't
/// read it.
PlanStep? planStepOf(Object? json) {
if (json is! Map<String, Object?>) {
return null;
}

View file

@ -20,8 +20,8 @@ int planMaxHeartRate(ProfileContent? profile, DateTime today) =>
defaultMaxHeartRate(profile?.ageOn(today) ?? _guessedAge);
/// [plan] as the recorder runs it (ADR 0030, D42), in the app's language
/// [locale] (a BCP 47 tag): `plan`, its payload, which the recording file
/// keeps; `steps`, the run spelled out, each with its end (`seconds` or
/// [locale] (a BCP 47 tag): `plan`, its payload and [maxHeartRate], which
/// the recording file keeps; `steps`, the run spelled out, each with its end (`seconds` or
/// `metres`, neither for a lap tap), its target, zones as the heart rates
/// they span for [maxHeartRate], and what is said as it starts; `say`, the
/// words for off target and for the end.
@ -31,7 +31,12 @@ Map<String, Object?> recorderPlanOf(
PlanContent plan, {
required int maxHeartRate,
}) => {
'plan': jsonDecode(utf8.decode(Uint8List.sublistView(encodePlan(plan)))),
'plan': {
...jsonDecode(utf8.decode(Uint8List.sublistView(encodePlan(plan))))
as Map<String, Object?>,
// For the workout's laps, which say whether a zone was held (D43).
'maxHeartRate': maxHeartRate,
},
'offTargetCues': plan.offTargetCues,
'locale': locale,
'steps': [

View file

@ -101,7 +101,7 @@ LivePlan? _planOf(Recording recording, DateTime now) {
return LivePlan(name: plan.name, index: run.length, count: run.length);
}
final step = run[index];
final before = _until(recording, mark.time);
final before = recordingUntil(recording, mark.time);
return LivePlan(
name: plan.name,
step: step,
@ -124,22 +124,3 @@ LivePlan? _planOf(Recording recording, DateTime now) {
}
Duration _atLeastZero(Duration time) => time.isNegative ? Duration.zero : time;
/// [recording] as it stood at [time]: its fixes, frames and pauses until
/// then.
Recording _until(Recording recording, DateTime time) => Recording(
activity: recording.activity,
startedAt: recording.startedAt,
fixes: [
for (final fix in recording.fixes)
if (!fix.time.isAfter(time)) fix,
],
pauses: [
for (final mark in recording.pauses)
if (!mark.time.isAfter(time)) mark,
],
rowing: [
for (final sample in recording.rowing)
if (!sample.time.isAfter(time)) sample,
],
);

View file

@ -11,7 +11,7 @@ const maxAccuracyCm = 2000;
/// [recording] as the workout it saves (D14) and its series: every sample
/// kept (D16, D20), the distance through the fixes precise enough and never
/// across a pause, the moving time without the pauses. One cut off ends at
/// its last sample (D18).
/// its last sample (D18). A plan's steps are its laps (D43).
(WorkoutContent, List<SeriesChunk>) recordedWorkout(Recording recording) {
final endedAt = recording.endedAt ?? recording.lastSampleAt;
return (
@ -21,11 +21,82 @@ const maxAccuracyCm = 2000;
endedAt: endedAt,
distanceMetres: distanceOf(recording),
movingTime: movingTimeOf(recording, endedAt),
plan: _planOf(recording, endedAt),
),
seriesOf(recording),
);
}
/// [recording]'s plan as it ran until [endedAt]: a lap from each step's
/// start to the next's, the plan's end or the recording's, its moving time
/// and distance counted as the workout's are.
WorkoutPlan? _planOf(Recording recording, DateTime endedAt) {
final plan = recording.plan;
if (plan == null) {
return null;
}
final run = plan.run;
final marks = recording.planSteps;
final measured = distanceOf(recording) != null;
final laps = <WorkoutLap>[];
for (final (i, mark) in marks.indexed) {
final index = mark.index;
if (index == null || index >= run.length) {
continue;
}
final next = i + 1 < marks.length ? marks[i + 1].time : endedAt;
final end = next.isAfter(endedAt) ? endedAt : next;
if (!end.isAfter(mark.time)) {
continue;
}
final (before, after) = (
recordingUntil(recording, mark.time),
recordingUntil(recording, end),
);
laps.add(
WorkoutLap(
step: run[index].step,
round: run[index].round,
rounds: run[index].rounds,
startedAt: mark.time,
endedAt: end,
movingTime: movingTimeOf(after, end) - movingTimeOf(before, mark.time),
metres: measured
? (distanceOf(after) ?? 0) - (distanceOf(before) ?? 0)
: null,
),
);
}
return WorkoutPlan(
name: plan.name,
laps: laps,
maxHeartRate: recording.planMaxHeartRate,
);
}
/// [recording] as it stood at [time]: its fixes, heart rates, frames and
/// pauses until then.
Recording recordingUntil(Recording recording, DateTime time) => Recording(
activity: recording.activity,
startedAt: recording.startedAt,
fixes: [
for (final fix in recording.fixes)
if (!fix.time.isAfter(time)) fix,
],
heartRates: [
for (final rate in recording.heartRates)
if (!rate.time.isAfter(time)) rate,
],
pauses: [
for (final mark in recording.pauses)
if (!mark.time.isAfter(time)) mark,
],
rowing: [
for (final sample in recording.rowing)
if (!sample.time.isAfter(time)) sample,
],
);
/// How far it went in metres: on the rower, the metres it counted while
/// moving (D28); else the track's length, null without fixes, from fix to fix
/// while it moves, the imprecise ones left out, starting afresh after each

View file

@ -65,6 +65,7 @@ Recording? readRecording(String text) {
final rowing = <RowingSample>[];
final rowers = <LinkMark>[];
PlanContent? plan;
int? planMaxHeartRate;
final planSteps = <StepMark>[];
DateTime? endedAt;
for (final line in lines.skip(1)) {
@ -75,11 +76,12 @@ Recording? readRecording(String text) {
}
if (fields.first == 'plan' && fields.length > 2) {
// Its JSON may hold spaces: all after the time.
plan = decodePlan(
ByteData.sublistView(
utf8.encode(line.substring('plan '.length + fields[1].length + 1)),
),
);
final json = line.substring('plan '.length + fields[1].length + 1);
plan = decodePlan(ByteData.sublistView(utf8.encode(json)));
planMaxHeartRate = switch (_jsonOf(json)) {
{'maxHeartRate': final int max} => max,
_ => null,
};
continue;
}
switch (fields) {
@ -154,12 +156,21 @@ Recording? readRecording(String text) {
rowers: rowers,
plan: plan,
planSteps: planSteps,
planMaxHeartRate: planMaxHeartRate,
endedAt: endedAt,
);
}
return null;
}
Object? _jsonOf(String text) {
try {
return jsonDecode(text);
} on FormatException {
return null;
}
}
DateTime? _time(String field) => switch (int.tryParse(field)) {
final ms? => DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true),
null => null,

View file

@ -115,9 +115,11 @@ final class const Recording({
final List<RowingSample> rowing = const [],
final List<LinkMark> rowers = const [],
/// The interval plan it ran, where it ran one, and its steps' starts (D42).
/// The interval plan it ran, where it ran one, its steps' starts, and
/// the highest heart rate its zones were worked out from (D42).
final PlanContent? plan,
final List<StepMark> planSteps = const [],
final int? planMaxHeartRate,
/// When the person stopped it; null for one cut off (D18).
final DateTime? endedAt,
@ -150,6 +152,7 @@ final class const Recording({
rowers,
plan,
planSteps,
planMaxHeartRate,
endedAt,
];
}

View file

@ -1,6 +1,7 @@
import 'dart:math';
import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
@ -33,6 +34,23 @@ final class const Split({
];
}
/// A plan's step as a workout ran it, with its figures (D43): the heart's
/// average rate, on the rower the average stroke rate and watts, and
/// whether it held its target (null without one, or without the figure).
final class const LapFigures({
required final WorkoutLap lap,
final int? averageBpm,
final double? strokeRate,
final int? watts,
final bool? held,
}) with Equatable {
@override
List<Object?> get props => [lap, averageBpm, strokeRate, watts, held];
}
/// Where on a chart's x a stretch runs, from `from` to `to`.
typedef ChartBand = ({double from, double to});
/// A point of a chart: `x` metres in (or seconds of moving time, where a
/// chart runs over the time), and its `value`.
typedef ChartPoint = ({double x, double value});
@ -76,6 +94,11 @@ final class const WorkoutAnalysis({
final int? averageWatts,
final int? maxWatts,
final int? strokes,
/// The plan's steps as run (D43), and where its work steps ran on the
/// charts, to shade.
final List<LapFigures> laps = const [],
final List<ChartBand> workBands = const [],
});
/// How long a split is for [activity].
@ -156,9 +179,113 @@ WorkoutAnalysis analyse(WorkoutContent content, List<SeriesChunk> series) {
averageWatts: wattsAlone.isEmpty ? null : _mean(wattsAlone).round(),
maxWatts: wattsAlone.isEmpty ? null : wattsAlone.reduce(max),
strokes: rowed ? _rowedStrokes(recording) : null,
laps: [
for (final lap in content.plan?.laps ?? const <WorkoutLap>[])
_lapFigures(
lap,
recording,
frames,
maxHeartRate: content.plan?.maxHeartRate,
),
],
workBands: [
for (final lap in content.plan?.laps ?? const <WorkoutLap>[])
if (lap.step.kind == StepKind.work)
(
from: xAt(_movingAtTime(recording, lap.startedAt)),
to: xAt(_movingAtTime(recording, lap.endedAt)),
),
],
);
}
/// How long [recording] had moved at [time].
Duration _movingAtTime(Recording recording, DateTime time) =>
movingTimeOf(recordingUntil(recording, time), time);
/// [lap]'s figures from the samples taken while it ran and moved.
LapFigures _lapFigures(
WorkoutLap lap,
Recording recording,
List<(RowingSample, Duration)> frames, {
required int? maxHeartRate,
}) {
bool within(DateTime time) =>
!time.isBefore(lap.startedAt) && time.isBefore(lap.endedAt);
final bpms = [
for (final (rate, _) in _whileMoving(
recording,
recording.heartRates,
(rate) => rate.time,
))
if (within(rate.time)) rate.bpm,
];
final rowed = [
for (final (sample, _) in frames)
if (within(sample.time)) sample,
];
final rates = [for (final s in rowed) ?s.strokeRate];
final watts = [for (final s in rowed) ?s.watts];
final averageBpm = bpms.isEmpty ? null : _mean(bpms).round();
final strokeRate = rates.isEmpty ? null : _mean(rates);
final averageWatts = watts.isEmpty ? null : _mean(watts).round();
return LapFigures(
lap: lap,
averageBpm: averageBpm,
strokeRate: strokeRate,
watts: averageWatts,
held: switch (lap.step.target) {
final target? => _held(
target,
lap,
bpm: averageBpm,
strokeRate: strokeRate,
watts: averageWatts,
maxHeartRate: maxHeartRate,
),
null => null,
},
);
}
/// Whether [lap] held [target] on its averages: its pace, speed or split
/// over its moving time and distance, its heart rate (a zone's for
/// [maxHeartRate]), its stroke rate or watts; null without the figure.
bool? _held(
Target target,
WorkoutLap lap, {
required int? bpm,
required double? strokeRate,
required int? watts,
required int? maxHeartRate,
}) {
final seconds = lap.movingTime.inMilliseconds / 1000;
final metres = lap.metres ?? 0;
final moved = seconds > 0 && metres > 0;
final value = switch (target.kind) {
TargetKind.pace => moved ? seconds / metres * 1000 : null,
TargetKind.speed => moved ? metres / seconds * 3.6 : null,
TargetKind.split => moved ? seconds / metres * 500 : null,
TargetKind.heartRate || TargetKind.heartZone => bpm?.toDouble(),
TargetKind.strokeRate => strokeRate,
TargetKind.watts => watts?.toDouble(),
};
final range = switch (target.kind) {
TargetKind.heartZone => switch (maxHeartRate) {
final max? => () {
final zone = heartZone(target.low.round(), max);
return (zone.low.toDouble(), zone.high.toDouble());
}(),
null => null,
},
_ => (target.low, target.high),
};
if (value == null || range == null) {
return null;
}
return value >= range.$1 && value <= range.$2;
}
/// How far it had gone at a moving time: `metres` at `moving`.
typedef _Progress = ({double metres, Duration moving});

View file

@ -2,13 +2,14 @@ import 'dart:convert';
import 'dart:developer';
import 'dart:typed_data';
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_payload.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// The schema of a workout's payload this app writes and reads.
const workoutSchemaVersion = 1;
/// [content] as its row's payload, before sealing: UTF-8 JSON, times in UTC
/// milliseconds.
/// milliseconds; a plan's steps as a plan's payload writes them.
ByteData encodeWorkout(WorkoutContent content) => ByteData.sublistView(
Uint8List.fromList(
utf8.encode(
@ -27,6 +28,23 @@ ByteData encodeWorkout(WorkoutContent content) => ByteData.sublistView(
'appName': ?origin.appName,
'sessionId': origin.sessionId,
},
if (content.plan case final plan?)
'plan': {
'name': plan.name,
'maxHeartRate': ?plan.maxHeartRate,
'laps': [
for (final lap in plan.laps)
{
'step': planStepJson(lap.step),
'round': lap.round,
'rounds': lap.rounds,
'startedAt': lap.startedAt.toUtc().millisecondsSinceEpoch,
'endedAt': lap.endedAt.toUtc().millisecondsSinceEpoch,
'movingMs': lap.movingTime.inMilliseconds,
'metres': ?lap.metres,
},
],
},
}),
),
),
@ -77,9 +95,44 @@ WorkoutContent? decodeWorkout(ByteData payload) {
),
_ => null,
},
plan: switch (json['plan']) {
{'name': final String name, 'laps': final List<Object?> laps} &&
final plan =>
WorkoutPlan(
name: name,
maxHeartRate: plan['maxHeartRate'] as int?,
laps: [for (final lap in laps) ?_lapOf(lap)],
),
_ => null,
},
);
} on Object catch (error) {
log('A workout not read: ${error.runtimeType}', name: 'Workouts');
return null;
}
}
/// The lap [json] holds; null for one it can't read, which is left out.
WorkoutLap? _lapOf(Object? json) => switch (json) {
{
'step': final Object step,
'round': final int round,
'rounds': final int rounds,
'startedAt': final int startedAt,
'endedAt': final int endedAt,
'movingMs': final int movingMs,
} =>
switch (planStepOf(step)) {
final step? => WorkoutLap(
step: step,
round: round,
rounds: rounds,
startedAt: DateTime.fromMillisecondsSinceEpoch(startedAt, isUtc: true),
endedAt: DateTime.fromMillisecondsSinceEpoch(endedAt, isUtc: true),
movingTime: Duration(milliseconds: movingMs),
metres: json['metres'] as int?,
),
null => null,
},
_ => null,
};

View file

@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
/// What a workout was (ADR 0030, D17), each one of Health Connect's exercise
/// types. Stored by name, so one this app doesn't know is skipped and new ones
@ -34,6 +35,42 @@ final class const WorkoutOrigin({
List<Object?> get props => [app, appName, sessionId];
}
/// A step of a plan as a workout ran it (D43): the step, which round of
/// how many, when it started and ended, how long it moved and how far it
/// went (null without a distance).
final class const WorkoutLap({
required final PlanStep step,
final int round = 1,
final int rounds = 1,
required final DateTime startedAt,
required final DateTime endedAt,
required final Duration movingTime,
final int? metres,
}) with Equatable {
@override
List<Object?> get props => [
step,
round,
rounds,
startedAt,
endedAt,
movingTime,
metres,
];
}
/// The interval plan a workout ran (D42, D43): its name, the highest heart
/// rate its zones were worked out from, and its steps as run, in order;
/// those it didn't reach left out.
final class const WorkoutPlan({
required final String name,
required final List<WorkoutLap> laps,
final int? maxHeartRate,
}) with Equatable {
@override
List<Object?> get props => [name, laps, maxHeartRate];
}
/// What a workout is, as its sealed payload holds it (D14); its samples are
/// its series.
final class const WorkoutContent({
@ -56,6 +93,9 @@ final class const WorkoutContent({
/// Where it came from; null for one Train recorded.
final WorkoutOrigin? origin,
/// The interval plan it ran; null without one.
final WorkoutPlan? plan,
}) with Equatable {
/// As it is, named [name] with [notes] (each null or blank: none).
WorkoutContent withText({String? name, String? notes, Activity? activity}) {
@ -70,6 +110,7 @@ final class const WorkoutContent({
distanceMetres: distanceMetres,
movingTime: movingTime,
origin: origin,
plan: plan,
);
}
@ -83,5 +124,6 @@ final class const WorkoutContent({
distanceMetres,
movingTime,
origin,
plan,
];
}

View file

@ -6,12 +6,14 @@ import 'package:material_ui/material_ui.dart';
/// A line over the distance or the time (ADR 0030, D24, drawn by hand rather than with a
/// chart package): [points] scaled to the box, the lowest and the highest
/// value beside it as [label] writes them. [inverted] puts low values at the
/// top, as a faster pace is fewer seconds.
/// top, as a faster pace is fewer seconds. [bands] are shaded behind the
/// line: a plan's work steps (D43).
class const WorkoutChart({
required final String title,
required final List<ChartPoint> points,
required final String Function(double value) label,
final bool inverted = false,
final List<ChartBand> bands = const [],
super.key,
}) extends StatelessWidget {
@override
@ -40,6 +42,8 @@ class const WorkoutChart({
child: CustomPaint(
painter: _LinePainter(
points,
bands: bands,
shade: theme.colorScheme.primary.withValues(alpha: 0.12),
low: low,
high: high,
inverted: inverted,
@ -68,6 +72,8 @@ class const WorkoutChart({
class _LinePainter(
final List<ChartPoint> _points, {
required final List<ChartBand> bands,
required final Color shade,
required final double low,
required final double high,
required final bool inverted,
@ -88,6 +94,14 @@ class _LinePainter(
);
final first = _points.first.x;
final span = max(_points.last.x - first, 1);
double xOf(double x) => ((x - first) / span).clamp(0, 1) * size.width;
final shading = Paint()..color = shade;
for (final band in bands) {
canvas.drawRect(
Rect.fromLTRB(xOf(band.from), 0, xOf(band.to), size.height),
shading,
);
}
// A flat line sits in the middle.
final range = high - low;
Offset at(ChartPoint point) {
@ -116,6 +130,8 @@ class _LinePainter(
@override
bool shouldRepaint(_LinePainter oldDelegate) =>
!identical(oldDelegate._points, _points) ||
!identical(oldDelegate.bands, bands) ||
oldDelegate.shade != shade ||
oldDelegate.line != line ||
oldDelegate.grid != grid;
}

View file

@ -4,6 +4,7 @@ import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workout_cubit.dart';
@ -197,6 +198,14 @@ class const _Workout({
const SizedBox(height: 16),
Text(notes, style: theme.textTheme.bodyLarge),
],
if (content.plan case final plan? when analysis.laps.isNotEmpty) ...[
const SizedBox(height: 24),
_Laps(
activity: content.activity,
name: plan.name,
laps: analysis.laps,
),
],
if (analysis.splits.isNotEmpty) ...[
const SizedBox(height: 24),
_Splits(activity: content.activity, splits: analysis.splits),
@ -205,6 +214,7 @@ class const _Workout({
const SizedBox(height: 24),
if (content.activity == Activity.indoorRowing)
WorkoutChart(
bands: analysis.workBands,
title: l10n.workoutSplitChart,
points: analysis.chart,
inverted: true,
@ -214,6 +224,7 @@ class const _Workout({
)
else if (showsPace(content.activity))
WorkoutChart(
bands: analysis.workBands,
title: l10n.workoutPaceChart,
points: analysis.chart,
inverted: true,
@ -223,6 +234,7 @@ class const _Workout({
)
else
WorkoutChart(
bands: analysis.workBands,
title: l10n.workoutSpeedChart,
points: analysis.chart,
label: (value) => l10n.recordingSpeedValue(speed(locale, value)),
@ -231,6 +243,7 @@ class const _Workout({
if (analysis.strokeRate.length > 1) ...[
const SizedBox(height: 24),
WorkoutChart(
bands: analysis.workBands,
title: l10n.workoutStrokeRateChart,
points: analysis.strokeRate,
label: (value) =>
@ -240,6 +253,7 @@ class const _Workout({
if (analysis.watts.length > 1) ...[
const SizedBox(height: 24),
WorkoutChart(
bands: analysis.workBands,
title: l10n.workoutPowerChart,
points: analysis.watts,
label: (value) => l10n.workoutWattsValue(value.round()),
@ -248,6 +262,7 @@ class const _Workout({
if (analysis.heartRate.length > 1) ...[
const SizedBox(height: 24),
WorkoutChart(
bands: analysis.workBands,
title: l10n.workoutHeartRateChart,
points: analysis.heartRate,
label: (value) => l10n.workoutBpmValue(value.round()),
@ -256,6 +271,7 @@ class const _Workout({
if (analysis.elevation.length > 1) ...[
const SizedBox(height: 24),
WorkoutChart(
bands: analysis.workBands,
title: l10n.workoutElevationChart,
points: analysis.elevation,
label: (value) => l10n.workoutClimbValue(value.round()),
@ -583,6 +599,142 @@ class const _Splits({
}
}
/// A plan's steps as run (D43): each with its target, moving time, pace,
/// speed or split, on the rower its stroke rate and watts, the heart's
/// average rate, and whether it held its target.
class const _Laps({
required final Activity activity,
required final String name,
required final List<LapFigures> laps,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final theme = Theme.of(context);
final figures = theme.textTheme.bodyLarge?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
);
final muted = theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
final distance = laps.any((l) => (l.lap.metres ?? 0) > 0);
final heart = laps.any((l) => l.averageBpm != null);
final strokes = laps.any((l) => l.strokeRate != null);
final power = laps.any((l) => l.watts != null);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.workoutSteps(name), style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
for (final figure in laps)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
[
stepKindName(l10n, figure.lap.step.kind),
if (figure.lap.rounds > 1)
l10n.planSayRound(
figure.lap.round,
figure.lap.rounds,
),
].join(' · '),
style: theme.textTheme.bodyLarge,
),
if (figure.lap.step.target case final target?)
Text(targetText(l10n, target), style: muted),
],
),
),
Expanded(
child: Text(
clockTime(figure.lap.movingTime),
style: figures,
),
),
if (distance)
Expanded(
child: Text(
paceText(
l10n,
locale,
activity,
figure.lap.metres ?? 0,
figure.lap.movingTime,
) ??
'–',
style: figures,
textAlign: TextAlign.end,
),
),
if (strokes)
Expanded(
child: Text(
switch (figure.strokeRate) {
final rate? => l10n.workoutStrokeRateValue(
strokeRateText(locale, rate),
),
null => '–',
},
style: figures,
textAlign: TextAlign.end,
),
),
if (power)
Expanded(
child: Text(
switch (figure.watts) {
final watts? => l10n.workoutWattsValue(watts),
null => '–',
},
style: figures,
textAlign: TextAlign.end,
),
),
if (heart)
Expanded(
child: Text(
switch (figure.averageBpm) {
final bpm? => l10n.workoutBpmValue(bpm),
null => '–',
},
style: figures,
textAlign: TextAlign.end,
),
),
SizedBox(
width: 32,
child: switch (figure.held) {
true => Icon(
LucideIcons.circleCheck,
size: 20,
color: theme.colorScheme.primary,
semanticLabel: l10n.workoutOnTarget,
),
false => Icon(
LucideIcons.circleAlert,
size: 20,
color: theme.colorScheme.tertiary,
semanticLabel: l10n.workoutOffTarget,
),
null => null,
},
),
],
),
),
],
);
}
}
/// A figure with its label beneath.
class const _Figure({required final String value, required final String label})
extends StatelessWidget {