feat(train): a workout's and its series' payloads (ADR 0030, D14)

A workout's payload is JSON naming its activity, times, name, notes and
totals; a series is cut into chunks of at most 10 minutes, each its
times and columns as differences from the value before, gzipped before
it is sealed (ten minutes of positions at a fix a second take a few KB).
A payload of a newer schema, an unknown activity or kind, or a blank one
is skipped, never read half.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-28 11:15:23 +01:00
commit d34e2b751b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 496 additions and 0 deletions

View file

@ -0,0 +1,98 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
/// The schema of a series' payload this app writes and reads.
const seriesSchemaVersion = 1;
/// [chunk] as its row's payload, before sealing (ADR 0030, D14): JSON, the
/// times and each column as differences from the value before (a missing
/// value stays `null` and moves nothing), gzipped, since sealed bytes
/// compress no more.
ByteData encodeSeries(SeriesChunk chunk) {
final json = {
'schemaVersion': seriesSchemaVersion,
'kind': chunk.kind.name,
'start': chunk.start.toUtc().millisecondsSinceEpoch,
'times': _differences(chunk.times),
'columns': {
for (final MapEntry(:key, :value) in chunk.columns.entries)
key: _differences(value),
},
};
return ByteData.sublistView(
Uint8List.fromList(
GZipCodec(level: 9).encode(utf8.encode(jsonEncode(json))),
),
);
}
/// The chunk [payload] holds, opened; null for a blank one (a row being
/// deleted), one of a newer schema or a kind this app doesn't know, or one
/// it can't read: skipped, never read half.
SeriesChunk? decodeSeries(ByteData payload) {
if (payload.lengthInBytes == 0) {
return null;
}
try {
final json = jsonDecode(
utf8.decode(GZipCodec().decode(Uint8List.sublistView(payload))),
) as Map<String, Object?>;
final version = json['schemaVersion']! as int;
if (version > seriesSchemaVersion) {
log('A series of schema $version skipped', name: 'Workouts');
return null;
}
final kind = SeriesKind.values.asNameMap()[json['kind']];
if (kind == null) {
log('A series of kind ${json['kind']} skipped', name: 'Workouts');
return null;
}
final times = _sums((json['times']! as List).cast<int?>());
final columns = {
for (final MapEntry(:key, :value)
in (json['columns']! as Map<String, Object?>).entries)
key: _sums((value! as List).cast<int?>()),
};
return SeriesChunk(
kind: kind,
start: DateTime.fromMillisecondsSinceEpoch(
json['start']! as int,
isUtc: true,
),
times: [for (final t in times) t!],
columns: columns,
);
} on Object catch (error) {
log('A series not read: ${error.runtimeType}', name: 'Workouts');
return null;
}
}
/// Each value as its difference from the value before it that isn't
/// missing; the first as it is.
List<int?> _differences(List<int?> values) {
final differences = <int?>[];
int? last;
for (final value in values) {
if (value == null) {
differences.add(null);
} else {
differences.add(value - (last ?? 0));
last = value;
}
}
return differences;
}
/// [_differences] undone.
List<int?> _sums(List<int?> differences) {
var last = 0;
return [
for (final difference in differences)
if (difference == null) null else last += difference,
];
}

View file

@ -0,0 +1,69 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:typed_data';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// The schema of a workout's payload this app writes and reads.
const workoutSchemaVersion = 1;
/// [content] as its row's payload, before sealing: UTF-8 JSON, times in UTC
/// milliseconds.
ByteData encodeWorkout(WorkoutContent content) => ByteData.sublistView(
Uint8List.fromList(
utf8.encode(
jsonEncode({
'schemaVersion': workoutSchemaVersion,
'activity': content.activity.name,
'startedAt': content.startedAt.toUtc().millisecondsSinceEpoch,
'endedAt': content.endedAt.toUtc().millisecondsSinceEpoch,
'name': ?content.name,
'notes': ?content.notes,
'distanceMetres': ?content.distanceMetres,
'movingMs': ?content.movingTime?.inMilliseconds,
}),
),
),
);
/// The workout [payload] holds, opened; null for a blank one (a row being
/// deleted), one of a newer schema or an activity this app doesn't know, or
/// one it can't read.
WorkoutContent? decodeWorkout(ByteData payload) {
if (payload.lengthInBytes == 0) {
return null;
}
try {
final json = jsonDecode(
utf8.decode(Uint8List.sublistView(payload)),
) as Map<String, Object?>;
final version = json['schemaVersion']! as int;
if (version > workoutSchemaVersion) {
log('A workout of schema $version skipped', name: 'Workouts');
return null;
}
final activity = Activity.values.asNameMap()[json['activity']];
if (activity == null) {
log(
'A workout of activity ${json['activity']} skipped',
name: 'Workouts',
);
return null;
}
DateTime time(String key) =>
DateTime.fromMillisecondsSinceEpoch(json[key]! as int, isUtc: true);
final movingMs = json['movingMs'] as int?;
return WorkoutContent(
activity: activity,
startedAt: time('startedAt'),
endedAt: time('endedAt'),
name: json['name'] as String?,
notes: json['notes'] as String?,
distanceMetres: json['distanceMetres'] as int?,
movingTime: movingMs == null ? null : Duration(milliseconds: movingMs),
);
} on Object catch (error) {
log('A workout not read: ${error.runtimeType}', name: 'Workouts');
return null;
}
}

View file

@ -0,0 +1,79 @@
import 'package:equatable/equatable.dart';
/// What a series holds (ADR 0030, D14): each kind's columns and their units
/// are its own. Stored by name, so a kind this app doesn't know is skipped.
enum SeriesKind() {
/// Where the person was: `lat` and `lon` in 1e-7 degrees, `alt` and `acc`
/// (the fix's accuracy) in centimetres.
position,
/// The heart's rate, `bpm`.
heartRate,
/// Paused and going on again (D16): `paused` 1 or 0, `auto` 1 where Train
/// paused by itself.
pauses,
}
/// How long a chunk runs at most: a 3-hour ride is 18 chunks of a few KB
/// each, rather than one row of several hundred.
const maxChunkLength = Duration(minutes: 10);
/// A chunk of one of a workout's series (D14): up to [maxChunkLength] of
/// samples, as columns of whole numbers beside their times, each value
/// possibly missing (`null`). Never changed once written.
final class const SeriesChunk({
/// What it holds.
required final SeriesKind kind,
/// When its first sample was taken, in UTC.
required final DateTime start,
/// Each sample's time, in milliseconds after [start].
required final List<int> times,
/// Each column by its name, one value per sample.
required final Map<String, List<int?>> columns,
}) with Equatable {
/// Each sample's time.
List<DateTime> get absoluteTimes => [
for (final t in times) start.add(Duration(milliseconds: t)),
];
@override
List<Object?> get props => [kind, start, times, columns];
}
/// A series of [kind] cut into chunks of at most [maxChunkLength], each
/// starting at its first sample: [times] in milliseconds after [start], in
/// order, and [columns] one value per time.
List<SeriesChunk> chunksOf(
SeriesKind kind, {
required DateTime start,
required List<int> times,
required Map<String, List<int?>> columns,
}) {
final chunks = <SeriesChunk>[];
var from = 0;
while (from < times.length) {
final first = times[from];
var to = from;
while (to < times.length &&
times[to] - first < maxChunkLength.inMilliseconds) {
to++;
}
chunks.add(
SeriesChunk(
kind: kind,
start: start.add(Duration(milliseconds: first)),
times: [for (var i = from; i < to; i++) times[i] - first],
columns: {
for (final MapEntry(:key, :value) in columns.entries)
key: value.sublist(from, to),
},
),
);
from = to;
}
return chunks;
}

View file

@ -0,0 +1,44 @@
import 'package:equatable/equatable.dart';
/// What a workout was (ADR 0030, D17), each one of Health Connect's exercise
/// types. Stored by name, so one this app doesn't know is skipped and new ones
/// need no migration.
enum Activity() {
running,
walking,
hiking,
cycling,
indoorRowing,
}
/// What a workout is, as its sealed payload holds it (D14); its samples are
/// its series.
final class const WorkoutContent({
/// What it was.
required final Activity activity,
/// When it started and ended, in UTC.
required final DateTime startedAt,
required final DateTime endedAt,
/// What the person called it, and wrote about it.
final String? name,
final String? notes,
/// How far it went, where it went anywhere.
final int? distanceMetres,
/// How long it moved, its pauses left out (D16).
final Duration? movingTime,
}) with Equatable {
@override
List<Object?> get props => [
activity,
startedAt,
endedAt,
name,
notes,
distanceMetres,
movingTime,
];
}