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