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
10 changed files with 1386 additions and 15 deletions
|
|
@ -514,5 +514,10 @@
|
|||
"announceTime": "Zeit {time}",
|
||||
"announcePace": "Pace {pace} pro Kilometer",
|
||||
"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": {"placeholders": {"speed": {"type": "String"}}},
|
||||
"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:
|
||||
/// **'Heart rate {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
|
||||
|
|
|
|||
|
|
@ -1872,4 +1872,21 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String announceHeartRate(String 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) {
|
||||
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`).
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ typedef OpenedFile = ({String name, Uint8List bytes});
|
|||
const gpxMimeType = 'application/gpx+xml';
|
||||
|
||||
/// 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() {
|
||||
/// The files opened with Train or shared to it (Android's), each once.
|
||||
Stream<OpenedFile> get opened;
|
||||
|
|
@ -23,9 +24,13 @@ abstract interface class RouteFiles() {
|
|||
/// A GPX file the person picks; null where they cancel.
|
||||
Future<OpenedFile?> pick();
|
||||
|
||||
/// Hands [bytes] on as the GPX file [name]; false where the person
|
||||
/// cancelled saving it.
|
||||
Future<bool> share({required String name, required Uint8List bytes});
|
||||
/// Hands [bytes] on as the file [name] of [mimeType] (GPX by default);
|
||||
/// false where the person cancelled saving it.
|
||||
Future<bool> share({
|
||||
required String name,
|
||||
required Uint8List bytes,
|
||||
String mimeType = gpxMimeType,
|
||||
});
|
||||
}
|
||||
|
||||
/// [RouteFiles] on the platform: Android's through the app's own
|
||||
|
|
@ -73,14 +78,18 @@ class PlatformRouteFiles(final FileSaver _saver) implements RouteFiles {
|
|||
}
|
||||
|
||||
@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) {
|
||||
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', {
|
||||
'name': name,
|
||||
'bytes': bytes,
|
||||
'mimeType': gpxMimeType,
|
||||
'mimeType': mimeType,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
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/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/model/workout.dart';
|
||||
|
||||
/// 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
|
||||
/// where it has a track to show.
|
||||
/// where it has a track to show; the workout as a file (D49).
|
||||
class WorkoutCubit(
|
||||
final WorkoutRepository _workouts,
|
||||
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 {
|
||||
final stored = [
|
||||
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/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_export.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_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,
|
||||
/// 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
|
||||
/// first, goes back home and offers to undo it there.
|
||||
/// splits and its charts. Edit names it and writes its notes; Export hands
|
||||
/// 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 {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -62,6 +64,16 @@ class const WorkoutScreen({super.key}) extends StatelessWidget {
|
|||
icon: const Icon(LucideIcons.pencil),
|
||||
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(
|
||||
tooltip: l10n.workoutDelete,
|
||||
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 {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final workouts = context.read<WorkoutsCubit>();
|
||||
|
|
|
|||
Loading…
Reference in a new issue