screen-locker/stronglift_replacement/workout_app/lib/services/break_clock.dart
Krzysztof kuhy Rudnicki b6e89b93dc
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
workout_app: run the break countdown in a foreground service with notifications
The between-set break never finished once the phone's screen was off. Three
independent causes (worked through on-device on 2026-09-12; left
uncommitted awaiting the go-ahead, committed here as-is plus one pin bump):

1. The countdown was not wall-clock: `_breakRemaining--` on a
   Timer.periodic, which Android throttles and Doze suspends in a
   backgrounded isolate, so the counter stalled and _onBreakFinished()
   never fired. Now a BreakClock deadline on the wall clock.
2. An expired break was silently dropped on restore (`if (remaining > 0)`
   only) — now cued and logged.
3. No notification infrastructure at all: no package, no
   POST_NOTIFICATIONS / FOREGROUND_SERVICE / WAKE_LOCK.

Now: flutter_foreground_task runs the countdown in a service isolate that
survives BACK and the screen lock; flutter_local_notifications shows an
ongoing "Workout A · break m:ss / Next: …" notification with −1 rep / Done
actions and a "Break over" alert on its own channel (raw break_end sound,
vibration). Button presses go through a persisted BreakIntent queue the
screen drains on resume, so a press with the app killed is not lost.
Finish and Reset stop the service; a startup reaper handles the crash case.

Verified on the phone with the screen off and locked: service alive and
ticking while Dozing, both notifications on the lock screen, channel
importance 5 with the break_end sound. Not verifiable from adb (receiver is
RECEIVER_NOT_EXPORTED): an actual action-button tap, and a true recents
swipe — see stronglift_replacement/TODO-workout-app-v2.md.

Also fixed en route: the break-end sound had never played in any widget
test (AssetSource needs path_provider, which nothing faked, and the failure
was swallowed by catchError); the queue's prune wrote back a stale list.

This commit additionally:
- takes the freshness gate's bump flutter_local_notifications 22.3.1;
- restores web/package.json to HEAD (the tree held a stale pre-bump copy);
- gives scripts/check_flutter_coverage.sh a FLUTTER_TEST_CONCURRENCY knob:
  the default one-isolate-per-core run peaks over 4 GiB and is OOM-killed
  under the resource cap. Default behaviour unchanged.

Gate: flutter analyze clean, 511 tests, 3187/3187 lines (100%), pre-commit
green. Not re-installed on the phone for the 22.3.1 pin: no device attached
at commit time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vgacLhb4WJjxuVj2QLEP
2026-09-13 20:28:36 +02:00

74 lines
2.7 KiB
Dart

/// Wall-clock arithmetic for a rest period between sets.
library;
import 'package:flutter/foundation.dart';
/// An immutable rest period, described by when it *ends* rather than by how
/// many ticks are left.
///
/// The break countdown used to be a plain `int` decremented once per
/// `Timer.periodic` tick. Android throttles — and in Doze suspends — timers in
/// a backgrounded isolate, so that counter stalled, never reached zero, and the
/// break-end cue never fired. Deriving the remaining time from a stored
/// deadline makes a stalled timer merely *stale*: the next tick, whenever it
/// lands, reports the truth.
@immutable
class BreakClock {
/// Creates a clock for a break of [durationSecs] ending at [endTime].
const BreakClock({required this.endTime, required this.durationSecs});
/// Creates a clock for a break of [durationSecs] starting at [start].
factory BreakClock.startingAt(DateTime start, int durationSecs) => BreakClock(
endTime: start.add(Duration(seconds: durationSecs)),
durationSecs: durationSecs,
);
/// When the rest period is over.
final DateTime endTime;
/// The rest period's full length in seconds, for the progress bar.
final int durationSecs;
/// When the rest period began.
DateTime get startTime => endTime.subtract(Duration(seconds: durationSecs));
/// Whole seconds left at [now], rounded up and floored at zero.
///
/// Rounded up so a break of 180 s reads "180" for its whole first second,
/// matching what the decrementing counter used to show.
int remainingSecsAt(DateTime now) {
final us = endTime.difference(now).inMicroseconds;
if (us <= 0) return 0;
return (us + Duration.microsecondsPerSecond - 1) ~/
Duration.microsecondsPerSecond;
}
/// True once [now] has reached [endTime].
bool expiredAt(DateTime now) => !now.isBefore(endTime);
/// Whole seconds since the break ended; zero while it is still running.
int secondsOverdueAt(DateTime now) {
final overdue = now.difference(endTime).inSeconds;
return overdue > 0 ? overdue : 0;
}
/// Returns this break re-cut to [secs], keeping the moment it started.
///
/// This is the 3-min ↔ 5-min switch that happens when the user decrements
/// reps on the set that earned the rest: the clock does not restart, it just
/// ends sooner or later than it was going to.
BreakClock withDuration(int secs) => BreakClock.startingAt(startTime, secs);
@override
bool operator ==(Object other) =>
other is BreakClock &&
other.endTime == endTime &&
other.durationSecs == durationSecs;
@override
int get hashCode => Object.hash(endTime, durationSecs);
@override
String toString() => 'BreakClock($endTime, ${durationSecs}s)';
}