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:
parent
7b20851a0a
commit
d34e2b751b
4 changed files with 496 additions and 0 deletions
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
Loading…
Reference in a new issue