screen-locker/stronglift_replacement/workout_app/lib/models/run_track.dart
Krzysztof kuhy Rudnicki c2544c4a9c Add GPS run foundation: TCX codec, DB, deps
Pure-Dart TCX codec pinned to the PC's RunnerUp parser by a shared
golden fixture (Dart byte-compare + pytest through the real verifier),
GPS filters and moving-time maths mirroring the PC walk rule, runs /
run_points tables (schema v9), PositionSource seam over geolocator,
location permissions and foreground service type, map/cache deps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 15:37:41 +01:00

156 lines
4.2 KiB
Dart

/// A recorded run or walk: the one shape the recorder, the TCX codec, the
/// database and the map all share.
library;
import 'package:flutter/foundation.dart';
/// The sports the app records. The name doubles as the TCX `Sport` attribute
/// and the file-name suffix the PC keys on (`..._Walking.tcx`).
enum RunSport {
/// A run; credited on distance OR duration by the PC.
running('Running'),
/// A walk; the PC sums a day's walks and credits their moving time.
walking('Walking');
const RunSport(this.tcxName);
/// `Sport="..."` on the TCX `Activity` and `_<name>.tcx` in the file name.
final String tcxName;
/// The sport named [tcxName], or null for anything the app does not record.
static RunSport? fromTcxName(String? tcxName) {
for (final sport in values) {
if (sport.tcxName == tcxName) return sport;
}
return null;
}
}
/// Where a track came from; stored as `runs.source`.
enum RunSource {
/// Recorded by this app.
app('app'),
/// Parsed from a RunnerUp export (history import).
runnerupImport('runnerup_import');
const RunSource(this.storageKey);
/// The value in the `runs.source` column.
final String storageKey;
/// The source stored as [key]; throws on an unknown key.
static RunSource fromStorageKey(String key) =>
values.firstWhere((s) => s.storageKey == key);
}
/// One accepted GPS fix.
@immutable
class RunPoint {
/// Creates a point; [time] is normalised to UTC.
RunPoint({
required DateTime time,
required this.latitude,
required this.longitude,
required this.distanceM,
this.altitudeM,
}) : time = time.toUtc();
/// When the fix was taken (UTC).
final DateTime time;
/// Degrees north.
final double latitude;
/// Degrees east.
final double longitude;
/// Metres above sea level, when the device reports it.
final double? altitudeM;
/// Cumulative credited distance from the start, in metres.
///
/// The PC derives walking speed from the difference between two points'
/// values, so this is the field the recorder must keep honest.
final double distanceM;
@override
bool operator ==(Object other) =>
other is RunPoint &&
other.time == time &&
other.latitude == latitude &&
other.longitude == longitude &&
other.altitudeM == altitudeM &&
other.distanceM == distanceM;
@override
int get hashCode =>
Object.hash(time, latitude, longitude, altitudeM, distanceM);
}
/// A finished (or in-progress) track.
@immutable
class RunTrack {
/// Creates a track; [startedAt] is normalised to UTC.
RunTrack({
required this.id,
required this.sport,
required DateTime startedAt,
required this.points,
required this.totalSeconds,
required this.distanceM,
this.source = RunSource.app,
}) : startedAt = startedAt.toUtc();
/// A track whose [id] is derived from [startedAt] (see [idFor]).
factory RunTrack.started({
required RunSport sport,
required DateTime startedAt,
required List<RunPoint> points,
required int totalSeconds,
required double distanceM,
RunSource source = RunSource.app,
}) => RunTrack(
id: idFor(startedAt),
sport: sport,
startedAt: startedAt,
points: points,
totalSeconds: totalSeconds,
distanceM: distanceM,
source: source,
);
/// The TCX `<Id>`: the start instant, UTC, second precision.
///
/// The PC deduplicates a run's copies (inbox and `processed/`) by this
/// string, so two different runs must never share one.
static String idFor(DateTime startedAt) {
final u = startedAt.toUtc();
String two(int v) => v.toString().padLeft(2, '0');
return '${u.year.toString().padLeft(4, '0')}-${two(u.month)}-${two(u.day)}'
'T${two(u.hour)}:${two(u.minute)}:${two(u.second)}Z';
}
/// The TCX `<Id>` and the database key.
final String id;
/// Running or walking.
final RunSport sport;
/// When recording started (UTC).
final DateTime startedAt;
/// Accepted fixes in time order.
final List<RunPoint> points;
/// Moving time in seconds (not elapsed): what the PC reads as the duration.
final int totalSeconds;
/// Credited distance in metres.
final double distanceM;
/// Recorded here or imported.
final RunSource source;
}