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:
parent
db7e0b7bcd
commit
128ba1f6c8
4 changed files with 1386 additions and 15 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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>'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue