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

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;
}