Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions lib/ble/ble_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,89 @@ class ChunkFailureLedger {
/// With continuous listening there is no discrete "sync done" signal, so we can't
/// fire the DerivationEngine off a SyncReport anymore. Instead, every time records
/// are persisted we mark them as dirty; once the inbound record stream goes quiet
/// Whether band records are landing RIGHT NOW.
///
/// Answers the TestFlight report "don't get to know if syncing is happening or
/// not" without adding a spinner: records arrive in bursts with gaps, so the
/// answer has to hold for a moment after each batch or an indicator would
/// strobe — and it has to expire, or a finished drain would look like a running
/// one forever.
///
/// Pure, so the window is testable without a band or a clock.
class SyncActivityWindow {
SyncActivityWindow({this.windowMs = 6000});

/// How long one batch keeps the answer true.
final int windowMs;

int _lastMs = 0;

/// Records just landed.
void mark(int nowMs) => _lastMs = nowMs;

/// True while the last batch is still within [windowMs].
bool isActive(int nowMs) => _lastMs > 0 && nowMs - _lastMs < windowMs;

/// When the current window closes, or null if nothing has arrived.
int? expiresAtMs() => _lastMs > 0 ? _lastMs + windowMs : null;
}

/// Steps accrued since LOCAL MIDNIGHT, from a counter that counts since the BLE
/// connection began.
///
/// The Today tile shows `derived day total + live session steps`, and the live
/// half is "since this connection started". This app deliberately holds ONE
/// continuous connection — the whole engine is built around never dropping it —
/// so that counter routinely spans midnight, and at 00:01 the tile showed
/// yesterday's steps plus today's and kept climbing from there. Reported from
/// TestFlight as "steps and calories are not resetting every day, it's
/// accumulating".
///
/// Rebasing at the day boundary is the fix: steps taken before midnight belong
/// to yesterday, and yesterday's derived total already contains them.
///
/// Pure so the boundary behaviour is testable without a clock or a band.
class LiveStepDayWindow {
String? _day;
int _base = 0;

/// [sessionTotal] is the connection-lifetime count; [today] is the local day
/// label. Returns what belongs to [today].
int stepsToday(int rawSessionTotal, String today) {
// A counter cannot be negative. Letting one through would seat `_base`
// below zero, and the next ordinary reading would then report the
// difference as steps that were never taken.
final sessionTotal = rawSessionTotal > 0 ? rawSessionTotal : 0;
if (_day == null) {
// First observation. The session counter is per-connection and per-
// process, so whatever it holds now was walked during this session — on
// this day. Rebasing here instead would DISCARD a real walk, which is
// what the live-coverage tests caught.
_day = today;
_base = 0;
} else if (_day != today) {
// A day boundary crossed while this window was watching: everything the
// counter holds belongs to the day that just ended.
_day = today;
_base = sessionTotal;
}
// A reconnect zeroes the session counter. Without this the stale, larger
// base would make every subsequent reading negative — clamped to 0 below,
// so a walk after a reconnect on the same day would silently stop counting.
if (sessionTotal < _base) _base = sessionTotal;
final n = sessionTotal - _base;
return n > 0 ? n : 0;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// The day this window is currently based on — null until first use.
///
/// Kept current from the SAMPLE path, not just from the widget that displays
/// it: a phone parked on another tab across midnight would otherwise make its
/// first post-midnight read the first observation of any day, and count
/// yesterday's whole session as today's.
String? get day => _day;
}

/// for [quietPeriod] (or [maxWait] elapses since the first un-derived record so a
/// never-quiet stream still derives periodically) a derive is scheduled, coalescing
/// the burst into a single pass. Pure + deterministic so it's unit-testable without
Expand Down
61 changes: 59 additions & 2 deletions lib/state/app_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ import '../models/app_status.dart';
import '../ble/accessory_setup.dart';
import '../ble/android_background.dart';
import '../ble/ble_engine.dart';
import '../ble/ble_state.dart' show AlarmConfirmation, AlarmEffect;
import '../ble/ble_state.dart'
show AlarmConfirmation, AlarmEffect, LiveStepDayWindow, SyncActivityWindow;
import '../ble/ios_ble_restore.dart';
import '../cloud/companion_client.dart';
import '../compute/derivation_engine.dart';
Expand Down Expand Up @@ -1011,6 +1012,8 @@ class AppState extends ChangeNotifier {

@override
void dispose() {
_syncQuietTimer?.cancel();
_syncQuietTimer = null;
_disposed = true;
// EVERY timer this object owns, not just three of them. _spotTimer,
// _breathingRecomputeTimer and _workoutTimer used to survive dispose, and
Expand Down Expand Up @@ -1654,6 +1657,9 @@ class AppState extends ChangeNotifier {
/// drain, live-triggered store) after the write is durable, so it can't
/// race it — same guarantee dbCounts already relies on above.
void _onDataStored() {
// Synchronously, before the async read below: this is the moment records
// became durable, and it is the only path that sees every commit.
_markSyncActivity();
unawaited(() async {
dbCounts = await LocalDb.counts();
final recTsHw = await LocalDb.getCursorInt('rec_ts_hw');
Expand Down Expand Up @@ -2100,8 +2106,19 @@ class AppState extends ChangeNotifier {
/// double-count into `live_coverage` and poison the cadence model.
int get _rawSessionSteps => (_liveRaw * ana.StepParams.gain).round();

/// Rebases the connection-lifetime counter at local midnight — see
/// [LiveStepDayWindow] for why a permanently-connected band made the Today
/// tile carry yesterday's steps into today.
final LiveStepDayWindow _liveStepDay = LiveStepDayWindow();

int get liveSteps {
final raw = _rawSessionSteps;
final today = todayLabel();
final dayChanged = _liveStepDay.day != null && _liveStepDay.day != today;
final raw = _liveStepDay.stepsToday(_rawSessionSteps, today);
// The cushion holds a PRE-midnight session total for a few seconds so the
// tile doesn't visibly dip on a reconnect. Carried across the boundary it
// would re-introduce exactly the number we just rebased away.
if (dayChanged) _sessionStepsCushion = 0;
if (_sessionStepsCushion <= 0) return raw;
if (DateTime.now().millisecondsSinceEpoch - _sessionCushionSetAtMs >=
_sessionCushionGraceMs) {
Expand Down Expand Up @@ -2218,6 +2235,12 @@ class AppState extends ChangeNotifier {
// a killed process doesn't lose the whole session — only whatever hasn't
// completed a minute yet. See _recoverOrphanedLiveSession.
if (committedThisTick) unawaited(_checkpointLiveSession());
// Keep the day window current from the SAMPLE path. Reading it only when
// the Today tile is built would make the first read after midnight the
// window's first observation of any day — and a first observation counts
// in full, so a phone parked on another tab across midnight would carry
// the whole of yesterday's session into today.
if (committedThisTick) _liveStepDay.stepsToday(_rawSessionSteps, todayLabel());
if (nowMs - _lastLiveUiNotifyMs >= 1000) {
_lastLiveUiNotifyMs = nowMs;
notifyListeners(); // live readout re-counts the partial minute on read
Expand Down Expand Up @@ -3568,6 +3591,40 @@ class AppState extends ChangeNotifier {
'reanalyze_progress': reanalyzeProgress,
};

final SyncActivityWindow _syncActivity = SyncActivityWindow();

/// Fires once when the activity window closes. `syncingNow` decays on
/// wall-clock time, and nothing else necessarily notifies at that moment — a
/// band that goes quiet after its last batch would leave the indicator lit
/// until some unrelated state change happened along.
Timer? _syncQuietTimer;

/// Band data is arriving right now. Deliberately narrow: it is not "connected"
/// and not "we would like to sync" — it is only true while records are
/// actually landing, so a quiet indicator means a quiet link rather than a
/// broken one.
///
/// From TestFlight: "don't get to know if syncing is happening or not".
bool get syncingNow =>
_syncActivity.isActive(DateTime.now().millisecondsSinceEpoch);

/// Records reached durable storage. Called from the durable-write callback —
/// NOT inferred from a sync burst finishing, because `_onDataStored` has
/// already advanced the frontier by then, so the burst's own "did the
/// frontier move" test is false exactly when data has just landed.
void _markSyncActivity() {
final now = DateTime.now().millisecondsSinceEpoch;
_syncActivity.mark(now);
_syncQuietTimer?.cancel();
_syncQuietTimer = Timer(
Duration(milliseconds: _syncActivity.windowMs),
() {
_syncQuietTimer = null;
notifyListeners();
},
);
}

/// REAL device timestamp of the newest record we hold (the band's own clock),
/// NOT when the BLE frame arrived. This is what "last data: …" displays — a
/// flash backfill arrives "now" but carries hours-old records. `null` until any
Expand Down
1 change: 1 addition & 0 deletions lib/ui/design/design.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export 'recap_card.dart';
export 'ring_week.dart';
export 'rows.dart';
export 'spark.dart';
export 'sync_dot.dart';
export 'state_chips.dart';
export 'surface.dart';

Expand Down
101 changes: 101 additions & 0 deletions lib/ui/design/sync_dot.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// SyncDot — the quietest possible "data is arriving".
//
// From TestFlight: "don't get to know if syncing is happening or not". The sync
// is deliberately invisible in this app — no progress bars, no spinners, no
// "syncing…" copy — and that is the right default, because the band syncs
// constantly and a user cannot act on any of it. But invisible and broken look
// identical, which is what the report is really about.
//
// So: a 6pt dot beside the title that breathes while records are landing, and
// is absent otherwise. No text, no layout shift (the space is held either way),
// nothing to dismiss. If you are not looking for it you will not notice it; if
// you are wondering whether the thing works, it answers you.

import 'package:flutter/material.dart';

import '../../theme/tokens.dart';

class SyncDot extends StatefulWidget {
const SyncDot({super.key, required this.active, this.size = 6});

/// The fixed-size box, so a test can measure the reserved space directly
/// rather than through the StatefulWidget element.
static const Key sizeKey = Key('sync-dot-box');

/// Records are landing right now (`AppState.syncingNow`).
final bool active;
final double size;

@override
State<SyncDot> createState() => _SyncDotState();
}

class _SyncDotState extends State<SyncDot>
with SingleTickerProviderStateMixin {
// Created in initState, NOT as a lazy `late final` initialiser. A dot that
// never animates never touches the field, so the first read would be
// `dispose()` — building a Ticker against an already-deactivated element,
// which throws "Looking up a deactivated widget's ancestor is unsafe". The
// quiet path is the common one, so the lazy version was broken for almost
// every user of this widget.
late final AnimationController _pulse;

@override
void initState() {
super.initState();
_pulse = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
);
if (widget.active) _pulse.repeat(reverse: true);
}

@override
void didUpdateWidget(covariant SyncDot old) {
super.didUpdateWidget(old);
if (widget.active == old.active) return;
// Never leave the controller running while the dot is invisible — a
// repeating animation on an off-screen widget is a permanent 60 Hz wake-up
// on a screen that already fights for the main isolate during a drain.
if (widget.active) {
_pulse.repeat(reverse: true);
} else {
_pulse.stop();
_pulse.value = 0;
}
}

@override
void dispose() {
_pulse.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
// The box is always occupied, so the title never shifts when the dot
// appears or goes — a jumping wordmark would be far louder than the dot.
return SizedBox(
key: SyncDot.sizeKey,
width: widget.size,
height: widget.size,
child: !widget.active
? const SizedBox.shrink()
: Semantics(
label: 'Syncing with your band',
liveRegion: true,
child: FadeTransition(
opacity: Tween<double>(begin: 0.25, end: 1).animate(
CurvedAnimation(parent: _pulse, curve: Curves.easeInOut),
),
child: DecoratedBox(
decoration: BoxDecoration(
color: AppColors.accent,
shape: BoxShape.circle,
),
),
),
),
);
}
}
31 changes: 22 additions & 9 deletions lib/ui/today/today_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -186,12 +186,13 @@ class _TodayScreenState extends State<TodayScreen>
// the REBUILD TRIGGER is scoped. liveSteps is included (already rate-
// limited to ~1/s at the source) so the steps tile doesn't freeze mid-walk
// while waiting on an unrelated dbCounts change.
context.select<AppState, (Map<String, int>, bool, String, int, bool)>(
context.select<AppState, (Map<String, int>, bool, String, int, bool, bool)>(
(a) => (
a.dbCounts,
a.reanalyzing,
a.reanalyzeProgress,
a.liveSteps,
a.syncingNow,
// The steps tile stops adding the band's live count the moment the
// phone owns the day, so a flip has to rebuild it.
a.todayStepsFromPhone,
Expand All @@ -202,14 +203,26 @@ class _TodayScreenState extends State<TodayScreen>

return AppScaffold(
// Brand wordmark — a confident title, not a greeting.
titleWidget: Text(
'Edge',
style: AppText.h1.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.9,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
titleWidget: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Text(
'Edge',
style: AppText.h1.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.9,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: Sp.x2),
// Breathes only while records are actually landing. See sync_dot.dart
// for why this is the whole of the answer to "is it syncing".
SyncDot(active: context.select<AppState, bool>((a) => a.syncingNow)),
],
),
actions: [
RoundIconButton(
Expand Down
Loading
Loading