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:
204
lib/src/domain/models.dart
Normal file
204
lib/src/domain/models.dart
Normal 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;
|
||||
}
|
||||
385
lib/src/stats/ride_statistics.dart
Normal file
385
lib/src/stats/ride_statistics.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
80
lib/src/telemetry/live_telemetry.dart
Normal file
80
lib/src/telemetry/live_telemetry.dart
Normal 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);
|
||||
}
|
||||
63
lib/src/telemetry/telemetry.dart
Normal file
63
lib/src/telemetry/telemetry.dart
Normal 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
35
lib/src/ui/format.dart
Normal 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';
|
||||
Reference in New Issue
Block a user