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
4 changed files with 1386 additions and 15 deletions

View file

@ -2653,6 +2653,46 @@ void main() {
expect(find.text('Pace'), findsNothing);
});
testWidgets('a run goes out as GPX, TCX or FIT, a row as TCX or FIT '
'alone (D49)', (tester) async {
await saveRun(DateTime(2026, 10, 27, 7, 30), strap: true);
await saveRow();
final files = FakeRouteFiles();
await start(
tester,
usedBefore: true,
workouts: workouts,
routeFiles: files,
);
await tester.tap(find.text('Morning run'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Export'));
await tester.pumpAndSettle();
expect(find.text('GPX'), findsOneWidget);
expect(find.text('TCX'), findsOneWidget);
await tester.tap(find.text('FIT'));
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
final fit = files.shared.single;
expect(fit.name, endsWith('.fit'));
expect(String.fromCharCodes(fit.bytes, 8, 12), '.FIT');
expect(files.sharedTypes.single, 'application/vnd.ant.fit');
await tester.pageBack();
await tester.pumpAndSettle();
await tester.tap(find.text('Evening row'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Export'));
await tester.pumpAndSettle();
expect(find.text('GPX'), findsNothing);
await tester.tap(find.text('TCX'));
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
expect(utf8.decode(files.shared.last.bytes), contains('Sport="Other"'));
});
testWidgets("where the instance can't be asked for its map source, the "
'page says so and shows the rest', (tester) async {
await saveRun(DateTime(2026, 10, 27, 7, 30));

View file

@ -0,0 +1,294 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.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/data/fit_encoder.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:xml/xml.dart';
import '../../support/fit_reader.dart';
/// A workout out as GPX, TCX and FIT (ADR 0030, D49).
void main() {
final start = DateTime.utc(2026, 10, 29, 7, 30);
DateTime at(num seconds) =>
start.add(Duration(milliseconds: (seconds * 1000).round()));
/// A fix [metres] north of where the equator crosses Greenwich.
Fix north(int seconds, num metres, {int accuracyCm = 500}) => Fix(
time: at(seconds),
latE7: (metres * 89.932).round(),
lonE7: 0,
accuracyCm: accuracyCm,
altitudeCm: 10000 + seconds * 10,
);
/// A run of two minutes at 3 m/s, paused from 60 s to 80 s, an imprecise
/// fix at 25 s, a heart rate each second and a step each 0.4 s.
final run = Recording(
activity: Activity.running,
startedAt: start,
fixes: [
for (var s = 0; s <= 120; s += 10)
if (s != 70) north(s, s * 3),
north(25, 500, accuracyCm: 4000),
north(70, 210),
]..sort((a, b) => a.time.compareTo(b.time)),
pauses: [
PauseMark(time: at(60)),
PauseMark(time: at(80), paused: false),
],
heartRates: [
for (var s = 0; s <= 120; s++) HeartRate(time: at(s), bpm: 140 + s ~/ 10),
],
steps: [for (var i = 0; i < 300; i++) at(i * 0.4)],
endedAt: at(120),
);
final (content, series) = recordedWorkout(run);
final track = exportTrackOf(content, series);
test("FIT's CRC is CRC-16/ARC", () {
expect(fitCrc('123456789'.codeUnits), 0xBB3D);
});
test('the points are the precise fixes while moving, with the distance, '
'the heart rate and the strides', () {
expect(track.located, isTrue);
expect(
[
for (final point in track.points)
point.time.difference(start).inSeconds,
],
[0, 10, 20, 30, 40, 50, 80, 90, 100, 110, 120],
);
final first = track.points.first;
expect(first.metres, 0);
expect(first.altitude, 100);
expect(first.bpm, 140);
// 25 steps in 10 s: 150 a minute, 75 strides.
expect(first.cadence, 75);
expect(track.points[1].metres, closeTo(30, 0.5));
// The fix at 60 s came as the pause did.
final afterPause = track.points[6];
expect(afterPause.afterPause, isTrue);
// The distance doesn't count the pause's way.
expect(afterPause.metres, closeTo(track.points[5].metres!, 0.01));
expect(track.points.where((point) => point.afterPause), hasLength(1));
expect(track.laps.single.metres, content.distanceMetres);
expect(track.laps.single.movingTime, const Duration(seconds: 100));
});
test('GPX: a segment each piece, the heart rate and cadence in '
"Garmin's extension", () {
final gpx = XmlDocument.parse(
gpxOfWorkout(content, track, name: 'Morning run'),
);
final root = gpx.rootElement;
expect(root.getAttribute('version'), '1.1');
final segments = root.findAllElements('trkseg').toList();
expect(segments, hasLength(2));
expect(segments.first.findElements('trkpt'), hasLength(6));
final point = segments.first.findElements('trkpt').first;
expect(point.getAttribute('lat'), '0.0000000');
expect(point.getElement('time')!.innerText, '2026-10-29T07:30:00Z');
expect(point.getElement('ele')!.innerText, '100.0');
expect(point.findAllElements('gpxtpx:hr').single.innerText, '140');
expect(point.findAllElements('gpxtpx:cad').single.innerText, '75');
expect(root.findAllElements('type').single.innerText, 'running');
});
test("TCX: the sport, a lap, each trackpoint's elements in the schema's "
"order, a run's cadence as RunCadence", () {
final tcx = XmlDocument.parse(tcxOf(content, track));
final activity = tcx.findAllElements('Activity').single;
expect(activity.getAttribute('Sport'), 'Running');
final lap = activity.findElements('Lap').single;
expect(lap.getAttribute('StartTime'), '2026-10-29T07:30:00Z');
expect(
[for (final element in lap.childElements) element.localName],
[
'TotalTimeSeconds',
'DistanceMeters',
'Calories',
'AverageHeartRateBpm',
'MaximumHeartRateBpm',
'Intensity',
'TriggerMethod',
'Track',
],
);
expect(lap.getElement('TotalTimeSeconds')!.innerText, '100.0');
expect(lap.getElement('MaximumHeartRateBpm')!.innerText.trim(), '152');
final points = lap.findAllElements('Trackpoint').toList();
expect(points, hasLength(11));
expect(
[for (final element in points.first.childElements) element.localName],
[
'Time',
'Position',
'AltitudeMeters',
'DistanceMeters',
'HeartRateBpm',
'Extensions',
],
);
expect(points.first.findAllElements('RunCadence').single.innerText, '75');
expect(points.first.findAllElements('Cadence'), isEmpty);
});
test('FIT: the file ID, a record each point, the timer events, the lap, '
'the session and the activity, as its specification reads', () {
final messages = readFit(fitOf(content, track));
Iterable<FitMessage> all(int global) =>
messages.where((message) => message.global == global);
final fileId = all(0).single.fields;
expect(fileId[0], 4);
expect(fileId[4], fitTime(start));
final records = all(20).toList();
expect(records, hasLength(11));
expect(records.first.fields[253], fitTime(start));
expect(records.first.fields[3], 140);
expect(records.first.fields[4], 75);
// 100 m altitude: (100 + 500) × 5.
expect(records.first.fields[2], 3000);
// 90 m north, in 1e-7 degrees as kept, in semicircles.
expect(
records[3].fields[0],
((90 * 89.932).round() / 1e7 * 2147483648 / 180).round(),
);
expect(records[1].fields[5], closeTo(3000, 50));
expect(records.first.fields.containsKey(1), isTrue);
expect(
[for (final event in all(21)) event.fields[1]],
[0, 4, 0, 4],
);
final lap = all(19).single.fields;
expect(lap[2], fitTime(start));
expect(lap[8], 100000);
expect(lap[7], 120000);
expect(lap[24], 7);
final session = all(18).single.fields;
expect(session[5], 1);
expect(session[8], 100000);
expect(session[9], content.distanceMetres! * 100);
expect(session[17], 152);
expect(session[26], 1);
final activity = all(34).single.fields;
expect(activity[1], 1);
expect(activity[3], 26);
});
test("a plan's steps are the laps, with their intensities", () {
final planned = WorkoutContent(
activity: Activity.running,
startedAt: start,
endedAt: at(120),
movingTime: const Duration(seconds: 100),
distanceMetres: 300,
plan: WorkoutPlan(
name: 'Short',
laps: [
WorkoutLap(
step: const PlanStep(
kind: StepKind.work,
end: EndAfterTime(Duration(minutes: 1)),
),
startedAt: start,
endedAt: at(60),
movingTime: const Duration(seconds: 60),
metres: 180,
),
WorkoutLap(
step: const PlanStep(kind: StepKind.recovery, end: EndWithLap()),
startedAt: at(60),
endedAt: at(120),
movingTime: const Duration(seconds: 40),
metres: 120,
),
],
),
);
final laps = exportTrackOf(planned, series).laps;
expect(
[for (final lap in laps) lap.kind],
[
StepKind.work,
StepKind.recovery,
],
);
final tcx = XmlDocument.parse(
tcxOf(planned, exportTrackOf(planned, series)),
);
final tcxLaps = tcx.findAllElements('Lap').toList();
expect(
[for (final lap in tcxLaps) lap.getElement('Intensity')!.innerText],
[
'Active',
'Resting',
],
);
// The point at 60 s starts the second lap.
expect(tcxLaps.first.findAllElements('Trackpoint'), hasLength(6));
final fitLaps = [
for (final message in readFit(
fitOf(planned, exportTrackOf(planned, series)),
))
if (message.global == 19) message.fields,
];
expect([for (final lap in fitLaps) lap[23]], [0, 1]);
expect([for (final lap in fitLaps) lap[24]], [0, 7]);
});
test("a row goes out as its rower's frames, never as GPX", () {
final row = Recording(
activity: Activity.indoorRowing,
startedAt: start,
rowing: [
for (var s = 0; s <= 60; s += 2)
RowingSample(
time: at(s),
strokeRate: 24,
strokes: s ~/ 2,
metres: s * 4,
watts: 180,
),
],
endedAt: at(60),
);
final (rowed, rowSeries) = recordedWorkout(row);
final rowTrack = exportTrackOf(rowed, rowSeries);
expect(rowTrack.located, isFalse);
expect(ExportFormat.gpx.takes(rowTrack), isFalse);
expect(ExportFormat.fit.takes(rowTrack), isTrue);
expect(rowTrack.points.last.metres, 240);
expect(rowTrack.points.last.cadence, 24);
expect(rowTrack.points.last.watts, 180);
final tcx = XmlDocument.parse(tcxOf(rowed, rowTrack));
expect(
tcx.findAllElements('Activity').single.getAttribute('Sport'),
'Other',
);
expect(tcx.findAllElements('Cadence').first.innerText, '24');
expect(tcx.findAllElements('Watts').first.innerText, '180');
final session = [
for (final message in readFit(fitOf(rowed, rowTrack)))
if (message.global == 18) message.fields,
].single;
expect(session[5], 15);
expect(session[6], 14);
});
test('the file is named by when the workout started, in UTC', () {
expect(
exportNameOf(content, ExportFormat.fit),
'halkyon-train-20261029-0730.fit',
);
expect(
utf8.decode(exportOf(ExportFormat.gpx, content, track, name: 'Run')),
contains('<name>Run</name>'),
);
});
}

View file

@ -4,7 +4,8 @@ import 'dart:typed_data';
import 'package:halkyon_train_flutter/src/feature/route/data/route_files.dart';
/// [RouteFiles] in memory: the file [picked] next, files handed over with
/// [open], and the files shared kept in [shared].
/// [open], and the files shared kept in [shared], their types in
/// [sharedTypes].
class FakeRouteFiles({@override final bool shares = true})
implements RouteFiles {
final _opened = StreamController<OpenedFile>.broadcast();
@ -13,6 +14,7 @@ class FakeRouteFiles({@override final bool shares = true})
OpenedFile? picked;
final shared = <OpenedFile>[];
final sharedTypes = <String>[];
/// A file opened with Train or shared to it.
void open(OpenedFile file) => _opened.add(file);
@ -24,8 +26,13 @@ class FakeRouteFiles({@override final bool shares = true})
Future<OpenedFile?> pick() async => picked;
@override
Future<bool> share({required String name, required Uint8List bytes}) async {
Future<bool> share({
required String name,
required Uint8List bytes,
String mimeType = gpxMimeType,
}) async {
shared.add((name: name, bytes: bytes));
sharedTypes.add(mimeType);
return true;
}
}

View file

@ -0,0 +1,74 @@
import 'dart:typed_data';
import 'package:halkyon_train_flutter/src/feature/workout/data/fit_encoder.dart';
/// A FIT message as read: its global number and its fields' values by
/// number, invalid values left out.
typedef FitMessage = ({int global, Map<int, int> fields});
/// Reads a FIT file as the FIT SDK's protocol 1.0 lays it out, written
/// from its specification apart from [FitWriter]: the header and the file's
/// CRC checked, each definition (little-endian only) and the messages of
/// normal headers; throws a [FormatException] on anything else.
List<FitMessage> readFit(Uint8List bytes) {
final data = ByteData.sublistView(bytes);
final headerSize = bytes[0];
if (headerSize != 14 || String.fromCharCodes(bytes, 8, 12) != '.FIT') {
throw const FormatException('Not a FIT header');
}
if (data.getUint16(12, Endian.little) != fitCrc(bytes.sublist(0, 12))) {
throw const FormatException("The header's CRC");
}
final size = data.getUint32(4, Endian.little);
if (bytes.length != headerSize + size + 2) {
throw const FormatException("The data's size");
}
final end = headerSize + size;
if (data.getUint16(end, Endian.little) != fitCrc(bytes.sublist(0, end))) {
throw const FormatException("The file's CRC");
}
final definitions = <int, ({int global, List<(int, int, int)> fields})>{};
final messages = <FitMessage>[];
var at = headerSize;
while (at < end) {
final header = bytes[at++];
if (header & 0x80 != 0) {
throw const FormatException('A compressed timestamp');
}
final local = header & 0x0F;
if (header & 0x40 != 0) {
if (bytes[at + 1] != 0) {
throw const FormatException('Big-endian');
}
final global = data.getUint16(at + 2, Endian.little);
final count = bytes[at + 4];
at += 5;
definitions[local] = (
global: global,
fields: [
for (var i = 0; i < count; i++)
(bytes[at + i * 3], bytes[at + i * 3 + 1], bytes[at + i * 3 + 2]),
],
);
at += count * 3;
continue;
}
final definition = definitions[local]!;
final fields = <int, int>{};
for (final (number, size, base) in definition.fields) {
final (value, invalid) = switch ((base, size)) {
(0x00 || 0x02, 1) => (bytes[at], 0xFF),
(0x84, 2) => (data.getUint16(at, Endian.little), 0xFFFF),
(0x85, 4) => (data.getInt32(at, Endian.little), 0x7FFFFFFF),
(0x86, 4) => (data.getUint32(at, Endian.little), 0xFFFFFFFF),
_ => throw FormatException('Base type $base of $size bytes'),
};
if (value != invalid) {
fields[number] = value;
}
at += size;
}
messages.add((global: definition.global, fields: fields));
}
return messages;
}