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
17 changed files with 420 additions and 65 deletions

View file

@ -16,6 +16,7 @@ class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val messenger = flutterEngine.dartExecutor.binaryMessenger
PomodoroService.appBack()
// The Pomodoro timer's side of the system (PomodoroService).
MethodChannel(messenger, "app.halkyon.learn/pomodoro").setMethodCallHandler { call, result ->
when (call.method) {
@ -28,6 +29,7 @@ class MainActivity : FlutterActivity() {
PomodoroService.clear(this)
result.success(null)
}
"takeStoppedOutside" -> result.success(PomodoroService.takeStoppedOutside(this))
"mayKeepQuiet" -> {
val allowed = Quiet.allowed(this)
if (!allowed && call.arguments == true) {
@ -53,6 +55,7 @@ class MainActivity : FlutterActivity() {
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
PomodoroService.buttons = null
PomodoroService.appGone()
super.cleanUpFlutterEngine(flutterEngine)
}

View file

@ -48,6 +48,31 @@ class PomodoroService : Service() {
/** The service while it runs. */
@Volatile private var running: PomodoroService? = null
/** Whether the app is gone (swiped away): its buttons then open it. */
@Volatile private var appGone = false
private const val PREFERENCES = "pomodoro"
private const val STOPPED_OUTSIDE = "stoppedOutside"
/** The app went away: its buttons open it from now on. */
fun appGone() {
appGone = true
running?.repost()
}
/** The app is back, and shows its timer anew. */
fun appBack() {
appGone = false
}
/** Whether the timer was stopped here while the app was gone; forgets it. */
fun takeStoppedOutside(context: Context): Boolean {
val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
val stopped = preferences.getBoolean(STOPPED_OUTSIDE, false)
preferences.edit().remove(STOPPED_OUTSIDE).apply()
return stopped
}
/** Shows [notice] (JSON), starting the service if it doesn't run yet. */
fun show(context: Context, notice: String) {
val service = running
@ -95,9 +120,16 @@ class PomodoroService : Service() {
val app = buttons
when {
app != null -> app(button)
// The app is gone (swiped away): Stop needs it not; the others
// open it, where the timer catches up and goes on.
button == "stop" -> clear(this)
// The app is gone (swiped away): Stop needs it not, and the app
// forgets its timer when it opens next; the others open it
// (their buttons do so by themselves once the app is gone).
button == "stop" -> {
getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.edit()
.putBoolean(STOPPED_OUTSIDE, true)
.apply()
clear(this)
}
else -> packageManager.getLaunchIntentForPackage(packageName)?.let {
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(it)
@ -108,9 +140,28 @@ class PomodoroService : Service() {
return START_NOT_STICKY
}
/** The last notice shown, to show again as the app comes and goes. */
private var last: JSONObject? = null
/** Shows the last notice again. */
fun repost() {
last?.let { notice ->
NotificationManagerCompat.from(this).let {
if (it.areNotificationsEnabled()) {
try {
it.notify(TIMER_ID, notification(notice))
} catch (_: SecurityException) {
// Notifications not allowed: nothing to show.
}
}
}
}
}
/** Shows [json], schedules its phase's end and keeps the device quiet as it says. */
fun present(json: String) {
val notice = JSONObject(json)
last = notice
channels(this, notice)
ServiceCompat.startForeground(
this,
@ -170,7 +221,11 @@ class PomodoroService : Service() {
}
private fun button(name: String): PendingIntent =
PendingIntent.getService(
// Without the app, Pause, Resume and Skip open it: a service may not
// start an activity from a notification's button (Android 12 on).
if (appGone && name != "stop") {
openApp(this)!!
} else PendingIntent.getService(
this,
name.hashCode(),
Intent(this, PomodoroService::class.java).setAction(ACTION_BUTTON).putExtra(EXTRA_BUTTON, name),
@ -192,21 +247,26 @@ internal fun openApp(context: Context): PendingIntent? =
/** The timer's channels, named in the app's language: the countdown, silent; the alerts, with sound and vibration. */
internal fun channels(context: Context, notice: JSONObject) {
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(
PomodoroService.TIMER_CHANNEL,
notice.optString("timerName", "Pomodoro"),
NotificationManager.IMPORTANCE_LOW,
).apply { setShowBadge(false) },
)
manager.createNotificationChannel(
NotificationChannel(
PomodoroService.ALERT_CHANNEL,
notice.optString("alertsName", "Pomodoro"),
NotificationManager.IMPORTANCE_HIGH,
).apply { enableVibration(true) },
)
context
.getSystemService(NotificationManager::class.java)
.createNotificationChannel(
NotificationChannel(
PomodoroService.TIMER_CHANNEL,
notice.optString("timerName", "Pomodoro"),
NotificationManager.IMPORTANCE_LOW,
).apply { setShowBadge(false) },
)
alertChannel(context, notice.optString("alertsName", "Pomodoro"))
}
/** The alerts' channel, with sound and vibration. */
internal fun alertChannel(context: Context, name: String) {
context
.getSystemService(NotificationManager::class.java)
.createNotificationChannel(
NotificationChannel(PomodoroService.ALERT_CHANNEL, name, NotificationManager.IMPORTANCE_HIGH)
.apply { enableVibration(true) },
)
}
/** The alarm at the end of a phase, which alerts even with the app closed. */
@ -253,13 +313,15 @@ internal object PomodoroAlarm {
class PomodoroAlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Quiet.restore(context)
// The app's own timers may have slept with the device: it looks now.
PomodoroService.buttons?.invoke("phaseEnded")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
return
}
channels(context, JSONObject().put("alertsName", intent.getStringExtra("alertsName")))
alertChannel(context, intent.getStringExtra("alertsName") ?: "Pomodoro")
NotificationManagerCompat.from(context)
.notify(
PomodoroService.ALERT_ID,

View file

@ -820,7 +820,7 @@
"pomodoroAutoStartBreaks": "Pausen von selbst starten",
"pomodoroAutoStartFocus": "Fokus nach einer Pause von selbst starten",
"pomodoroDoNotDisturb": "Nicht stören während des Fokus",
"pomodoroDoNotDisturbHint": "Das Gerät bleibt still, während du dich konzentrierst; die Meldung an seinem Ende ist trotzdem zu hören.",
"pomodoroDoNotDisturbHint": "Das Gerät bleibt still, während du dich konzentrierst; die Meldung am Ende des Fokus ist trotzdem zu hören.",
"pomodoroDoNotDisturbDenied": "Die App darf dieses Gerät nicht stummschalten. Erlaube es in den Systemeinstellungen.",
"pomodoroDeleteProfile": "Profil löschen",
"pomodoroSummary": "{focus} Min. Fokus, {shortBreak} Min. Pause",

View file

@ -2962,7 +2962,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get pomodoroDoNotDisturbHint =>
'Das Gerät bleibt still, während du dich konzentrierst; die Meldung an seinem Ende ist trotzdem zu hören.';
'Das Gerät bleibt still, während du dich konzentrierst; die Meldung am Ende des Fokus ist trotzdem zu hören.';
@override
String get pomodoroDoNotDisturbDenied =>

View file

@ -2,6 +2,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/app/app_router.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/widget/device_settings_scope.dart';
@ -89,6 +90,9 @@ class _PersonAppState() extends State<PersonApp> {
create: (context) => PomodoroBloc(
PersonScope.of(context).settings,
PersonScope.of(context).pomodoroRuns,
stoppedOutside: DependenciesScope.of(
context,
).pomodoro.takeStoppedOutside,
)..add(const PomodoroEventStarted()),
),
],

View file

@ -18,8 +18,12 @@ import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_reposit
/// state, through `PomodoroPresenter`.
class PomodoroBloc(
final SettingsRepository _settings,
final PomodoroRunStore _runs,
) extends Bloc<PomodoroEvent, PomodoroState> {
final PomodoroRunStore _runs, {
/// Whether the timer was stopped outside the app while it was gone, so
/// the timer kept is no more (`PomodoroPlatform.takeStoppedOutside`).
Future<bool> Function()? stoppedOutside,
}) extends Bloc<PomodoroEvent, PomodoroState> {
this : super(const PomodoroState()) {
on<PomodoroEventStarted>(_onStarted);
on<PomodoroEventSettingsChanged>((event, emit) async {
@ -73,17 +77,28 @@ class PomodoroBloc(
);
}
final Future<bool> Function()? _stoppedOutside = stoppedOutside;
StreamSubscription<void>? _settingsChanges;
Timer? _phaseEnd;
Timer? _looks;
Future<void> _onStarted(
PomodoroEventStarted event,
Emitter<PomodoroState> emit,
) async {
final settings = await _settings.read();
final kept = await _runs.read();
await _emit(emit, settings: settings.pomodoro, run: kept);
await _catchUp(emit);
var kept = await _runs.read();
if (kept != null && (await _stoppedOutside?.call() ?? false)) {
kept = null;
await _runs.write(null);
}
// Caught up before it shows: a phase that ended while the app was
// closed was alerted already.
await _emit(
emit,
settings: settings.pomodoro,
run: kept?.caughtUpTo(clock.now().toUtc()).$1,
);
_settingsChanges = _settings.watch().listen(
(settings) => add(PomodoroEventSettingsChanged(settings.pomodoro)),
// A read that failed leaves the profiles as they were.
@ -119,18 +134,29 @@ class PomodoroBloc(
await _runs.write(next.run);
}
_phaseEnd?.cancel();
_looks?.cancel();
if (isClosed) {
return;
}
if (next.run?.endsAt case final endsAt?) {
_phaseEnd = Timer(
endsAt.difference(clock.now().toUtc()) +
const Duration(milliseconds: 1),
() => add(const PomodoroEventCaughtUp()),
);
// Timers sleep with the device: a phase that ended meanwhile is seen
// to soon after it wakes.
_looks = Timer.periodic(
const Duration(seconds: 15),
(_) => add(const PomodoroEventCaughtUp()),
);
}
}
@override
Future<void> close() async {
_phaseEnd?.cancel();
_looks?.cancel();
await _settingsChanges?.cancel();
await super.close();
}

View file

@ -18,10 +18,24 @@ class AndroidPomodoroPlatform() implements PomodoroPlatform {
@override
Future<void> clear() => _channel.invokeMethod<void>('clear');
@override
late final Stream<PomodoroAction> actions = _buttons
late final Stream<String> _events = _buttons
.receiveBroadcastStream()
.map((button) => PomodoroAction.values.byName(button as String));
.cast<String>()
.asBroadcastStream();
@override
late final Stream<PomodoroAction> actions = _events
.where(PomodoroAction.values.asNameMap().containsKey)
.map(PomodoroAction.values.byName);
@override
late final Stream<void> phaseEnds = _events.where(
(event) => event == 'phaseEnded',
);
@override
Future<bool> takeStoppedOutside() async =>
await _channel.invokeMethod<bool>('takeStoppedOutside') ?? false;
@override
Future<bool> mayKeepQuiet({bool ask = false}) async =>

View file

@ -4,6 +4,7 @@ import 'dart:io';
import 'package:clock/clock.dart';
import 'package:dbus/dbus.dart';
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_platform.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/status_notifier_item.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_run.dart';
@ -125,8 +126,14 @@ class LinuxPomodoroPlatform(
return (reply.values.single as DBusUint32).value;
}
// One change at a time: each waits for the bus, and the next mustn't meet
// it half done (an indicator registered twice, a notification left over).
final _queue = SerialQueue();
@override
Future<void> show(PomodoroNotice notice) async {
Future<void> show(PomodoroNotice notice) => _queue.run(() => _show(notice));
Future<void> _show(PomodoroNotice notice) async {
_listen();
final labels = notice.actions;
final buttons = [
@ -198,7 +205,10 @@ class LinuxPomodoroPlatform(
}
_alert?.cancel();
if (notice.endsAt case final endsAt?) {
_alert = Timer(endsAt.difference(clock.now()), () => _alertEnd(notice));
_alert = Timer(
endsAt.difference(clock.now()),
() => _queue.run(() => _alertEnd(notice)),
);
}
await (notice.quiet ? _keepQuiet() : _restoreQuiet());
}
@ -222,7 +232,9 @@ class LinuxPomodoroPlatform(
}
@override
Future<void> clear() async {
Future<void> clear() => _queue.run(_clear);
Future<void> _clear() async {
_alert?.cancel();
_ticks?.cancel();
try {
@ -250,6 +262,14 @@ class LinuxPomodoroPlatform(
@override
Stream<PomodoroAction> get actions => _actions.stream;
// A desktop app's timer lives no longer than the app, and the bloc looks
// again after a suspend.
@override
Stream<void> get phaseEnds => const Stream.empty();
@override
Future<bool> takeStoppedOutside() async => false;
static const _banners = ['org.gnome.desktop.notifications', 'show-banners'];
@override
@ -289,6 +309,8 @@ class LinuxPomodoroPlatform(
_quietBefore.deleteSync();
} on ProcessException catch (error) {
log("Couldn't end the quiet", name: 'Pomodoro', error: error);
} on FileSystemException catch (error) {
log("Couldn't end the quiet", name: 'Pomodoro', error: error);
}
}
}

View file

@ -104,6 +104,14 @@ abstract interface class PomodoroPlatform() {
/// The buttons pressed outside the app.
Stream<PomodoroAction> get actions;
/// The system telling that a phase ended (its alarm), which the app's own
/// timers may have slept through with the device.
Stream<void> get phaseEnds;
/// Whether the timer was stopped outside the app while the app was gone
/// (its notification's Stop); forgets it.
Future<bool> takeStoppedOutside();
/// Whether the person allowed the app to keep the device quiet; asks
/// them (the system's settings may open) when [ask].
Future<bool> mayKeepQuiet({bool ask = false});
@ -120,6 +128,12 @@ class const NoPomodoroPlatform() implements PomodoroPlatform {
@override
Stream<PomodoroAction> get actions => const Stream.empty();
@override
Stream<void> get phaseEnds => const Stream.empty();
@override
Future<bool> takeStoppedOutside() async => false;
@override
Future<bool> mayKeepQuiet({bool ask = false}) async => false;
}

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_run.dart';
/// Where this device keeps its Pomodoro timer while the app is closed: the
@ -17,6 +18,9 @@ abstract interface class PomodoroRunStore() {
/// Keeps the timer as JSON in [_file], in the person's folder, which goes
/// with them; written to a temporary file first and renamed into place.
class FilePomodoroRunStore(final File _file) implements PomodoroRunStore {
// One write at a time: they share the temporary file.
final _queue = SerialQueue();
@override
Future<PomodoroRun?> read() async {
try {
@ -30,7 +34,9 @@ class FilePomodoroRunStore(final File _file) implements PomodoroRunStore {
}
@override
Future<void> write(PomodoroRun? run) async {
Future<void> write(PomodoroRun? run) => _queue.run(() => _write(run));
Future<void> _write(PomodoroRun? run) async {
if (run == null) {
try {
await _file.delete();

View file

@ -20,15 +20,26 @@ typedef IndicatorEntry = ({String key, String label, bool enabled});
/// desktop without a watcher (stock GNOME without the extension) shows
/// nothing, and [show] says so.
class StatusNotifierItem(final DBusClient _bus, {required final String id}) {
static const _watcher = 'org.kde.StatusNotifierWatcher';
late final _item = _Item(id);
late final _menu = _Menu();
final _name = 'org.freedesktop.StatusNotifierItem-$pid-1';
/// Whether the item and its menu are on the bus, under their name.
var _exported = false;
/// Whether the top bar's watcher knows the item.
var _registered = false;
StreamSubscription<DBusNameOwnerChangedEvent>? _watcherChanges;
/// The keys of the menu entries chosen.
Stream<String> get chosen => _menu.chosen.stream;
/// Shows the icon with [label] beside it, [title] as its name, and
/// [entries] as its menu; whether a top bar shows it.
/// [entries] as its menu; whether a top bar shows it. Asks the top bar's
/// watcher again each time until one knows the item, and again when the
/// watcher restarts (the shell, or its extension, turned off and on).
Future<bool> show({
required String title,
required String label,
@ -36,30 +47,43 @@ class StatusNotifierItem(final DBusClient _bus, {required final String id}) {
}) async {
await _menu.update(entries);
await _item.update(title: title, label: label, active: true);
if (_registered) {
return true;
}
try {
if (!_exported) {
await _bus.registerObject(_item);
await _bus.registerObject(_menu);
final name = 'org.freedesktop.StatusNotifierItem-$pid-1';
await _bus.requestName(name);
await _bus.requestName(_name);
_exported = true;
_watcherChanges = _bus.nameOwnerChanged
.where((change) => change.name == _watcher)
.listen((change) {
_registered = false;
if (change.newOwner != null && _item.active) {
unawaited(_register());
}
});
}
if (!_registered) {
await _register();
}
return _registered;
}
Future<void> _register() async {
try {
await DBusRemoteObject(
_bus,
name: 'org.kde.StatusNotifierWatcher',
name: _watcher,
path: DBusObjectPath('/StatusNotifierWatcher'),
).callMethod(
'org.kde.StatusNotifierWatcher',
_watcher,
'RegisterStatusNotifierItem',
[DBusString(name)],
[DBusString(_name)],
replySignature: DBusSignature(''),
);
_registered = true;
} on DBusMethodResponseException {
// No watcher: no top bar shows such icons here.
return false;
// No watcher: no top bar shows such icons here, for now.
_registered = false;
}
return true;
}
/// Only the text beside the icon changes, as a countdown does.
@ -67,6 +91,9 @@ class StatusNotifierItem(final DBusClient _bus, {required final String id}) {
/// Hides the icon.
Future<void> hide() => _item.update(active: false);
/// Stops following the watcher.
Future<void> dispose() async => await _watcherChanges?.cancel();
}
const _itemInterface = 'org.kde.StatusNotifierItem';
@ -80,6 +107,9 @@ class _Item(final String _id) extends DBusObject {
var _label = '';
var _active = false;
/// Whether the icon shows.
bool get active => _active;
Future<void> update({String? title, String? label, bool? active}) async {
if (title != null && title != _title) {
_title = title;

View file

@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_profile.dart';
@ -162,14 +164,24 @@ final class const PomodoroRun({
return (run, ended);
}
/// This timer on [profile] from now on; the phase under way keeps its end.
PomodoroRun withProfile(PomodoroProfile profile) => PomodoroRun(
profile: profile,
phase: phase,
focusDone: focusDone,
endsAt: endsAt,
left: left,
);
/// This timer on [profile] from now on: the phase under way keeps its
/// end (a phase waiting at its full length takes the new one), and where
/// the timer is stays within the new profile's cycle.
PomodoroRun withProfile(PomodoroProfile profile) {
final after = profile.longBreakAfter;
return PomodoroRun(
profile: profile,
phase: phase,
focusDone: switch (phase) {
_ when !profile.hasLongBreak => phase.isBreak ? 1 : 0,
PomodoroPhase.focus => focusDone.clamp(0, after - 1),
PomodoroPhase.shortBreak => focusDone.clamp(1, after - 1),
PomodoroPhase.longBreak => after,
},
endsAt: endsAt,
left: !isRunning && left == length ? _lengthOf(profile, phase) : left,
);
}
/// The phases of a whole cycle of the profile: focus and short breaks up to
/// the long break, or one focus and one break without long breaks.
@ -188,20 +200,23 @@ final class const PomodoroRun({
];
/// Where in [cycle] the timer is.
int get indexInCycle => !profile.hasLongBreak
? (phase.isBreak ? 1 : 0)
: switch (phase) {
PomodoroPhase.longBreak => cycle.length - 1,
PomodoroPhase.focus => focusDone * 2,
PomodoroPhase.shortBreak => focusDone * 2 - 1,
};
int get indexInCycle =>
(!profile.hasLongBreak
? (phase.isBreak ? 1 : 0)
: switch (phase) {
PomodoroPhase.longBreak => cycle.length - 1,
PomodoroPhase.focus => focusDone * 2,
PomodoroPhase.shortBreak => focusDone * 2 - 1,
})
.clamp(0, cycle.length - 1);
/// How long a phase of [cycle] lasts.
Duration lengthIn(PomodoroPhase phase) => _lengthOf(profile, phase);
/// How much of [cycle] has passed at [now].
Duration elapsedInCycleAt(DateTime now) {
var elapsed = length - leftAt(now);
final passed = length - leftAt(now);
var elapsed = passed.isNegative ? Duration.zero : passed;
for (final phase in cycle.take(indexInCycle)) {
elapsed += _lengthOf(profile, phase);
}
@ -222,7 +237,7 @@ Duration _lengthOf(PomodoroProfile profile, PomodoroPhase phase) => Duration(
/// [left] as a countdown: minutes and seconds, hours first from an hour on.
String formatPomodoroLeft(Duration left) {
final seconds = (left.inMilliseconds / 1000).ceil();
final seconds = math.max(0, (left.inMilliseconds / 1000).ceil());
String two(int value) => value.toString().padLeft(2, '0');
final hours = seconds ~/ 3600;
final minutes = seconds % 3600 ~/ 60;

View file

@ -26,6 +26,7 @@ class _PomodoroPresenterState() extends State<PomodoroPresenter> {
context,
).pomodoro;
StreamSubscription<PomodoroAction>? _actions;
StreamSubscription<void>? _phaseEnds;
late final AppLifecycleListener _lifecycle;
Timer? _refresh;
var _showing = false;
@ -43,6 +44,9 @@ class _PomodoroPresenterState() extends State<PomodoroPresenter> {
PomodoroAction.stop => const PomodoroEventStopRequested(),
}),
);
_phaseEnds = _platform.phaseEnds.listen(
(_) => bloc.add(const PomodoroEventCaughtUp()),
);
_lifecycle = AppLifecycleListener(
onResume: () => bloc.add(const PomodoroEventCaughtUp()),
);
@ -52,6 +56,7 @@ class _PomodoroPresenterState() extends State<PomodoroPresenter> {
void dispose() {
_refresh?.cancel();
unawaited(_actions?.cancel());
unawaited(_phaseEnds?.cancel());
_lifecycle.dispose();
// A change of person takes their timer away with their app.
if (_showing) {

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);

View file

@ -31,6 +31,24 @@ class FakePomodoroPlatform() implements PomodoroPlatform {
@override
Stream<PomodoroAction> get actions => _actions.stream;
final _phaseEnds = StreamController<void>.broadcast();
/// Tells, as the system's alarm does, that a phase ended.
void endPhase() => _phaseEnds.add(null);
@override
Stream<void> get phaseEnds => _phaseEnds.stream;
/// Whether the timer was stopped outside the app, as the next start asks.
bool stoppedOutside = false;
@override
Future<bool> takeStoppedOutside() async {
final stopped = stoppedOutside;
stoppedOutside = false;
return stopped;
}
@override
Future<bool> mayKeepQuiet({bool ask = false}) async {
if (ask) {