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
16 changed files with 1386 additions and 15 deletions
File diff suppressed because one or more lines are too long
|
|
@ -514,5 +514,10 @@
|
||||||
"announceTime": "Zeit {time}",
|
"announceTime": "Zeit {time}",
|
||||||
"announcePace": "Pace {pace} pro Kilometer",
|
"announcePace": "Pace {pace} pro Kilometer",
|
||||||
"announceSpeed": "Tempo {speed} Kilometer pro Stunde",
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -653,5 +653,10 @@
|
||||||
"announceSpeed": "Speed {speed} kilometres an hour",
|
"announceSpeed": "Speed {speed} kilometres an hour",
|
||||||
"@announceSpeed": {"placeholders": {"speed": {"type": "String"}}},
|
"@announceSpeed": {"placeholders": {"speed": {"type": "String"}}},
|
||||||
"announceHeartRate": "Heart rate {bpm}",
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3111,6 +3111,36 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Heart rate {bpm}'**
|
/// **'Heart rate {bpm}'**
|
||||||
String announceHeartRate(String 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
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -1872,4 +1872,21 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String announceHeartRate(String bpm) {
|
String announceHeartRate(String bpm) {
|
||||||
return 'Puls $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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1860,6 +1860,23 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String announceHeartRate(String bpm) {
|
String announceHeartRate(String bpm) {
|
||||||
return 'Heart rate $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`).
|
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ typedef OpenedFile = ({String name, Uint8List bytes});
|
||||||
const gpxMimeType = 'application/gpx+xml';
|
const gpxMimeType = 'application/gpx+xml';
|
||||||
|
|
||||||
/// Routes' files on the device (ADR 0030, D45): GPX opened with Train or
|
/// 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() {
|
abstract interface class RouteFiles() {
|
||||||
/// The files opened with Train or shared to it (Android's), each once.
|
/// The files opened with Train or shared to it (Android's), each once.
|
||||||
Stream<OpenedFile> get opened;
|
Stream<OpenedFile> get opened;
|
||||||
|
|
@ -23,9 +24,13 @@ abstract interface class RouteFiles() {
|
||||||
/// A GPX file the person picks; null where they cancel.
|
/// A GPX file the person picks; null where they cancel.
|
||||||
Future<OpenedFile?> pick();
|
Future<OpenedFile?> pick();
|
||||||
|
|
||||||
/// Hands [bytes] on as the GPX file [name]; false where the person
|
/// Hands [bytes] on as the file [name] of [mimeType] (GPX by default);
|
||||||
/// cancelled saving it.
|
/// false where the person cancelled saving it.
|
||||||
Future<bool> share({required String name, required Uint8List bytes});
|
Future<bool> share({
|
||||||
|
required String name,
|
||||||
|
required Uint8List bytes,
|
||||||
|
String mimeType = gpxMimeType,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [RouteFiles] on the platform: Android's through the app's own
|
/// [RouteFiles] on the platform: Android's through the app's own
|
||||||
|
|
@ -73,14 +78,18 @@ class PlatformRouteFiles(final FileSaver _saver) implements RouteFiles {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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) {
|
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', {
|
await _channel.invokeMethod<void>('share', {
|
||||||
'name': name,
|
'name': name,
|
||||||
'bytes': bytes,
|
'bytes': bytes,
|
||||||
'mimeType': gpxMimeType,
|
'mimeType': mimeType,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
|
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/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_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/data/workout_repository.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.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,
|
/// 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
|
/// 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(
|
class WorkoutCubit(
|
||||||
final WorkoutRepository _workouts,
|
final WorkoutRepository _workouts,
|
||||||
final MapSourceRepository _maps,
|
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 {
|
Future<void> _read() async {
|
||||||
final stored = [
|
final stored = [
|
||||||
for (final workout in await _workouts.all())
|
for (final workout in await _workouts.all())
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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/workout_cubit.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_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_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/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_chart.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_texts.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,
|
/// 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
|
/// 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
|
/// splits and its charts. Edit names it and writes its notes; Export hands
|
||||||
/// first, goes back home and offers to undo it there.
|
/// 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 {
|
class const WorkoutScreen({super.key}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -62,6 +64,16 @@ class const WorkoutScreen({super.key}) extends StatelessWidget {
|
||||||
icon: const Icon(LucideIcons.pencil),
|
icon: const Icon(LucideIcons.pencil),
|
||||||
onPressed: () => _edit(context, content),
|
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(
|
IconButton(
|
||||||
tooltip: l10n.workoutDelete,
|
tooltip: l10n.workoutDelete,
|
||||||
icon: const Icon(LucideIcons.trash2),
|
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 {
|
Future<void> _delete(BuildContext context) async {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final workouts = context.read<WorkoutsCubit>();
|
final workouts = context.read<WorkoutsCubit>();
|
||||||
|
|
|
||||||
|
|
@ -2653,6 +2653,46 @@ void main() {
|
||||||
expect(find.text('Pace'), findsNothing);
|
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 "
|
testWidgets("where the instance can't be asked for its map source, the "
|
||||||
'page says so and shows the rest', (tester) async {
|
'page says so and shows the rest', (tester) async {
|
||||||
await saveRun(DateTime(2026, 10, 27, 7, 30));
|
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';
|
import 'package:halkyon_train_flutter/src/feature/route/data/route_files.dart';
|
||||||
|
|
||||||
/// [RouteFiles] in memory: the file [picked] next, files handed over with
|
/// [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})
|
class FakeRouteFiles({@override final bool shares = true})
|
||||||
implements RouteFiles {
|
implements RouteFiles {
|
||||||
final _opened = StreamController<OpenedFile>.broadcast();
|
final _opened = StreamController<OpenedFile>.broadcast();
|
||||||
|
|
@ -13,6 +14,7 @@ class FakeRouteFiles({@override final bool shares = true})
|
||||||
OpenedFile? picked;
|
OpenedFile? picked;
|
||||||
|
|
||||||
final shared = <OpenedFile>[];
|
final shared = <OpenedFile>[];
|
||||||
|
final sharedTypes = <String>[];
|
||||||
|
|
||||||
/// A file opened with Train or shared to it.
|
/// A file opened with Train or shared to it.
|
||||||
void open(OpenedFile file) => _opened.add(file);
|
void open(OpenedFile file) => _opened.add(file);
|
||||||
|
|
@ -24,8 +26,13 @@ class FakeRouteFiles({@override final bool shares = true})
|
||||||
Future<OpenedFile?> pick() async => picked;
|
Future<OpenedFile?> pick() async => picked;
|
||||||
|
|
||||||
@override
|
@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));
|
shared.add((name: name, bytes: bytes));
|
||||||
|
sharedTypes.add(mimeType);
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
|
|
@ -1137,6 +1137,23 @@ Strava. What was found, 27 Oct:
|
||||||
for every activity until switched on (`announce.json`, `AnnounceStore`, never
|
for every activity until switched on (`announce.json`, `AnnounceStore`, never
|
||||||
synced). Not tried on a phone yet: how it sounds over music, eSpeak's and
|
synced). Not tried on a phone yet: how it sounds over music, eSpeak's and
|
||||||
RHVoice's reading of the German sentences.
|
RHVoice's reading of the German sentences.
|
||||||
|
- Built: 11b. *Export* on a workout's page (where the device hands files on) offers
|
||||||
|
GPX (only with a track), TCX and FIT, and hands the file to the share sheet (saved
|
||||||
|
where the person chooses on the desktop), named `halkyon-train-` and when it
|
||||||
|
started in UTC. Each is written from the same points (`workout_export.dart`): the
|
||||||
|
precise fixes while moving as the saved distance counts them, else the rower's
|
||||||
|
frames, else the heart rates; with the heart rate of the last 5 s and the cadence
|
||||||
|
of the window the point falls in, on foot in strides (half the steps, as Garmin's
|
||||||
|
devices write a run's cadence), a row's strokes, its watts; a plan's steps are the
|
||||||
|
laps (recovery *Resting* in TCX, FIT's intensities for warm-up and cool-down), else
|
||||||
|
one lap. GPX has a segment each piece between pauses; TCX writes the schema's order;
|
||||||
|
FIT (`fit_encoder.dart`, protocol 1.0, little-endian, profile 21.00, CRC-16/ARC) the
|
||||||
|
file ID as a development manufacturer's, the timer's start, stops and starts at
|
||||||
|
the pauses, a record each point (semicircles, centimetres), a lap each, the session
|
||||||
|
(a row as rowing indoors) and the activity. Tested by reading the files back: the
|
||||||
|
XML parsed, the FIT by a reader written from the specification in the tests
|
||||||
|
(`test/support/fit_reader.dart`), not by Garmin's SDK. Not tried yet: an upload to
|
||||||
|
Strava, intervals.icu or Garmin Connect.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue