Port Telemetry, Format, RideStatistics; add cross-language parity harness

T03 — telemetry.dart, format.dart, live_telemetry.dart, plus pure domain models
(Trip/Segment/TrackPoint/RideStats) with no persistence dependency, so Drift can
map to them in T08 rather than the domain depending on the database.

T04 — ride_statistics.dart including ElevationAccumulator, ported structurally
faithfully: moving average, reversal hysteresis, gainIncludingPending, and the
finish() reconciliation against lastRaw.

T07 (early, because T04 forced it) — tool/parity/ drives identical fixtures
through the real Kotlin files and the Dart port, then diffs. Result: every value
byte-identical, including noisy_gain=38.959594555022136 to the last digit. The
sole difference is run_avg_speed, where Kotlin's 32-bit Float widens to double
with artefacts Dart's binary64 does not reproduce. Documented, not papered over.

That harness settled a real question. The ported elevation test failed at 50.9m
against Kotlin's 35m bound, which looked like a porting bug. It was not: Kotlin's
and Dart's Random(42) are different streams. On a shared LCG fixture both produce
39.0m -- which would also fail Kotlin's own bound. The native guard passes on seed
luck rather than on a property of the algorithm. The Dart test now uses the shared
LCG, asserts bit-equality with Kotlin, and sets its bound from measured behaviour
(25 seeds spanned 24.7-46.7m).

52 tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 20:59:35 -05:00
parent f0f9ed8c34
commit 4af4e3411a
14 changed files with 1368 additions and 1 deletions

204
lib/src/domain/models.dart Normal file
View File

@@ -0,0 +1,204 @@
/// Core domain models, free of any persistence or platform dependency.
///
/// Ported from the Room entities in `com.rippr.data`. The Room annotations are
/// deliberately **not** carried over: Drift owns the table definitions in T08 and maps
/// to these types, so the domain layer never depends on the database package. That is
/// the same separation the Kotlin app achieved by keeping logic free of Android imports.
///
/// ## One deliberate divergence: Float becomes double
///
/// Kotlin stores `speedKmh`, `accuracyM` and `bearingDeg` as 32-bit `Float`. Dart has no
/// float32 — every `double` is IEEE-754 binary64. These are therefore widened.
///
/// This is the right call (a float32 shim would be pure friction for sub-millimetre
/// precision on a GPS-derived value), but it means **speed-derived values cannot be
/// compared bit-for-bit across the two implementations**. Kotlin's `Float.toDouble()`
/// produces artefacts like `12.300000190734863`; Dart produces `12.3`. The T07 parity
/// harness must use a tolerance for these, and only these.
library;
/// Where a ride is in its lifecycle.
///
/// [Trip.endedAt] alone distinguishes active from finished, but cannot tell recording
/// from paused — and the recorder needs that distinction to decide what to do when the
/// OS restarts it mid-ride. Hence both.
enum TripState { recording, paused, completed }
/// One ride, from pressing Start to pressing Stop.
///
/// The aggregate fields are denormalised on purpose. They are accumulated as points
/// arrive and recomputed authoritatively when the trip completes, so the trips list can
/// render hundreds of rides without touching the point table.
class Trip {
const Trip({
this.id = 0,
required this.startedAt,
this.endedAt,
this.name,
this.state = TripState.recording,
this.distanceM = 0.0,
this.movingMillis = 0,
this.maxSpeedKmh = 0.0,
this.elevationGainM = 0.0,
this.pointCount = 0,
});
final int id;
final int startedAt;
/// Null while the ride is still active.
final int? endedAt;
/// Null means the UI derives a label from [startedAt]. Never store an empty string.
final String? name;
final TripState state;
final double distanceM;
final int movingMillis;
final double maxSpeedKmh;
final double elevationGainM;
final int pointCount;
bool get isActive => endedAt == null;
int get elapsedMillis => endedAt == null ? 0 : endedAt! - startedAt;
Trip copyWith({
int? id,
int? startedAt,
int? endedAt,
String? name,
TripState? state,
double? distanceM,
int? movingMillis,
double? maxSpeedKmh,
double? elevationGainM,
int? pointCount,
}) =>
Trip(
id: id ?? this.id,
startedAt: startedAt ?? this.startedAt,
endedAt: endedAt ?? this.endedAt,
name: name ?? this.name,
state: state ?? this.state,
distanceM: distanceM ?? this.distanceM,
movingMillis: movingMillis ?? this.movingMillis,
maxSpeedKmh: maxSpeedKmh ?? this.maxSpeedKmh,
elevationGainM: elevationGainM ?? this.elevationGainM,
pointCount: pointCount ?? this.pointCount,
);
}
/// One pause-free stretch of recording within a [Trip].
///
/// This layer is what makes pause correct rather than cosmetic. Without it, a rider who
/// pauses at a gas station and resumes across town gets a polyline drawn straight
/// through terrain they never travelled, and a distance total that includes it. Points
/// are grouped by segment for rendering, distance accumulation, and GPX `<trkseg>`
/// output, so every consumer naturally leaves a gap where the rider stopped.
class Segment {
const Segment({
this.id = 0,
required this.tripId,
required this.startedAt,
this.endedAt,
});
final int id;
final int tripId;
final int startedAt;
/// Null while this segment is still being recorded into.
final int? endedAt;
bool get isOpen => endedAt == null;
}
/// A single GPS fix.
///
/// Ordering is by [id] rather than [timestamp] everywhere it matters: `timestamp` comes
/// from the platform location fix, which is GPS-derived and can jump, whereas the
/// autoincrement id is genuinely monotonic in write order.
class TrackPoint {
const TrackPoint({
this.id = 0,
required this.tripId,
required this.segmentId,
required this.timestamp,
required this.latitude,
required this.longitude,
required this.speedKmh,
required this.altitudeM,
this.accuracyM = 0.0,
this.bearingDeg = 0.0,
this.synced = false,
});
final int id;
final int tripId;
final int segmentId;
final int timestamp;
final double latitude;
final double longitude;
final double speedKmh;
final double altitudeM;
final double accuracyM;
final double bearingDeg;
/// Set once the point has been accepted by the remote endpoint.
final bool synced;
TrackPoint copyWith({int? id, int? tripId, int? segmentId, bool? synced}) =>
TrackPoint(
id: id ?? this.id,
tripId: tripId ?? this.tripId,
segmentId: segmentId ?? this.segmentId,
timestamp: timestamp,
latitude: latitude,
longitude: longitude,
speedKmh: speedKmh,
altitudeM: altitudeM,
accuracyM: accuracyM,
bearingDeg: bearingDeg,
synced: synced ?? this.synced,
);
}
/// Cheap SQL-computed stats for the live recording screen.
///
/// Deliberately limited to what plain aggregate functions can express. Distance and
/// elevation gain are absent because they need consecutive-row differences — they are
/// accumulated in Dart and stored on the [Trip] row instead.
///
/// The SQLite-3.18 window-function limitation that forced this in the Kotlin app no
/// longer strictly applies (Drift bundles a modern SQLite), but the split is kept: the
/// accumulate-as-you-go design is what lets a mid-ride crash leave usable totals.
class RideStats {
const RideStats({
required this.pointCount,
required this.maxSpeedKmh,
required this.avgSpeedKmh,
required this.firstTimestamp,
required this.lastTimestamp,
required this.pendingUpload,
});
static const empty = RideStats(
pointCount: 0,
maxSpeedKmh: 0.0,
avgSpeedKmh: 0.0,
firstTimestamp: 0,
lastTimestamp: 0,
pendingUpload: 0,
);
final int pointCount;
final double maxSpeedKmh;
final double avgSpeedKmh;
final int firstTimestamp;
final int lastTimestamp;
final int pendingUpload;
int get durationMillis =>
pointCount == 0 ? 0 : lastTimestamp - firstTimestamp;
}

View File

@@ -0,0 +1,385 @@
/// Batch statistics over a stored ride.
///
/// Ported from `com.rippr.stats.RideStatistics`.
///
/// This is the authoritative computation. The recorder accumulates the same values live
/// as points arrive, but re-runs this on trip completion so a mid-ride process kill
/// cannot leave permanently skewed totals. Both paths must agree, which is why they
/// share the constants below rather than duplicating magic numbers.
library;
import 'dart:collection';
import 'dart:math' as math;
import '../domain/models.dart';
import '../geo/geo.dart';
import '../telemetry/telemetry.dart';
class RideSummary {
const RideSummary({
this.distanceM = 0.0,
this.elapsedMillis = 0,
this.movingMillis = 0,
this.maxSpeedKmh = 0.0,
this.avgMovingSpeedKmh = 0.0,
this.elevationGainM = 0.0,
this.elevationLossM = 0.0,
this.pointCount = 0,
});
static const empty = RideSummary();
final double distanceM;
/// Wall clock, first fix to last.
final int elapsedMillis;
/// Time spent above the speed noise floor.
final int movingMillis;
final double maxSpeedKmh;
/// Distance ÷ moving time — not the mean of the speed samples.
final double avgMovingSpeedKmh;
final double elevationGainM;
final double elevationLossM;
final int pointCount;
int get stoppedMillis => math.max(0, elapsedMillis - movingMillis);
// Kotlin got this free from `data class`. Needed so tests can compare against
// RideSummary.empty by value rather than by identity.
@override
bool operator ==(Object other) =>
other is RideSummary &&
other.distanceM == distanceM &&
other.elapsedMillis == elapsedMillis &&
other.movingMillis == movingMillis &&
other.maxSpeedKmh == maxSpeedKmh &&
other.avgMovingSpeedKmh == avgMovingSpeedKmh &&
other.elevationGainM == elevationGainM &&
other.elevationLossM == elevationLossM &&
other.pointCount == pointCount;
@override
int get hashCode => Object.hash(distanceM, elapsedMillis, movingMillis,
maxSpeedKmh, avgMovingSpeedKmh, elevationGainM, elevationLossM, pointCount);
@override
String toString() => 'RideSummary(distance: $distanceM m, elapsed: $elapsedMillis ms, '
'moving: $movingMillis ms, max: $maxSpeedKmh km/h, gain: $elevationGainM m, '
'loss: $elevationLossM m, points: $pointCount)';
}
class SpeedBucket {
const SpeedBucket(this.fromKmh, this.toKmh, this.millis);
final int fromKmh;
final int toKmh;
final int millis;
String get label => '$fromKmh–$toKmh';
}
class ElevationSample {
const ElevationSample(this.distanceM, this.altitudeM);
final double distanceM;
final double altitudeM;
}
/// A GPS dropout leaves a large gap between consecutive fixes. Without a cap, a
/// two-minute tunnel counts as two minutes of moving time at the last known speed.
const int maxSampleGapMillis = 10000;
/// Raw GPS altitude wanders by ±5–10 m even sitting still. Summing every positive delta
/// turns a flat ride into thousands of metres of climbing — the classic bug in this
/// calculation. A climb only counts once it exceeds this much in one direction.
const double elevationHysteresisM = 3.0;
/// ~7 s at 2 Hz: long enough to suppress wander, short enough to keep real terrain.
const int _smoothingWindow = 15;
/// Group points by segment id, preserving first-seen order.
///
/// Dart's `Map` is insertion-ordered, matching Kotlin's `groupBy` (a `LinkedHashMap`).
/// Order matters: the segment iteration order determines nothing statistically, but
/// keeping it identical makes the two implementations diffable.
LinkedHashMap<int, List<TrackPoint>> _groupBySegment(List<TrackPoint> points) {
final grouped = LinkedHashMap<int, List<TrackPoint>>();
for (final p in points) {
(grouped[p.segmentId] ??= <TrackPoint>[]).add(p);
}
return grouped;
}
RideSummary computeSummary(
List<TrackPoint> points, {
List<Segment> segments = const [],
}) {
if (points.isEmpty) return RideSummary.empty;
var distanceM = 0.0;
var movingMillis = 0;
var maxSpeedKmh = 0.0;
final elevation = ElevationAccumulator();
// Grouping by segment is what keeps a pause from inventing distance: points either
// side of a gas-station stop can be kilometres apart.
for (final segmentPoints in _groupBySegment(points).values) {
TrackPoint? previous;
for (final point in segmentPoints) {
maxSpeedKmh = math.max(maxSpeedKmh, point.speedKmh);
elevation.add(point.altitudeM);
if (previous != null) {
distanceM += haversineMeters(
previous.latitude,
previous.longitude,
point.latitude,
point.longitude,
);
final dt = point.timestamp - previous.timestamp;
if (dt >= 1 &&
dt <= maxSampleGapMillis &&
point.speedKmh >= speedNoiseFloorKmh) {
movingMillis += dt;
}
}
previous = point;
}
}
elevation.finish();
final elapsedMillis = _elapsedFor(points, segments);
// Guard the divide: a ride that never moved would otherwise produce NaN, which the UI
// happily renders as the literal text "NaN".
final avgMovingSpeedKmh = movingMillis > 0
? distanceM / 1000.0 / (movingMillis / 3600000.0)
: 0.0;
return RideSummary(
distanceM: distanceM,
elapsedMillis: elapsedMillis,
movingMillis: movingMillis,
maxSpeedKmh: maxSpeedKmh,
avgMovingSpeedKmh: avgMovingSpeedKmh,
elevationGainM: elevation.gain,
elevationLossM: elevation.loss,
pointCount: points.length,
);
}
/// Prefers segment boundaries over point timestamps: they capture the time between a
/// segment's last fix and the pause itself, which point timestamps cannot see.
int _elapsedFor(List<TrackPoint> points, List<Segment> segments) {
final closed = <int>[
for (final s in segments)
if (s.endedAt != null) s.endedAt! - s.startedAt,
];
if (closed.isNotEmpty && closed.length == segments.length) {
return closed.fold(0, (a, b) => a + b);
}
if (points.isEmpty) return 0;
var lo = points.first.timestamp;
var hi = points.first.timestamp;
for (final p in points) {
lo = math.min(lo, p.timestamp);
hi = math.max(hi, p.timestamp);
}
return math.max(0, hi - lo);
}
/// Time spent in each speed band. Buckets are keyed on the *interval* between fixes, so
/// the result is a time distribution rather than a sample count — a bike that sits idle
/// at 2 Hz would otherwise dominate purely by producing more samples.
List<SpeedBucket> speedHistogram(List<TrackPoint> points, {int bucketKmh = 10}) {
if (points.length < 2 || bucketKmh <= 0) return const [];
final millisByBucket = <int, int>{};
for (final segmentPoints in _groupBySegment(points).values) {
for (var i = 1; i < segmentPoints.length; i++) {
final dt = segmentPoints[i].timestamp - segmentPoints[i - 1].timestamp;
if (dt < 1 || dt > maxSampleGapMillis) continue;
final bucket = (segmentPoints[i].speedKmh / bucketKmh).toInt();
millisByBucket[bucket] = (millisByBucket[bucket] ?? 0) + dt;
}
}
// Kotlin used a sortedMapOf; Dart maps are insertion-ordered, so sort explicitly.
final keys = millisByBucket.keys.toList()..sort();
return [
for (final bucket in keys)
SpeedBucket(bucket * bucketKmh, (bucket + 1) * bucketKmh,
millisByBucket[bucket]!),
];
}
/// Altitude against distance travelled, downsampled for charting.
///
/// Sampled by distance along the path rather than by index, so a long stop does not
/// flatten the interesting part of the profile into a few pixels.
List<ElevationSample> elevationProfile(
List<TrackPoint> points, {
int maxSamples = 200,
}) {
if (points.isEmpty) return const [];
if (points.length == 1) {
return [ElevationSample(0.0, points[0].altitudeM)];
}
final full = <ElevationSample>[];
var cumulative = 0.0;
TrackPoint? previous;
var previousSegment = points.first.segmentId;
for (final point in points) {
if (previous != null) {
// Distance only accrues within a segment, matching computeSummary().
if (point.segmentId == previousSegment) {
cumulative += haversineMeters(
previous.latitude,
previous.longitude,
point.latitude,
point.longitude,
);
}
}
full.add(ElevationSample(cumulative, point.altitudeM));
previous = point;
previousSegment = point.segmentId;
}
if (full.length <= maxSamples) return full;
final step = full.length / maxSamples;
final lastIndex = full.length - 1;
return [
for (var i = 0; i < maxSamples; i++)
full[math.min((i * step).round(), lastIndex)],
full.last,
];
}
/// Elevation gain/loss accumulator that survives GPS altitude noise.
///
/// Two mechanisms, because one is not enough:
///
/// 1. **A moving-average window.** Raw GPS altitude wanders by ±5–10 m while completely
/// stationary. Averaging over [windowSize] samples cuts the noise by roughly
/// sqrt(windowSize), bringing it under the threshold below.
/// 2. **Reversal hysteresis.** A climb is only banked once the altitude turns back down
/// by more than [thresholdM] from its peak. Simply summing every delta that exceeds
/// a threshold does *not* work — noise crosses any small threshold constantly, and a
/// parked bike accumulates well over a kilometre of imaginary climbing. That was
/// measured, not assumed: the naive version reported 1498 m over a parked bike.
///
/// Streaming rather than batch so the recorder can accumulate live and the batch
/// computation can reuse the identical code path.
class ElevationAccumulator {
ElevationAccumulator({
this.windowSize = _smoothingWindow,
this.thresholdM = elevationHysteresisM,
});
final int windowSize;
final double thresholdM;
final _window = Queue<double>();
double _windowSum = 0.0;
double _lastRaw = 0.0;
double? _lastCommitted;
double _extreme = 0.0;
int _direction = 0; // 0 unknown, +1 climbing, -1 descending
double _gain = 0.0;
double _loss = 0.0;
double get gain => _gain;
double get loss => _loss;
void add(double altitudeM) {
if (!altitudeM.isFinite) return;
_lastRaw = altitudeM;
_window.addLast(altitudeM);
_windowSum += altitudeM;
if (_window.length > windowSize) _windowSum -= _window.removeFirst();
final smoothed = _windowSum / _window.length;
final committed = _lastCommitted;
if (committed == null) {
_lastCommitted = smoothed;
_extreme = smoothed;
return;
}
switch (_direction) {
case 0:
if (smoothed > committed + thresholdM) {
_direction = 1;
_extreme = smoothed;
} else if (smoothed < committed - thresholdM) {
_direction = -1;
_extreme = smoothed;
}
case 1:
if (smoothed > _extreme) {
_extreme = smoothed;
} else if (smoothed < _extreme - thresholdM) {
_gain += _extreme - committed;
_lastCommitted = _extreme;
_direction = -1;
_extreme = smoothed;
}
default:
if (smoothed < _extreme) {
_extreme = smoothed;
} else if (smoothed > _extreme + thresholdM) {
_loss += committed - _extreme;
_lastCommitted = _extreme;
_direction = 1;
_extreme = smoothed;
}
}
}
/// Gain including the run still in progress, without mutating state.
///
/// Safe to poll while recording continues — [finish] would end the run, which is wrong
/// mid-ride, but reading only [gain] would report zero for a climb that has not yet
/// turned back down.
double gainIncludingPending() {
final committed = _lastCommitted;
if (committed == null) return _gain;
final tip = _direction == 1 ? math.max(_extreme, _lastRaw) : _extreme;
return _direction == 1 && tip > committed ? _gain + (tip - committed) : _gain;
}
/// Banks the run still in progress. Must be called once the last point is added, or a
/// steady climb to the summit with no descent afterwards reports zero gain.
void finish() {
final committed = _lastCommitted;
if (committed == null) return;
// The moving average lags the true altitude by about half a window, so the final
// smoothed value clips the tail of a climb (a 100 m ascent measured 93 m before
// this). Reconcile against the last raw reading to recover it.
if (_direction == 1) {
_extreme = math.max(_extreme, _lastRaw);
} else if (_direction == -1) {
_extreme = math.min(_extreme, _lastRaw);
}
if (_direction == 1 && _extreme > committed) {
_gain += _extreme - committed;
} else if (_direction == -1 && _extreme < committed) {
_loss += committed - _extreme;
}
_lastCommitted = _extreme;
_direction = 0;
}
}

View File

@@ -0,0 +1,80 @@
/// Ephemeral, in-memory state published straight from the location callback.
///
/// Ported from `com.rippr.LiveTelemetry` and `com.rippr.UploadStatus`. Both were Kotlin
/// `object` singletons over `StateFlow`; here they are small value-holding broadcast
/// streams so the pure layer stays free of Flutter and Riverpod. The UI layer wraps
/// these in providers rather than the other way round.
///
/// Everything in this file is deliberately **not** persisted. Recording state lives in
/// the database because it must survive process death; these must not, because a stale
/// value from a previous process would be actively misleading.
library;
import 'dart:async';
/// A stream that also remembers its current value, so a late subscriber is not blind
/// until the next emission.
class _ValueStream<T> {
_ValueStream(this._value);
final _controller = StreamController<T>.broadcast();
T _value;
T get value => _value;
Stream<T> get stream => _controller.stream;
void set(T next) {
_value = next;
if (!_controller.isClosed) _controller.add(next);
}
Future<void> dispose() => _controller.close();
}
/// The most recent fix, published straight from the location callback.
///
/// The Trip row is only written every ~2 s and carries *max* speed, not current, so the
/// recording screen cannot show a live speedo from it.
///
/// This existing precisely because v2.0 shipped max speed as the headline figure and it
/// read as a frozen screen on a real ride — see `docs/TESTING.md` in the native repo.
class LiveTelemetry {
LiveTelemetry._();
static final instance = LiveTelemetry._();
final _speedKmh = _ValueStream<double>(0.0);
final _accuracyM = _ValueStream<double>(0.0);
double get speedKmh => _speedKmh.value;
double get accuracyM => _accuracyM.value;
Stream<double> get speedStream => _speedKmh.stream;
Stream<double> get accuracyStream => _accuracyM.stream;
void update(double speedKmh, double accuracyM) {
_speedKmh.set(speedKmh);
_accuracyM.set(accuracyM);
}
void clear() {
_speedKmh.set(0.0);
_accuracyM.set(0.0);
}
}
/// Last-seen upload failure, surfaced on the recording screen.
class UploadStatus {
UploadStatus._();
static final instance = UploadStatus._();
final _lastError = _ValueStream<String?>(null);
String? get lastError => _lastError.value;
Stream<String?> get stream => _lastError.stream;
void setError(String? message) => _lastError.set(message);
}

View File

@@ -0,0 +1,63 @@
/// Pure conversion and serialization logic, free of Flutter and platform types.
///
/// Ported from `com.rippr.Telemetry`. The Kotlin original also held process-wide
/// recording state; that was already deleted in v2 (state lives in the database, because
/// it must survive process death) and is not resurrected here.
library;
import 'dart:convert';
import '../domain/models.dart';
const double msToKmhFactor = 3.6;
double msToKmh(double metersPerSecond) => metersPerSecond * msToKmhFactor;
/// A parked bike still emits jittering fixes. Anything under this is reported as zero so
/// "max speed" is not set by GPS noise while the phone sits in a pocket.
const double speedNoiseFloorKmh = 1.5;
double sanitizeSpeedKmh(double raw) {
if (!raw.isFinite || raw < speedNoiseFloorKmh) return 0.0;
return raw;
}
/// Drop fixes too imprecise to be worth storing. 0 means "accuracy unknown".
bool isUsableFix(double accuracyMeters, {double maxAccuracyMeters = 50.0}) =>
accuracyMeters <= 0.0 || accuracyMeters <= maxAccuracyMeters;
String formatDuration(int millis) {
if (millis <= 0) return '00:00:00';
final totalSeconds = millis ~/ 1000;
final h = (totalSeconds ~/ 3600).toString().padLeft(2, '0');
final m = ((totalSeconds % 3600) ~/ 60).toString().padLeft(2, '0');
final s = (totalSeconds % 60).toString().padLeft(2, '0');
return '$h:$m:$s';
}
/// Encode a batch of points for the upload endpoint.
///
/// The trip and segment ids are written **per point, not per batch**: the unsynced-point
/// query draws by id and can straddle a segment or, after a discard-and-restart, a trip
/// boundary. Hoisting them to batch level would silently mislabel points.
String encodeBatch(String deviceId, List<TrackPoint> points) {
final array = points
.map((p) => <String, Object?>{
'id': p.id,
'trip_id': p.tripId,
'segment_id': p.segmentId,
'ts': p.timestamp,
'lat': p.latitude,
'lon': p.longitude,
'speed_kmh': p.speedKmh,
'alt_m': p.altitudeM,
'acc_m': p.accuracyM,
'bearing': p.bearingDeg,
})
.toList(growable: false);
return jsonEncode(<String, Object?>{
'device_id': deviceId,
'points': array,
});
}

35
lib/src/ui/format.dart Normal file
View File

@@ -0,0 +1,35 @@
/// Display formatting shared across screens.
///
/// Ported from `com.rippr.ui.Format`.
///
/// Timestamps come from the platform location fix, which is UTC epoch millis, so
/// everything here converts to the device zone. Formatting in UTC would show a 21:00
/// ride as tomorrow.
library;
import 'package:intl/intl.dart';
import '../domain/models.dart';
// Built per call rather than cached in a top-level final. The Kotlin original captured
// Locale.getDefault() once at class-init; doing the same here would freeze the format
// for the process lifetime and ignore a locale change.
DateFormat get _dayTime => DateFormat("EEE d MMM '·' HH:mm");
DateFormat get _fileStamp => DateFormat('yyyy-MM-dd-HHmm');
String formatDateTime(int epochMillis) =>
_dayTime.format(DateTime.fromMillisecondsSinceEpoch(epochMillis));
String formatFileTimestamp(int epochMillis) =>
_fileStamp.format(DateTime.fromMillisecondsSinceEpoch(epochMillis));
/// A trip's own name, or a date-derived label when it has none.
String tripLabel(Trip trip) => trip.name ?? formatDateTime(trip.startedAt);
String formatDistance(double meters) => meters < 1000
? '${meters.toInt()} m'
: '${(meters / 1000).toStringAsFixed(1)} km';
String formatSpeed(double kmh) => '${kmh.toStringAsFixed(1)} km/h';
String formatElevation(double meters) => '${meters.toInt()} m';