fix(app): the Pomodoro timer holds up with the app gone, asleep or edited

A review of the timer found:

- Stop in the notification with the app swiped away took the timer
  away there, but the app showed it again at its next start. The
  service now leaves a mark the app takes when it starts, and the timer
  it kept is forgotten.
- Pause, Resume and Skip did nothing with the app gone: since Android
  12 a service may not start an activity from a notification's button.
  The notification is posted again with buttons that open the app.
- A timer kept while the app was closed was shown as it was before it
  caught up, so its ended phase alerted a second time. It is now caught
  up before it first shows.
- Dart's timers sleep with the device, so a phase that ended while the
  phone or laptop slept went unnoticed until the app came back. While a
  phase runs the timer looks every 15 seconds, and on Android the
  alarm's receiver wakes it too.
- On Linux, changes that came at once overlapped on the bus (an
  indicator registered twice, notifications left over, a ticking label
  never stopped); they now go one at a time. The indicator registers
  again when GNOME's watcher restarts, and with a watcher that only
  turns up later.
- Changing the running profile could put the timer outside its cycle
  ("Focus 6 of 4", the Live Update's bar past its end); where it is
  stays within the new cycle, and a phase waiting at its full length
  takes the new length. A countdown past its end shows 00:00.
- Writes of the kept timer go one at a time, and a closed timer arms no
  more timers.
- The alert no longer renames the timer's notification channel, and a
  German hint said the alert came at the device's end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-07 03:23:24 +02:00
commit a822763df8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 420 additions and 65 deletions

View file

@ -144,4 +144,56 @@ void main() {
expect(bloc.state.settings.profiles, hasLength(2));
});
});
test('a timer kept while the app was closed shows caught up, never as it '
'was', () {
fakeAsync((async) {
withClock(async.getClock(start), () {
final settings = SettingsRepository(FakeSettingsGateway());
final store = MemoryPomodoroRunStore(
PomodoroRun.ready(
PomodoroProfile.classic,
).started(start.subtract(const Duration(hours: 1))),
);
final bloc = PomodoroBloc(settings, store);
final states = <PomodoroState>[];
bloc.stream.listen(states.add);
bloc.add(const PomodoroEventStarted());
async.flushMicrotasks();
expect(
states.where((state) => state.run?.isRunning ?? false),
isEmpty,
);
expect(states.last.run?.phase, PomodoroPhase.shortBreak);
bloc.close();
settings.dispose();
async.flushMicrotasks();
});
});
});
test('a timer stopped outside the app while it was gone is no more', () {
fakeAsync((async) {
withClock(async.getClock(start), () {
final settings = SettingsRepository(FakeSettingsGateway());
final store = MemoryPomodoroRunStore(
PomodoroRun.ready(PomodoroProfile.classic).started(start),
);
final bloc = PomodoroBloc(
settings,
store,
stoppedOutside: () async => true,
)..add(const PomodoroEventStarted());
async.flushMicrotasks();
expect(bloc.state.loaded, isTrue);
expect(bloc.state.run, isNull);
expect(store.kept, isNull);
bloc.close();
settings.dispose();
async.flushMicrotasks();
});
});
});
}

View file

@ -131,6 +131,21 @@ void main() {
);
});
test('changes that come at once still make one notification', () async {
final run = PomodoroRun.ready(PomodoroProfile.classic);
await Future.wait([
platform.show(notice(run.started(DateTime.now().toUtc()))),
platform.show(notice(run)),
]);
expect(
notifications.notified.map((call) => (call[1] as DBusUint32).value),
[0, 1],
reason: 'the second replaces the first',
);
});
test('hands the buttons pressed to the app', () async {
final pressed = platform.actions.first;
await platform.show(notice(PomodoroRun.ready(PomodoroProfile.classic)));
@ -260,6 +275,26 @@ void main() {
});
});
test('the item registers again with a watcher that restarts', () async {
var watcher = _Watcher();
var shell = DBusClient(address);
await shell.requestName('org.kde.StatusNotifierWatcher');
await shell.registerObject(watcher);
final run = PomodoroRun.ready(PomodoroProfile.classic);
await platform.show(notice(run));
expect(watcher.registered, hasLength(1));
await shell.close();
watcher = _Watcher();
shell = DBusClient(address);
await shell.requestName('org.kde.StatusNotifierWatcher');
await shell.registerObject(watcher);
addTearDown(shell.close);
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(watcher.registered, hasLength(1));
});
test('an icon of the timer is drawn for the top bar', () {
final icon = timerIcon(24);

View file

@ -178,6 +178,55 @@ void main() {
);
});
test('a profile changed under way keeps where the timer is within its '
'cycle', () {
var run = PomodoroRun.ready(PomodoroProfile.fiftyTwoSeventeen);
for (var i = 0; i < 10; i++) {
run = run.next(now);
}
final withLongBreaks = run.withProfile(
PomodoroProfile.fiftyTwoSeventeen.copyWith(
longBreakMinutes: 30,
longBreakAfter: 4,
),
);
expect(withLongBreaks.phase, PomodoroPhase.focus);
expect(
withLongBreaks.indexInCycle,
lessThan(withLongBreaks.cycle.length),
);
expect(withLongBreaks.focusDone, lessThan(4));
final cycleLength = withLongBreaks.cycle.fold(
Duration.zero,
(sum, phase) => sum + withLongBreaks.lengthIn(phase),
);
expect(
withLongBreaks.elapsedInCycleAt(now),
lessThanOrEqualTo(cycleLength),
);
});
test('a phase waiting at its full length takes the length of the changed '
'profile; one under way keeps its end', () {
final waiting = PomodoroRun.ready(classic);
expect(
waiting.withProfile(classic.copyWith(focusMinutes: 40)).leftAt(now),
const Duration(minutes: 40),
);
final running = waiting.started(now);
expect(
running.withProfile(classic.copyWith(focusMinutes: 40)).endsAt,
running.endsAt,
);
});
test('a countdown past its end shows no time left', () {
expect(formatPomodoroLeft(const Duration(seconds: -13)), '00:00');
expect(formatPomodoroLeft(const Duration(minutes: 61)), '1:01:00');
});
test('survives being kept and read back', () {
final run = PomodoroRun.ready(classic).next(now).started(now);