feat(train): a workout out as GPX, TCX or FIT (ADR 0030, D49)

11b: Export on a workout's page hands it to the share sheet as GPX (with
a track only; heart rate and cadence in Garmin's TrackPointExtension),
TCX (laps, heart rate, cadence, watts, a row's distance) or FIT (file
ID, records, timer events at the pauses, laps with their intensities,
session, activity), all from the same points: the precise fixes while
moving, else the rower's frames, else the heart rates; a plan's steps
are the laps. The FIT writer is our own (protocol 1.0, CRC-16/ARC),
tested by a reader written from the specification.

Not tried yet with Strava, intervals.icu or Garmin Connect.

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-29 14:37:48 +01:00
commit 128ba1f6c8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 1386 additions and 15 deletions

View file

@ -514,5 +514,10 @@
"announceTime": "Zeit {time}",
"announcePace": "Pace {pace} pro Kilometer",
"announceSpeed": "Tempo {speed} Kilometer pro Stunde",
"announceHeartRate": "Puls {bpm}"
"announceHeartRate": "Puls {bpm}",
"workoutExport": "Exportieren",
"workoutExportTitle": "Training exportieren",
"workoutExportGpxHint": "Die Strecke, für Karten und andere Apps",
"workoutExportTcxHint": "Runden, Puls und Kadenz, für die meisten Trainingsseiten",
"workoutExportFitHint": "Was Strava, Garmin und intervals.icu am besten lesen"
}

View file

@ -653,5 +653,10 @@
"announceSpeed": "Speed {speed} kilometres an hour",
"@announceSpeed": {"placeholders": {"speed": {"type": "String"}}},
"announceHeartRate": "Heart rate {bpm}",
"@announceHeartRate": {"placeholders": {"bpm": {"type": "String"}}}
"@announceHeartRate": {"placeholders": {"bpm": {"type": "String"}}},
"workoutExport": "Export",
"workoutExportTitle": "Export the workout",
"workoutExportGpxHint": "The track, for maps and other apps",
"workoutExportTcxHint": "Laps, heart rate and cadence, for most training sites",
"workoutExportFitHint": "What Strava, Garmin and intervals.icu read best"
}

View file

@ -3111,6 +3111,36 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Heart rate {bpm}'**
String announceHeartRate(String bpm);
/// No description provided for @workoutExport.
///
/// In en, this message translates to:
/// **'Export'**
String get workoutExport;
/// No description provided for @workoutExportTitle.
///
/// In en, this message translates to:
/// **'Export the workout'**
String get workoutExportTitle;
/// No description provided for @workoutExportGpxHint.
///
/// In en, this message translates to:
/// **'The track, for maps and other apps'**
String get workoutExportGpxHint;
/// No description provided for @workoutExportTcxHint.
///
/// In en, this message translates to:
/// **'Laps, heart rate and cadence, for most training sites'**
String get workoutExportTcxHint;
/// No description provided for @workoutExportFitHint.
///
/// In en, this message translates to:
/// **'What Strava, Garmin and intervals.icu read best'**
String get workoutExportFitHint;
}
class _AppLocalizationsDelegate

View file

@ -1872,4 +1872,21 @@ class AppLocalizationsDe extends AppLocalizations {
String announceHeartRate(String bpm) {
return 'Puls $bpm';
}
@override
String get workoutExport => 'Exportieren';
@override
String get workoutExportTitle => 'Training exportieren';
@override
String get workoutExportGpxHint => 'Die Strecke, für Karten und andere Apps';
@override
String get workoutExportTcxHint =>
'Runden, Puls und Kadenz, für die meisten Trainingsseiten';
@override
String get workoutExportFitHint =>
'Was Strava, Garmin und intervals.icu am besten lesen';
}

View file

@ -1860,6 +1860,23 @@ class AppLocalizationsEn extends AppLocalizations {
String announceHeartRate(String bpm) {
return 'Heart rate $bpm';
}
@override
String get workoutExport => 'Export';
@override
String get workoutExportTitle => 'Export the workout';
@override
String get workoutExportGpxHint => 'The track, for maps and other apps';
@override
String get workoutExportTcxHint =>
'Laps, heart rate and cadence, for most training sites';
@override
String get workoutExportFitHint =>
'What Strava, Garmin and intervals.icu read best';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -11,7 +11,8 @@ typedef OpenedFile = ({String name, Uint8List bytes});
const gpxMimeType = 'application/gpx+xml';
/// Routes' files on the device (ADR 0030, D45): GPX opened with Train or
/// shared to it, one the person picks, and a route handed on.
/// shared to it, one the person picks, and a route handed on, as a workout
/// is (D49).
abstract interface class RouteFiles() {
/// The files opened with Train or shared to it (Android's), each once.
Stream<OpenedFile> get opened;
@ -23,9 +24,13 @@ abstract interface class RouteFiles() {
/// A GPX file the person picks; null where they cancel.
Future<OpenedFile?> pick();
/// Hands [bytes] on as the GPX file [name]; false where the person
/// cancelled saving it.
Future<bool> share({required String name, required Uint8List bytes});
/// Hands [bytes] on as the file [name] of [mimeType] (GPX by default);
/// false where the person cancelled saving it.
Future<bool> share({
required String name,
required Uint8List bytes,
String mimeType = gpxMimeType,
});
}
/// [RouteFiles] on the platform: Android's through the app's own
@ -73,14 +78,18 @@ class PlatformRouteFiles(final FileSaver _saver) implements RouteFiles {
}
@override
Future<bool> share({required String name, required Uint8List bytes}) async {
Future<bool> share({
required String name,
required Uint8List bytes,
String mimeType = gpxMimeType,
}) async {
if (!shares) {
return await _saver.save(name: name, bytes: bytes, mimeType: gpxMimeType);
return await _saver.save(name: name, bytes: bytes, mimeType: mimeType);
}
await _channel.invokeMethod<void>('share', {
'name': name,
'bytes': bytes,
'mimeType': gpxMimeType,
'mimeType': mimeType,
});
return true;
}

View file

@ -1,16 +1,18 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_export.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// One workout's page (ADR 0030, D24): the workout and what its series show,
/// read again whenever the workouts change, and the instance's map source
/// where it has a track to show.
/// where it has a track to show; the workout as a file (D49).
class WorkoutCubit(
final WorkoutRepository _workouts,
final MapSourceRepository _maps,
@ -41,6 +43,20 @@ class WorkoutCubit(
);
}
/// The workout as a file in [format] (D49), [name] inside where the
/// format names it; null where it is gone or [format] can't take it (GPX
/// without a track).
Future<Uint8List?> export(ExportFormat format, {required String name}) async {
final content = state.content;
if (content == null) {
return null;
}
final track = exportTrackOf(content, await _workouts.series(id));
return format.takes(track)
? exportOf(format, content, track, name: name)
: null;
}
Future<void> _read() async {
final stored = [
for (final workout in await _workouts.all())

View file

@ -0,0 +1,284 @@
import 'dart:typed_data';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_export.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// FIT's epoch: 31 Dec 1989, 00:00 UTC.
final _epoch = DateTime.utc(1989, 12, 31);
/// [time] in FIT's seconds.
int fitTime(DateTime time) => time.toUtc().difference(_epoch).inSeconds;
/// A field's base type in FIT: its number, its size and its invalid value,
/// written for a field that has none.
enum FitBase(final int id, final int size, final int invalid) {
enum8(0x00, 1, 0xFF),
uint8(0x02, 1, 0xFF),
uint16(0x84, 2, 0xFFFF),
sint32(0x85, 4, 0x7FFFFFFF),
uint32(0x86, 4, 0xFFFFFFFF),
}
/// FIT's CRC-16 of [bytes], as its SDK works it out.
int fitCrc(List<int> bytes, [int start = 0]) {
const table = [
0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401, //
0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400,
];
var crc = start;
for (final byte in bytes) {
var tmp = table[crc & 0xF];
crc = (crc >> 4) & 0x0FFF;
crc = crc ^ tmp ^ table[byte & 0xF];
tmp = table[crc & 0xF];
crc = (crc >> 4) & 0x0FFF;
crc = crc ^ tmp ^ table[(byte >> 4) & 0xF];
}
return crc;
}
/// Writes a FIT file (the Garmin FIT SDK's protocol 1.0, little-endian):
/// each message type defined once under its local number, its messages
/// after; [finish] puts the header and the CRC around them.
class FitWriter() {
final _data = BytesBuilder();
final _fields = <int, List<FitBase>>{};
/// Defines local message [local] as the global message [global] with
/// [fields], each its number and base type.
void define(int local, int global, List<(int, FitBase)> fields) {
_data
..addByte(0x40 | local)
..addByte(0)
// Little-endian.
..addByte(0)
..add(_bytes(global, 2))
..addByte(fields.length);
for (final (number, base) in fields) {
_data.add([number, base.size, base.id]);
}
_fields[local] = [for (final (_, base) in fields) base];
}
/// A message of local type [local], its [values] in its fields' order;
/// null is the field's invalid value.
void write(int local, List<int?> values) {
final fields = _fields[local]!;
assert(values.length == fields.length, 'A value for each field');
_data.addByte(local);
for (final (i, base) in fields.indexed) {
_data.add(_bytes(values[i] ?? base.invalid, base.size));
}
}
Uint8List finish() {
final data = _data.takeBytes();
final header = BytesBuilder()
..addByte(14)
..addByte(0x10)
// Profile 21.00.
..add(_bytes(2100, 2))
..add(_bytes(data.length, 4))
..add('.FIT'.codeUnits);
final head = header.takeBytes();
final file = BytesBuilder()
..add(head)
..add(_bytes(fitCrc(head), 2))
..add(data);
final body = file.takeBytes();
return (BytesBuilder()
..add(body)
..add(_bytes(fitCrc(body), 2)))
.takeBytes();
}
static List<int> _bytes(int value, int size) => [
for (var i = 0; i < size; i++) (value >> (8 * i)) & 0xFF,
];
}
/// [track] as a FIT activity file (D49): its file ID, a record each point,
/// the timer's events at the start, each pause and the end, a lap each with
/// its step's intensity, one session and the activity, as Garmin's devices
/// write them; a run's cadence in strides, positions in semicircles,
/// distances in centimetres, times in milliseconds.
Uint8List fitOf(WorkoutContent content, ExportTrack track) {
final (sport, subSport) = switch (content.activity) {
Activity.running => (1, 0),
Activity.cycling => (2, 0),
Activity.walking => (11, 0),
Activity.hiking => (17, 0),
Activity.indoorRowing => (15, 14),
Activity.other => (0, 0),
};
int semicircles(int e7) => (e7 / 1e7 * 2147483648 / 180).round();
({int? average, int? max}) heartOf(DateTime from, DateTime to) {
final rates = [
for (final point in track.points)
if (!point.time.isBefore(from) && !point.time.isAfter(to)) ?point.bpm,
];
if (rates.isEmpty) {
return (average: null, max: null);
}
return (
average: (rates.reduce((a, b) => a + b) / rates.length).round(),
max: rates.reduce((a, b) => a > b ? a : b),
);
}
final start = fitTime(content.startedAt);
final end = fitTime(content.endedAt);
final writer = FitWriter()
..define(0, 0, [
(0, FitBase.enum8),
(1, FitBase.uint16),
(2, FitBase.uint16),
(4, FitBase.uint32),
])
// An activity, by a development manufacturer.
..write(0, [4, 255, 0, start])
..define(1, 21, [
(253, FitBase.uint32),
(0, FitBase.enum8),
(1, FitBase.enum8),
])
// The timer started.
..write(1, [start, 0, 0])
..define(2, 20, [
(253, FitBase.uint32),
(0, FitBase.sint32),
(1, FitBase.sint32),
(2, FitBase.uint16),
(3, FitBase.uint8),
(4, FitBase.uint8),
(5, FitBase.uint32),
(7, FitBase.uint16),
]);
final pauses = [...track.pauses];
for (final point in track.points) {
while (pauses.isNotEmpty && !pauses.first.time.isAfter(point.time)) {
final pause = pauses.removeAt(0);
// The timer stopped (stop all), or started again.
final type = pause.paused ? 4 : 0;
writer.write(1, [fitTime(pause.time), 0, type]);
}
writer.write(2, [
fitTime(point.time),
switch (point.latE7) {
final lat? => semicircles(lat),
null => null,
},
switch (point.lonE7) {
final lon? => semicircles(lon),
null => null,
},
switch (point.altitude) {
final metres? => ((metres + 500) * 5).round().clamp(0, 0xFFFE),
null => null,
},
point.bpm?.clamp(0, 254),
point.cadence?.clamp(0, 254),
switch (point.metres) {
final metres? => (metres * 100).round(),
null => null,
},
point.watts?.clamp(0, 0xFFFE),
]);
}
writer
..write(1, [end, 0, 4])
..define(3, 19, [
(253, FitBase.uint32),
(0, FitBase.enum8),
(1, FitBase.enum8),
(2, FitBase.uint32),
(7, FitBase.uint32),
(8, FitBase.uint32),
(9, FitBase.uint32),
(15, FitBase.uint8),
(16, FitBase.uint8),
(23, FitBase.enum8),
(24, FitBase.enum8),
(25, FitBase.enum8),
]);
for (final (index, lap) in track.laps.indexed) {
final heart = heartOf(lap.start, lap.end);
writer.write(3, [
fitTime(lap.end),
// A lap's stop.
9,
1,
fitTime(lap.start),
lap.end.difference(lap.start).inMilliseconds,
lap.movingTime.inMilliseconds,
switch (lap.metres) {
final metres? => metres * 100,
null => null,
},
heart.average,
heart.max,
switch (lap.kind) {
StepKind.recovery => 1,
StepKind.warmUp => 2,
StepKind.coolDown => 3,
StepKind.work || null => 0,
},
// Ended by hand, the last by the session's end.
if (index == track.laps.length - 1) 7 else 0,
sport,
]);
}
final heart = heartOf(content.startedAt, content.endedAt);
final timer =
(content.movingTime ?? content.endedAt.difference(content.startedAt))
.inMilliseconds;
final local = end + content.startedAt.toLocal().timeZoneOffset.inSeconds;
writer
..define(4, 18, [
(253, FitBase.uint32),
(0, FitBase.enum8),
(1, FitBase.enum8),
(2, FitBase.uint32),
(5, FitBase.enum8),
(6, FitBase.enum8),
(7, FitBase.uint32),
(8, FitBase.uint32),
(9, FitBase.uint32),
(16, FitBase.uint8),
(17, FitBase.uint8),
(25, FitBase.uint16),
(26, FitBase.uint16),
])
..write(4, [
end,
// The session's stop.
8,
1,
start,
sport,
subSport,
content.endedAt.difference(content.startedAt).inMilliseconds,
timer,
switch (content.distanceMetres) {
final metres? => metres * 100,
null => null,
},
heart.average,
heart.max,
0,
track.laps.length,
])
..define(5, 34, [
(253, FitBase.uint32),
(0, FitBase.uint32),
(1, FitBase.uint16),
(2, FitBase.enum8),
(3, FitBase.enum8),
(4, FitBase.enum8),
(5, FitBase.uint32),
])
// One session, made by hand; the activity's stop.
..write(5, [end, timer, 1, 0, 26, 1, local]);
return writer.finish();
}

View file

@ -0,0 +1,500 @@
import 'dart:convert';
import 'dart:typed_data';
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/data/fit_encoder.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:intl/intl.dart';
import 'package:xml/xml.dart';
/// The formats a workout goes out in (ADR 0030, D49), each with its file's
/// extension and media type.
enum ExportFormat(final String extension, final String mimeType) {
gpx('gpx', 'application/gpx+xml'),
tcx('tcx', 'application/vnd.garmin.tcx+xml'),
fit('fit', 'application/vnd.ant.fit');
/// Whether [track] can go out in it: GPX is a track's alone.
bool takes(ExportTrack track) => this != gpx || track.located;
}
/// A sample as the formats write it: when, where (in 1e-7 degrees) and how
/// high (metres), how far it had gone (metres), the heart rate, the cadence
/// in turns a minute (a run's strides, half its steps; a row's strokes) and
/// the watts; [afterPause] where a pause came before it.
final class const ExportPoint({
required final DateTime time,
final int? latE7,
final int? lonE7,
final double? altitude,
final double? metres,
final int? bpm,
final int? cadence,
final int? watts,
final bool afterPause = false,
});
/// A lap as the formats write it: a plan's step as run (D43), or the whole
/// workout without a plan.
final class const ExportLap({
required final DateTime start,
required final DateTime end,
required final Duration movingTime,
final int? metres,
final StepKind? kind,
});
/// What a workout goes out as: its points, in order, its laps and its
/// pauses.
final class const ExportTrack({
required final List<ExportPoint> points,
required final List<ExportLap> laps,
final List<PauseMark> pauses = const [],
}) {
/// Whether it has a track on the map.
bool get located => points.any((point) => point.latE7 != null);
}
/// [content] and its [series] as the formats write them: the fixes precise
/// enough taken while moving, as the saved distance counts them, else the
/// rower's frames, else the heart rates; each with the heart rate of the
/// last 5 s, the cadence of the window it fell in (on foot, as strides) and
/// the rower's stroke rate and watts.
ExportTrack exportTrackOf(WorkoutContent content, List<SeriesChunk> series) {
final recording = recordingOf(content, series);
final heart = _Latest(
[for (final rate in recording.heartRates) (rate.time, rate.bpm)],
const Duration(seconds: 5),
);
final steps = _Next(
[
for (final chunk in series)
if (chunk.kind == SeriesKind.cadence)
for (final (i, time) in chunk.absoluteTimes.indexed)
if (chunk.columns['spm']?[i] case final spm?) (time, spm),
]..sort((a, b) => a.$1.compareTo(b.$1)),
cadenceWindow,
);
final points = <ExportPoint>[];
if (recording.fixes.isNotEmpty) {
for (final point in _movingFixes(recording)) {
final fix = point.fix;
points.add(
ExportPoint(
time: fix.time,
latE7: fix.latE7,
lonE7: fix.lonE7,
altitude: switch (fix.altitudeCm) {
final cm? => cm / 100,
null => null,
},
metres: point.metres,
bpm: heart.at(fix.time),
cadence: switch (steps.at(fix.time)) {
final spm? => (spm / 2).round(),
null => null,
},
afterPause: point.afterPause,
),
);
}
} else if (recording.rowing.isNotEmpty) {
for (final sample in recording.rowing) {
points.add(
ExportPoint(
time: sample.time,
metres: sample.metres?.toDouble(),
bpm: heart.at(sample.time),
cadence: sample.strokeRate?.round(),
watts: sample.watts,
),
);
}
} else {
for (final rate in recording.heartRates) {
points.add(ExportPoint(time: rate.time, bpm: rate.bpm));
}
}
final plan = content.plan;
final laps = plan == null || plan.laps.isEmpty
? [
ExportLap(
start: content.startedAt,
end: content.endedAt,
movingTime:
content.movingTime ??
content.endedAt.difference(content.startedAt),
metres: content.distanceMetres,
),
]
: [
for (final lap in plan.laps)
ExportLap(
start: lap.startedAt,
end: lap.endedAt,
movingTime: lap.movingTime,
metres: lap.metres,
kind: lap.step.kind,
),
];
return ExportTrack(points: points, laps: laps, pauses: recording.pauses);
}
/// The file [content] goes out as in [format].
Uint8List exportOf(
ExportFormat format,
WorkoutContent content,
ExportTrack track, {
required String name,
}) => switch (format) {
ExportFormat.gpx => utf8.encode(gpxOfWorkout(content, track, name: name)),
ExportFormat.tcx => utf8.encode(tcxOf(content, track)),
ExportFormat.fit => fitOf(content, track),
};
/// The file's name for [content] in [format]: `halkyon-train-` and when it
/// started, in UTC.
String exportNameOf(WorkoutContent content, ExportFormat format) =>
'halkyon-train-'
'${DateFormat('yyyyMMdd-HHmm').format(content.startedAt.toUtc())}'
'.${format.extension}';
const _gpxTpx = 'http://www.garmin.com/xmlschemas/TrackPointExtension/v1';
/// [track] as GPX 1.1 named [name], a segment each piece between pauses,
/// the heart rate and the cadence in Garmin's TrackPointExtension.
String gpxOfWorkout(
WorkoutContent content,
ExportTrack track, {
required String name,
}) {
final builder = XmlBuilder()
..processing('xml', 'version="1.0" encoding="UTF-8"');
void segment(List<ExportPoint> points) => builder.element(
'trkseg',
nest: () {
for (final point in points) {
builder.element(
'trkpt',
attributes: {
'lat': (point.latE7! / 1e7).toStringAsFixed(7),
'lon': (point.lonE7! / 1e7).toStringAsFixed(7),
},
nest: () {
if (point.altitude case final altitude?) {
builder.element('ele', nest: altitude.toStringAsFixed(1));
}
builder.element('time', nest: _time(point.time));
if (point.bpm != null || point.cadence != null) {
builder.element(
'extensions',
nest: () => builder.element(
'gpxtpx:TrackPointExtension',
nest: () {
if (point.bpm case final bpm?) {
builder.element('gpxtpx:hr', nest: '$bpm');
}
if (point.cadence case final cadence?) {
builder.element('gpxtpx:cad', nest: '$cadence');
}
},
),
);
}
},
);
}
},
);
builder.element(
'gpx',
attributes: {
'version': '1.1',
'creator': 'Halkyon Train',
'xmlns': 'http://www.topografix.com/GPX/1/1',
'xmlns:gpxtpx': _gpxTpx,
},
nest: () {
builder.element(
'metadata',
nest: () {
builder
..element('name', nest: name)
..element('time', nest: _time(content.startedAt));
},
);
builder.element(
'trk',
nest: () {
builder
..element('name', nest: name)
..element('type', nest: content.activity.name);
if (content.notes case final notes?) {
builder.element('desc', nest: notes);
}
final located = [
for (final point in track.points)
if (point.latE7 != null && point.lonE7 != null) point,
];
var piece = <ExportPoint>[];
for (final point in located) {
if (point.afterPause && piece.isNotEmpty) {
segment(piece);
piece = [];
}
piece.add(point);
}
if (piece.isNotEmpty) {
segment(piece);
}
},
);
},
);
return builder.buildDocument().toXmlString(pretty: true);
}
const _tcx = 'http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2';
const _tcxExtension = 'http://www.garmin.com/xmlschemas/ActivityExtension/v2';
/// [track] as TCX (Garmin's TrainingCenterDatabase v2): a lap each, its
/// points those taken while it ran, with the elements in the schema's
/// order; a run's cadence as `RunCadence`, a ride's and a row's as
/// `Cadence`, the watts in the activity extension.
String tcxOf(WorkoutContent content, ExportTrack track) {
final builder = XmlBuilder()
..processing('xml', 'version="1.0" encoding="UTF-8"');
final onFoot = switch (content.activity) {
Activity.running || Activity.walking || Activity.hiking => true,
_ => false,
};
void trackpoint(ExportPoint point) => builder.element(
'Trackpoint',
nest: () {
builder.element('Time', nest: _time(point.time));
if ((point.latE7, point.lonE7) case (final lat?, final lon?)) {
builder.element(
'Position',
nest: () {
builder
..element('LatitudeDegrees', nest: (lat / 1e7).toStringAsFixed(7))
..element(
'LongitudeDegrees',
nest: (lon / 1e7).toStringAsFixed(7),
);
},
);
}
if (point.altitude case final altitude?) {
builder.element('AltitudeMeters', nest: altitude.toStringAsFixed(1));
}
if (point.metres case final metres?) {
builder.element('DistanceMeters', nest: metres.toStringAsFixed(1));
}
if (point.bpm case final bpm?) {
builder.element(
'HeartRateBpm',
nest: () => builder.element('Value', nest: '$bpm'),
);
}
if (point.cadence case final cadence? when !onFoot) {
builder.element('Cadence', nest: '${cadence.clamp(0, 254)}');
}
final runCadence = onFoot ? point.cadence : null;
if (runCadence != null || point.watts != null) {
builder.element(
'Extensions',
nest: () => builder.element(
'TPX',
attributes: {'xmlns': _tcxExtension},
nest: () {
if (point.watts case final watts?) {
builder.element('Watts', nest: '$watts');
}
if (runCadence != null) {
builder.element(
'RunCadence',
nest: '${runCadence.clamp(0, 254)}',
);
}
},
),
);
}
},
);
builder.element(
'TrainingCenterDatabase',
attributes: {'xmlns': _tcx},
nest: () => builder.element(
'Activities',
nest: () => builder.element(
'Activity',
attributes: {
'Sport': switch (content.activity) {
Activity.running => 'Running',
Activity.cycling => 'Biking',
_ => 'Other',
},
},
nest: () {
builder.element('Id', nest: _time(content.startedAt));
for (final (index, lap) in track.laps.indexed) {
final last = index == track.laps.length - 1;
final points = [
for (final point in track.points)
if (!point.time.isBefore(lap.start) &&
(last
? !point.time.isAfter(lap.end)
: point.time.isBefore(lap.end)))
point,
];
final rates = [for (final point in points) ?point.bpm];
builder.element(
'Lap',
attributes: {'StartTime': _time(lap.start)},
nest: () {
builder
..element(
'TotalTimeSeconds',
nest: (lap.movingTime.inMilliseconds / 1000)
.toStringAsFixed(1),
)
..element('DistanceMeters', nest: '${lap.metres ?? 0}')
..element('Calories', nest: '0');
if (rates.isNotEmpty) {
builder
..element(
'AverageHeartRateBpm',
nest: () => builder.element(
'Value',
nest:
'${(rates.reduce((a, b) => a + b) / rates.length).round()}',
),
)
..element(
'MaximumHeartRateBpm',
nest: () => builder.element(
'Value',
nest: '${rates.reduce((a, b) => a > b ? a : b)}',
),
);
}
builder
..element(
'Intensity',
nest: lap.kind == StepKind.recovery ? 'Resting' : 'Active',
)
..element('TriggerMethod', nest: 'Manual');
if (points.isNotEmpty) {
builder.element(
'Track',
nest: () => points.forEach(trackpoint),
);
}
},
);
}
if (content.notes case final notes?) {
builder.element('Notes', nest: notes);
}
builder.element(
'Creator',
attributes: {
'xsi:type': 'Device_t',
'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
},
nest: () {
builder
..element('Name', nest: 'Halkyon Train')
..element('UnitId', nest: '0')
..element('ProductID', nest: '0')
..element(
'Version',
nest: () {
builder
..element('VersionMajor', nest: '0')
..element('VersionMinor', nest: '0');
},
);
},
);
},
),
),
);
return builder.buildDocument().toXmlString(pretty: true);
}
String _time(DateTime time) =>
'${time.toUtc().toIso8601String().split('.').first}Z';
/// The fixes precise enough taken while moving, with the distance up to
/// each, as the saved distance counts it, and whether a pause came before.
List<({Fix fix, double metres, bool afterPause})> _movingFixes(
Recording recording,
) {
final pauses = recording.pauses;
final points = <({Fix fix, double metres, bool afterPause})>[];
var next = 0;
var paused = false;
var broken = false;
var metres = 0.0;
for (final fix in recording.fixes) {
while (next < pauses.length && !pauses[next].time.isAfter(fix.time)) {
paused = pauses[next++].paused;
broken = true;
}
if (paused || fix.accuracyCm > maxAccuracyCm) {
continue;
}
final last = points.lastOrNull;
if (last != null && !broken) {
metres += metresBetween(last.fix, fix);
}
points.add((fix: fix, metres: metres, afterPause: broken && last != null));
broken = false;
}
return points;
}
/// The latest of timed values at or before a time, no older than [_fresh];
/// asked in time order.
class _Latest(final List<(DateTime, int)> _values, final Duration _fresh) {
var _next = 0;
int? at(DateTime time) {
while (_next < _values.length && !_values[_next].$1.isAfter(time)) {
_next++;
}
if (_next == 0) {
return null;
}
final (at, value) = _values[_next - 1];
return time.difference(at) <= _fresh ? value : null;
}
}
/// The first of timed values at or after a time, no further than [_window]:
/// a cadence counts the steps of the window before its time; asked in time
/// order.
class _Next(final List<(DateTime, int)> _values, final Duration _window) {
var _next = 0;
int? at(DateTime time) {
while (_next < _values.length && _values[_next].$1.isBefore(time)) {
_next++;
}
if (_next == _values.length) {
return null;
}
final (at, value) = _values[_next];
return at.difference(time) <= _window ? value : null;
}
}

View file

@ -10,6 +10,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_tex
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workout_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_export.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_chart.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_texts.dart';
@ -42,8 +43,9 @@ Future<void> openWorkoutScreen(BuildContext context, String id) {
/// A workout's page (ADR 0030, D24): its track on the instance's map, top,
/// where it has one; below its name, when it was, its totals, its notes, its
/// splits and its charts. Edit names it and writes its notes; Delete asks
/// first, goes back home and offers to undo it there.
/// splits and its charts. Edit names it and writes its notes; Export hands
/// it on as GPX, TCX or FIT (D49), where the device hands files on; Delete
/// asks first, goes back home and offers to undo it there.
class const WorkoutScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
@ -62,6 +64,16 @@ class const WorkoutScreen({super.key}) extends StatelessWidget {
icon: const Icon(LucideIcons.pencil),
onPressed: () => _edit(context, content),
),
if (PersonScope.of<TrainPerson>(context).routeFiles != null)
IconButton(
tooltip: l10n.workoutExport,
icon: const Icon(LucideIcons.share2),
onPressed: () => _export(
context,
content,
located: analysis?.track.isNotEmpty ?? false,
),
),
IconButton(
tooltip: l10n.workoutDelete,
icon: const Icon(LucideIcons.trash2),
@ -101,6 +113,50 @@ class const WorkoutScreen({super.key}) extends StatelessWidget {
}
}
Future<void> _export(
BuildContext context,
WorkoutContent content, {
required bool located,
}) async {
final l10n = AppLocalizations.of(context);
final cubit = context.read<WorkoutCubit>();
final files = PersonScope.of<TrainPerson>(context).routeFiles!;
final name = workoutTitle(l10n, content);
final format = await showAdaptiveSheet<ExportFormat>(
context,
builder: (context) => SheetScaffold(
title: l10n.workoutExportTitle,
body: Column(
children: [
for (final format in ExportFormat.values)
if (format != ExportFormat.gpx || located)
ListTile(
leading: const Icon(LucideIcons.fileDown),
title: Text(format.extension.toUpperCase()),
subtitle: Text(switch (format) {
ExportFormat.gpx => l10n.workoutExportGpxHint,
ExportFormat.tcx => l10n.workoutExportTcxHint,
ExportFormat.fit => l10n.workoutExportFitHint,
}),
onTap: () => Navigator.of(context).pop(format),
),
],
),
),
);
if (format == null) {
return;
}
final bytes = await cubit.export(format, name: name);
if (bytes != null) {
await files.share(
name: exportNameOf(content, format),
bytes: bytes,
mimeType: format.mimeType,
);
}
}
Future<void> _delete(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final workouts = context.read<WorkoutsCubit>();