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

View file

@ -143,7 +143,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync.
- **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces/<space>/<sha256>`, at most 20 MB each, and within limits (`file_limits.dart`, from the environment: an anonymous person 100 MB, an identified one 500 MB or what a `file_allowance` row grants them, the instance 15 GB), checked when an upload starts (unfinished uploads count, or they could fill the disk unnoticed) and again when it finishes (only what is kept counts), refused with `FileLimitExceededException`; `deletePerson` deletes the person's files and allowance, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:<asset row id>` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people.
- **PDFs** (`feature/pdf_source/`): a `pdf_source` row per PDF in a studyset (payload: title, file name, the asset of its file, page count, reading deadline; last writer wins) and an append-only `pdf_reading` log (`reached` with a page, `reset`): the furthest page is the furthest reached since the last reset, so devices never conflict. A PDF's file is an asset of up to 50 MB (other files 20 MB): the server takes up to 50 MB and deletes a stored file over 20 MB that doesn't start with `%PDF-`. Deleting a PDF deletes its asset row too unless another PDF has the same file (asset rows are otherwise never deleted). Cards and notes tied to a page keep it in their payload (`source`, `core/content/pdf_page_ref.dart`; card schema 4, note schema 2); a screenshot is an `image` block with `pdf` and `page` attributes.
- **The Pomodoro timer** (`feature/pomodoro/`): the profiles follow the person (`PersonSettings.pomodoro`, settings schema 4, written as schema 3 while they are as they come), the running timer is the device's own (`PomodoroRun` in `<person folder>/pomodoro.json`, `PomodoroRunStore`). `PomodoroRun` is a pure state machine on the time: a phase that ends leads to the next, running if its profile starts it by itself, else paused at its full length; a timer kept while the app was closed catches up (`caughtUpTo`). `PomodoroBloc` sits above the router (`PersonApp`), and `PomodoroPresenter` hands every change to `PomodoroPlatform` as a `PomodoroNotice` in the app's language, and its buttons back. **Android:** the app's own Kotlin (`PomodoroService.kt`; MethodChannel `app.halkyon.learn/pomodoro`, EventChannel `…/buttons`): a `specialUse` foreground service with a countdown notification (chronometer), Pause/Resume, Skip, Stop, on Android 16 a Live Update (`NotificationCompat.ProgressStyle`, the cycle's phases as segments, `setRequestPromotedOngoing`); an exact alarm (`USE_EXACT_ALARM`) alerts at the phase's end with the app closed; Do Not Disturb through the interruption filter, the one before kept in shared preferences and restored. Buttons pressed with the app swiped away open it (Stop works alone), and a profile's next phase only starts by itself while the app runs. No notification plugin: flutter_local_notifications had Live Updates only in a prerelease needing compileSdk 37 (checked 2026-10-07). **Linux:** `LinuxPomodoroPlatform` speaks `org.freedesktop.Notifications` over package:dbus (the snap's `desktop` plug allows it): a resident notification with the buttons, an alert with sound while the app runs, and GNOME's `show-banners` off while focusing, through `gsettings` (the value before kept in `<app support>/pomodoro_quiet_before`). xdg-desktop-portal's Background status is for Flatpaks only, so it isn't used.
- **The Pomodoro timer** (`feature/pomodoro/`): the profiles follow the person (`PersonSettings.pomodoro`, settings schema 4, written as schema 3 while they are as they come), the running timer is the device's own (`PomodoroRun` in `<person folder>/pomodoro.json`, `PomodoroRunStore`). `PomodoroRun` is a pure state machine on the time: a phase that ends leads to the next, running if its profile starts it by itself, else paused at its full length; a timer kept while the app was closed catches up (`caughtUpTo`). `PomodoroBloc` sits above the router (`PersonApp`), and `PomodoroPresenter` hands every change to `PomodoroPlatform` as a `PomodoroNotice` in the app's language, and its buttons back. **Android:** the app's own Kotlin (`PomodoroService.kt`; MethodChannel `app.halkyon.learn/pomodoro`, EventChannel `…/buttons`): a `specialUse` foreground service with a countdown notification (chronometer), Pause/Resume, Skip, Stop, on Android 16 a Live Update (`NotificationCompat.ProgressStyle`, the cycle's phases as segments, `setRequestPromotedOngoing`); an exact alarm (`USE_EXACT_ALARM`) alerts at the phase's end with the app closed; Do Not Disturb through the interruption filter, the one before kept in shared preferences and restored. With the app swiped away its buttons open it (a service may not start an activity from a notification's button since Android 12, so the notification is posted again with activity intents), and Stop works alone, leaving a mark the app takes at its next start (`takeStoppedOutside`) to forget its timer; a profile's next phase only starts by itself while the app runs. Dart's timers sleep with the device, so `PomodoroBloc` also looks every 15 s while a phase runs, and the alarm's receiver wakes it (`phaseEnds`); a kept timer is caught up before it first shows, or its ended phase would alert again. No notification plugin: flutter_local_notifications had Live Updates only in a prerelease needing compileSdk 37 (checked 2026-10-07). **Linux:** `LinuxPomodoroPlatform` speaks `org.freedesktop.Notifications` over package:dbus (the snap's `desktop` plug allows it): a resident notification with the buttons, an alert with sound while the app runs, and GNOME's `show-banners` off while focusing, through `gsettings` (the value before kept in `<app support>/pomodoro_quiet_before`). xdg-desktop-portal's Background status is for Flatpaks only, so it isn't used.
- **Imported cards** say where they came from in their first revision's payload (`origin`, card schema 3, e.g. `anki`); the dashboard doesn't count them as written. The Anki import (`feature/anki/`) reads `.apkg`/`.colpkg` packages of every format on an isolate of its own (`AnkiPackageReader`), zstd through our fork of `pure_zstd` (Anki's frames don't record their size), and its fixtures come from Anki itself (`tool/anki/make_fixtures.py`).
- **Every client migration keeps a device's rows** (`test/core/sync/device_migration_test.dart` replays each on a database with a row in every synced table of ours; SQLite rebuilds tables to add foreign keys, which Serverpod's runner does with foreign keys off). A new synced table needs a row in that test, which fails until it has one.
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.

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