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:
parent
6ba229be2c
commit
a822763df8
18 changed files with 420 additions and 65 deletions
|
|
@ -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.
|
- **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.
|
- **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.
|
- **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`).
|
- **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 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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class MainActivity : FlutterActivity() {
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||||
|
PomodoroService.appBack()
|
||||||
// The Pomodoro timer's side of the system (PomodoroService).
|
// The Pomodoro timer's side of the system (PomodoroService).
|
||||||
MethodChannel(messenger, "app.halkyon.learn/pomodoro").setMethodCallHandler { call, result ->
|
MethodChannel(messenger, "app.halkyon.learn/pomodoro").setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
|
|
@ -28,6 +29,7 @@ class MainActivity : FlutterActivity() {
|
||||||
PomodoroService.clear(this)
|
PomodoroService.clear(this)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"takeStoppedOutside" -> result.success(PomodoroService.takeStoppedOutside(this))
|
||||||
"mayKeepQuiet" -> {
|
"mayKeepQuiet" -> {
|
||||||
val allowed = Quiet.allowed(this)
|
val allowed = Quiet.allowed(this)
|
||||||
if (!allowed && call.arguments == true) {
|
if (!allowed && call.arguments == true) {
|
||||||
|
|
@ -53,6 +55,7 @@ class MainActivity : FlutterActivity() {
|
||||||
|
|
||||||
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
PomodoroService.buttons = null
|
PomodoroService.buttons = null
|
||||||
|
PomodoroService.appGone()
|
||||||
super.cleanUpFlutterEngine(flutterEngine)
|
super.cleanUpFlutterEngine(flutterEngine)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,31 @@ class PomodoroService : Service() {
|
||||||
/** The service while it runs. */
|
/** The service while it runs. */
|
||||||
@Volatile private var running: PomodoroService? = null
|
@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. */
|
/** Shows [notice] (JSON), starting the service if it doesn't run yet. */
|
||||||
fun show(context: Context, notice: String) {
|
fun show(context: Context, notice: String) {
|
||||||
val service = running
|
val service = running
|
||||||
|
|
@ -95,9 +120,16 @@ class PomodoroService : Service() {
|
||||||
val app = buttons
|
val app = buttons
|
||||||
when {
|
when {
|
||||||
app != null -> app(button)
|
app != null -> app(button)
|
||||||
// The app is gone (swiped away): Stop needs it not; the others
|
// The app is gone (swiped away): Stop needs it not, and the app
|
||||||
// open it, where the timer catches up and goes on.
|
// forgets its timer when it opens next; the others open it
|
||||||
button == "stop" -> clear(this)
|
// (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 {
|
else -> packageManager.getLaunchIntentForPackage(packageName)?.let {
|
||||||
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
startActivity(it)
|
startActivity(it)
|
||||||
|
|
@ -108,9 +140,28 @@ class PomodoroService : Service() {
|
||||||
return START_NOT_STICKY
|
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. */
|
/** Shows [json], schedules its phase's end and keeps the device quiet as it says. */
|
||||||
fun present(json: String) {
|
fun present(json: String) {
|
||||||
val notice = JSONObject(json)
|
val notice = JSONObject(json)
|
||||||
|
last = notice
|
||||||
channels(this, notice)
|
channels(this, notice)
|
||||||
ServiceCompat.startForeground(
|
ServiceCompat.startForeground(
|
||||||
this,
|
this,
|
||||||
|
|
@ -170,7 +221,11 @@ class PomodoroService : Service() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun button(name: String): PendingIntent =
|
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,
|
this,
|
||||||
name.hashCode(),
|
name.hashCode(),
|
||||||
Intent(this, PomodoroService::class.java).setAction(ACTION_BUTTON).putExtra(EXTRA_BUTTON, name),
|
Intent(this, PomodoroService::class.java).setAction(ACTION_BUTTON).putExtra(EXTRA_BUTTON, name),
|
||||||
|
|
@ -192,20 +247,25 @@ 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. */
|
/** 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) {
|
internal fun channels(context: Context, notice: JSONObject) {
|
||||||
val manager = context.getSystemService(NotificationManager::class.java)
|
context
|
||||||
manager.createNotificationChannel(
|
.getSystemService(NotificationManager::class.java)
|
||||||
|
.createNotificationChannel(
|
||||||
NotificationChannel(
|
NotificationChannel(
|
||||||
PomodoroService.TIMER_CHANNEL,
|
PomodoroService.TIMER_CHANNEL,
|
||||||
notice.optString("timerName", "Pomodoro"),
|
notice.optString("timerName", "Pomodoro"),
|
||||||
NotificationManager.IMPORTANCE_LOW,
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
).apply { setShowBadge(false) },
|
).apply { setShowBadge(false) },
|
||||||
)
|
)
|
||||||
manager.createNotificationChannel(
|
alertChannel(context, notice.optString("alertsName", "Pomodoro"))
|
||||||
NotificationChannel(
|
}
|
||||||
PomodoroService.ALERT_CHANNEL,
|
|
||||||
notice.optString("alertsName", "Pomodoro"),
|
/** The alerts' channel, with sound and vibration. */
|
||||||
NotificationManager.IMPORTANCE_HIGH,
|
internal fun alertChannel(context: Context, name: String) {
|
||||||
).apply { enableVibration(true) },
|
context
|
||||||
|
.getSystemService(NotificationManager::class.java)
|
||||||
|
.createNotificationChannel(
|
||||||
|
NotificationChannel(PomodoroService.ALERT_CHANNEL, name, NotificationManager.IMPORTANCE_HIGH)
|
||||||
|
.apply { enableVibration(true) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -253,13 +313,15 @@ internal object PomodoroAlarm {
|
||||||
class PomodoroAlarmReceiver : BroadcastReceiver() {
|
class PomodoroAlarmReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
Quiet.restore(context)
|
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 &&
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||||
PackageManager.PERMISSION_GRANTED
|
PackageManager.PERMISSION_GRANTED
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
channels(context, JSONObject().put("alertsName", intent.getStringExtra("alertsName")))
|
alertChannel(context, intent.getStringExtra("alertsName") ?: "Pomodoro")
|
||||||
NotificationManagerCompat.from(context)
|
NotificationManagerCompat.from(context)
|
||||||
.notify(
|
.notify(
|
||||||
PomodoroService.ALERT_ID,
|
PomodoroService.ALERT_ID,
|
||||||
|
|
|
||||||
|
|
@ -820,7 +820,7 @@
|
||||||
"pomodoroAutoStartBreaks": "Pausen von selbst starten",
|
"pomodoroAutoStartBreaks": "Pausen von selbst starten",
|
||||||
"pomodoroAutoStartFocus": "Fokus nach einer Pause von selbst starten",
|
"pomodoroAutoStartFocus": "Fokus nach einer Pause von selbst starten",
|
||||||
"pomodoroDoNotDisturb": "Nicht stören während des Fokus",
|
"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.",
|
"pomodoroDoNotDisturbDenied": "Die App darf dieses Gerät nicht stummschalten. Erlaube es in den Systemeinstellungen.",
|
||||||
"pomodoroDeleteProfile": "Profil löschen",
|
"pomodoroDeleteProfile": "Profil löschen",
|
||||||
"pomodoroSummary": "{focus} Min. Fokus, {shortBreak} Min. Pause",
|
"pomodoroSummary": "{focus} Min. Fokus, {shortBreak} Min. Pause",
|
||||||
|
|
|
||||||
|
|
@ -2962,7 +2962,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pomodoroDoNotDisturbHint =>
|
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
|
@override
|
||||||
String get pomodoroDoNotDisturbDenied =>
|
String get pomodoroDoNotDisturbDenied =>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.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/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/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/bloc/device_settings_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/device_settings/widget/device_settings_scope.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(
|
create: (context) => PomodoroBloc(
|
||||||
PersonScope.of(context).settings,
|
PersonScope.of(context).settings,
|
||||||
PersonScope.of(context).pomodoroRuns,
|
PersonScope.of(context).pomodoroRuns,
|
||||||
|
stoppedOutside: DependenciesScope.of(
|
||||||
|
context,
|
||||||
|
).pomodoro.takeStoppedOutside,
|
||||||
)..add(const PomodoroEventStarted()),
|
)..add(const PomodoroEventStarted()),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,12 @@ import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_reposit
|
||||||
/// state, through `PomodoroPresenter`.
|
/// state, through `PomodoroPresenter`.
|
||||||
class PomodoroBloc(
|
class PomodoroBloc(
|
||||||
final SettingsRepository _settings,
|
final SettingsRepository _settings,
|
||||||
final PomodoroRunStore _runs,
|
final PomodoroRunStore _runs, {
|
||||||
) extends Bloc<PomodoroEvent, PomodoroState> {
|
|
||||||
|
/// 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()) {
|
this : super(const PomodoroState()) {
|
||||||
on<PomodoroEventStarted>(_onStarted);
|
on<PomodoroEventStarted>(_onStarted);
|
||||||
on<PomodoroEventSettingsChanged>((event, emit) async {
|
on<PomodoroEventSettingsChanged>((event, emit) async {
|
||||||
|
|
@ -73,17 +77,28 @@ class PomodoroBloc(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final Future<bool> Function()? _stoppedOutside = stoppedOutside;
|
||||||
StreamSubscription<void>? _settingsChanges;
|
StreamSubscription<void>? _settingsChanges;
|
||||||
Timer? _phaseEnd;
|
Timer? _phaseEnd;
|
||||||
|
Timer? _looks;
|
||||||
|
|
||||||
Future<void> _onStarted(
|
Future<void> _onStarted(
|
||||||
PomodoroEventStarted event,
|
PomodoroEventStarted event,
|
||||||
Emitter<PomodoroState> emit,
|
Emitter<PomodoroState> emit,
|
||||||
) async {
|
) async {
|
||||||
final settings = await _settings.read();
|
final settings = await _settings.read();
|
||||||
final kept = await _runs.read();
|
var kept = await _runs.read();
|
||||||
await _emit(emit, settings: settings.pomodoro, run: kept);
|
if (kept != null && (await _stoppedOutside?.call() ?? false)) {
|
||||||
await _catchUp(emit);
|
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(
|
_settingsChanges = _settings.watch().listen(
|
||||||
(settings) => add(PomodoroEventSettingsChanged(settings.pomodoro)),
|
(settings) => add(PomodoroEventSettingsChanged(settings.pomodoro)),
|
||||||
// A read that failed leaves the profiles as they were.
|
// A read that failed leaves the profiles as they were.
|
||||||
|
|
@ -119,18 +134,29 @@ class PomodoroBloc(
|
||||||
await _runs.write(next.run);
|
await _runs.write(next.run);
|
||||||
}
|
}
|
||||||
_phaseEnd?.cancel();
|
_phaseEnd?.cancel();
|
||||||
|
_looks?.cancel();
|
||||||
|
if (isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (next.run?.endsAt case final endsAt?) {
|
if (next.run?.endsAt case final endsAt?) {
|
||||||
_phaseEnd = Timer(
|
_phaseEnd = Timer(
|
||||||
endsAt.difference(clock.now().toUtc()) +
|
endsAt.difference(clock.now().toUtc()) +
|
||||||
const Duration(milliseconds: 1),
|
const Duration(milliseconds: 1),
|
||||||
() => add(const PomodoroEventCaughtUp()),
|
() => 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
|
@override
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
_phaseEnd?.cancel();
|
_phaseEnd?.cancel();
|
||||||
|
_looks?.cancel();
|
||||||
await _settingsChanges?.cancel();
|
await _settingsChanges?.cancel();
|
||||||
await super.close();
|
await super.close();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,24 @@ class AndroidPomodoroPlatform() implements PomodoroPlatform {
|
||||||
@override
|
@override
|
||||||
Future<void> clear() => _channel.invokeMethod<void>('clear');
|
Future<void> clear() => _channel.invokeMethod<void>('clear');
|
||||||
|
|
||||||
@override
|
late final Stream<String> _events = _buttons
|
||||||
late final Stream<PomodoroAction> actions = _buttons
|
|
||||||
.receiveBroadcastStream()
|
.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
|
@override
|
||||||
Future<bool> mayKeepQuiet({bool ask = false}) async =>
|
Future<bool> mayKeepQuiet({bool ask = false}) async =>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
import 'package:dbus/dbus.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/pomodoro_platform.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/status_notifier_item.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';
|
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;
|
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
|
@override
|
||||||
Future<void> show(PomodoroNotice notice) async {
|
Future<void> show(PomodoroNotice notice) => _queue.run(() => _show(notice));
|
||||||
|
|
||||||
|
Future<void> _show(PomodoroNotice notice) async {
|
||||||
_listen();
|
_listen();
|
||||||
final labels = notice.actions;
|
final labels = notice.actions;
|
||||||
final buttons = [
|
final buttons = [
|
||||||
|
|
@ -198,7 +205,10 @@ class LinuxPomodoroPlatform(
|
||||||
}
|
}
|
||||||
_alert?.cancel();
|
_alert?.cancel();
|
||||||
if (notice.endsAt case final endsAt?) {
|
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());
|
await (notice.quiet ? _keepQuiet() : _restoreQuiet());
|
||||||
}
|
}
|
||||||
|
|
@ -222,7 +232,9 @@ class LinuxPomodoroPlatform(
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> clear() async {
|
Future<void> clear() => _queue.run(_clear);
|
||||||
|
|
||||||
|
Future<void> _clear() async {
|
||||||
_alert?.cancel();
|
_alert?.cancel();
|
||||||
_ticks?.cancel();
|
_ticks?.cancel();
|
||||||
try {
|
try {
|
||||||
|
|
@ -250,6 +262,14 @@ class LinuxPomodoroPlatform(
|
||||||
@override
|
@override
|
||||||
Stream<PomodoroAction> get actions => _actions.stream;
|
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'];
|
static const _banners = ['org.gnome.desktop.notifications', 'show-banners'];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -289,6 +309,8 @@ class LinuxPomodoroPlatform(
|
||||||
_quietBefore.deleteSync();
|
_quietBefore.deleteSync();
|
||||||
} on ProcessException catch (error) {
|
} on ProcessException catch (error) {
|
||||||
log("Couldn't end the quiet", name: 'Pomodoro', error: 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,14 @@ abstract interface class PomodoroPlatform() {
|
||||||
/// The buttons pressed outside the app.
|
/// The buttons pressed outside the app.
|
||||||
Stream<PomodoroAction> get actions;
|
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
|
/// Whether the person allowed the app to keep the device quiet; asks
|
||||||
/// them (the system's settings may open) when [ask].
|
/// them (the system's settings may open) when [ask].
|
||||||
Future<bool> mayKeepQuiet({bool ask = false});
|
Future<bool> mayKeepQuiet({bool ask = false});
|
||||||
|
|
@ -120,6 +128,12 @@ class const NoPomodoroPlatform() implements PomodoroPlatform {
|
||||||
@override
|
@override
|
||||||
Stream<PomodoroAction> get actions => const Stream.empty();
|
Stream<PomodoroAction> get actions => const Stream.empty();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<void> get phaseEnds => const Stream.empty();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> takeStoppedOutside() async => false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> mayKeepQuiet({bool ask = false}) async => false;
|
Future<bool> mayKeepQuiet({bool ask = false}) async => false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:io';
|
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';
|
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
|
/// 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
|
/// 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.
|
/// with them; written to a temporary file first and renamed into place.
|
||||||
class FilePomodoroRunStore(final File _file) implements PomodoroRunStore {
|
class FilePomodoroRunStore(final File _file) implements PomodoroRunStore {
|
||||||
|
// One write at a time: they share the temporary file.
|
||||||
|
final _queue = SerialQueue();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<PomodoroRun?> read() async {
|
Future<PomodoroRun?> read() async {
|
||||||
try {
|
try {
|
||||||
|
|
@ -30,7 +34,9 @@ class FilePomodoroRunStore(final File _file) implements PomodoroRunStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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) {
|
if (run == null) {
|
||||||
try {
|
try {
|
||||||
await _file.delete();
|
await _file.delete();
|
||||||
|
|
|
||||||
|
|
@ -20,15 +20,26 @@ typedef IndicatorEntry = ({String key, String label, bool enabled});
|
||||||
/// desktop without a watcher (stock GNOME without the extension) shows
|
/// desktop without a watcher (stock GNOME without the extension) shows
|
||||||
/// nothing, and [show] says so.
|
/// nothing, and [show] says so.
|
||||||
class StatusNotifierItem(final DBusClient _bus, {required final String id}) {
|
class StatusNotifierItem(final DBusClient _bus, {required final String id}) {
|
||||||
|
static const _watcher = 'org.kde.StatusNotifierWatcher';
|
||||||
|
|
||||||
late final _item = _Item(id);
|
late final _item = _Item(id);
|
||||||
late final _menu = _Menu();
|
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;
|
var _registered = false;
|
||||||
|
StreamSubscription<DBusNameOwnerChangedEvent>? _watcherChanges;
|
||||||
|
|
||||||
/// The keys of the menu entries chosen.
|
/// The keys of the menu entries chosen.
|
||||||
Stream<String> get chosen => _menu.chosen.stream;
|
Stream<String> get chosen => _menu.chosen.stream;
|
||||||
|
|
||||||
/// Shows the icon with [label] beside it, [title] as its name, and
|
/// 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({
|
Future<bool> show({
|
||||||
required String title,
|
required String title,
|
||||||
required String label,
|
required String label,
|
||||||
|
|
@ -36,30 +47,43 @@ class StatusNotifierItem(final DBusClient _bus, {required final String id}) {
|
||||||
}) async {
|
}) async {
|
||||||
await _menu.update(entries);
|
await _menu.update(entries);
|
||||||
await _item.update(title: title, label: label, active: true);
|
await _item.update(title: title, label: label, active: true);
|
||||||
if (_registered) {
|
if (!_exported) {
|
||||||
return true;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await _bus.registerObject(_item);
|
await _bus.registerObject(_item);
|
||||||
await _bus.registerObject(_menu);
|
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(
|
await DBusRemoteObject(
|
||||||
_bus,
|
_bus,
|
||||||
name: 'org.kde.StatusNotifierWatcher',
|
name: _watcher,
|
||||||
path: DBusObjectPath('/StatusNotifierWatcher'),
|
path: DBusObjectPath('/StatusNotifierWatcher'),
|
||||||
).callMethod(
|
).callMethod(
|
||||||
'org.kde.StatusNotifierWatcher',
|
_watcher,
|
||||||
'RegisterStatusNotifierItem',
|
'RegisterStatusNotifierItem',
|
||||||
[DBusString(name)],
|
[DBusString(_name)],
|
||||||
replySignature: DBusSignature(''),
|
replySignature: DBusSignature(''),
|
||||||
);
|
);
|
||||||
_registered = true;
|
_registered = true;
|
||||||
} on DBusMethodResponseException {
|
} on DBusMethodResponseException {
|
||||||
// No watcher: no top bar shows such icons here.
|
// No watcher: no top bar shows such icons here, for now.
|
||||||
return false;
|
_registered = false;
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Only the text beside the icon changes, as a countdown does.
|
/// 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.
|
/// Hides the icon.
|
||||||
Future<void> hide() => _item.update(active: false);
|
Future<void> hide() => _item.update(active: false);
|
||||||
|
|
||||||
|
/// Stops following the watcher.
|
||||||
|
Future<void> dispose() async => await _watcherChanges?.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
const _itemInterface = 'org.kde.StatusNotifierItem';
|
const _itemInterface = 'org.kde.StatusNotifierItem';
|
||||||
|
|
@ -80,6 +107,9 @@ class _Item(final String _id) extends DBusObject {
|
||||||
var _label = '';
|
var _label = '';
|
||||||
var _active = false;
|
var _active = false;
|
||||||
|
|
||||||
|
/// Whether the icon shows.
|
||||||
|
bool get active => _active;
|
||||||
|
|
||||||
Future<void> update({String? title, String? label, bool? active}) async {
|
Future<void> update({String? title, String? label, bool? active}) async {
|
||||||
if (title != null && title != _title) {
|
if (title != null && title != _title) {
|
||||||
_title = title;
|
_title = title;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_profile.dart';
|
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_profile.dart';
|
||||||
|
|
||||||
|
|
@ -162,14 +164,24 @@ final class const PomodoroRun({
|
||||||
return (run, ended);
|
return (run, ended);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This timer on [profile] from now on; the phase under way keeps its end.
|
/// This timer on [profile] from now on: the phase under way keeps its
|
||||||
PomodoroRun withProfile(PomodoroProfile profile) => PomodoroRun(
|
/// 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,
|
profile: profile,
|
||||||
phase: phase,
|
phase: phase,
|
||||||
focusDone: focusDone,
|
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,
|
endsAt: endsAt,
|
||||||
left: left,
|
left: !isRunning && left == length ? _lengthOf(profile, phase) : left,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The phases of a whole cycle of the profile: focus and short breaks up to
|
/// 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.
|
/// 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.
|
/// Where in [cycle] the timer is.
|
||||||
int get indexInCycle => !profile.hasLongBreak
|
int get indexInCycle =>
|
||||||
|
(!profile.hasLongBreak
|
||||||
? (phase.isBreak ? 1 : 0)
|
? (phase.isBreak ? 1 : 0)
|
||||||
: switch (phase) {
|
: switch (phase) {
|
||||||
PomodoroPhase.longBreak => cycle.length - 1,
|
PomodoroPhase.longBreak => cycle.length - 1,
|
||||||
PomodoroPhase.focus => focusDone * 2,
|
PomodoroPhase.focus => focusDone * 2,
|
||||||
PomodoroPhase.shortBreak => focusDone * 2 - 1,
|
PomodoroPhase.shortBreak => focusDone * 2 - 1,
|
||||||
};
|
})
|
||||||
|
.clamp(0, cycle.length - 1);
|
||||||
|
|
||||||
/// How long a phase of [cycle] lasts.
|
/// How long a phase of [cycle] lasts.
|
||||||
Duration lengthIn(PomodoroPhase phase) => _lengthOf(profile, phase);
|
Duration lengthIn(PomodoroPhase phase) => _lengthOf(profile, phase);
|
||||||
|
|
||||||
/// How much of [cycle] has passed at [now].
|
/// How much of [cycle] has passed at [now].
|
||||||
Duration elapsedInCycleAt(DateTime 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)) {
|
for (final phase in cycle.take(indexInCycle)) {
|
||||||
elapsed += _lengthOf(profile, phase);
|
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.
|
/// [left] as a countdown: minutes and seconds, hours first from an hour on.
|
||||||
String formatPomodoroLeft(Duration left) {
|
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');
|
String two(int value) => value.toString().padLeft(2, '0');
|
||||||
final hours = seconds ~/ 3600;
|
final hours = seconds ~/ 3600;
|
||||||
final minutes = seconds % 3600 ~/ 60;
|
final minutes = seconds % 3600 ~/ 60;
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ class _PomodoroPresenterState() extends State<PomodoroPresenter> {
|
||||||
context,
|
context,
|
||||||
).pomodoro;
|
).pomodoro;
|
||||||
StreamSubscription<PomodoroAction>? _actions;
|
StreamSubscription<PomodoroAction>? _actions;
|
||||||
|
StreamSubscription<void>? _phaseEnds;
|
||||||
late final AppLifecycleListener _lifecycle;
|
late final AppLifecycleListener _lifecycle;
|
||||||
Timer? _refresh;
|
Timer? _refresh;
|
||||||
var _showing = false;
|
var _showing = false;
|
||||||
|
|
@ -43,6 +44,9 @@ class _PomodoroPresenterState() extends State<PomodoroPresenter> {
|
||||||
PomodoroAction.stop => const PomodoroEventStopRequested(),
|
PomodoroAction.stop => const PomodoroEventStopRequested(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
_phaseEnds = _platform.phaseEnds.listen(
|
||||||
|
(_) => bloc.add(const PomodoroEventCaughtUp()),
|
||||||
|
);
|
||||||
_lifecycle = AppLifecycleListener(
|
_lifecycle = AppLifecycleListener(
|
||||||
onResume: () => bloc.add(const PomodoroEventCaughtUp()),
|
onResume: () => bloc.add(const PomodoroEventCaughtUp()),
|
||||||
);
|
);
|
||||||
|
|
@ -52,6 +56,7 @@ class _PomodoroPresenterState() extends State<PomodoroPresenter> {
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_refresh?.cancel();
|
_refresh?.cancel();
|
||||||
unawaited(_actions?.cancel());
|
unawaited(_actions?.cancel());
|
||||||
|
unawaited(_phaseEnds?.cancel());
|
||||||
_lifecycle.dispose();
|
_lifecycle.dispose();
|
||||||
// A change of person takes their timer away with their app.
|
// A change of person takes their timer away with their app.
|
||||||
if (_showing) {
|
if (_showing) {
|
||||||
|
|
|
||||||
|
|
@ -144,4 +144,56 @@ void main() {
|
||||||
expect(bloc.state.settings.profiles, hasLength(2));
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
test('hands the buttons pressed to the app', () async {
|
||||||
final pressed = platform.actions.first;
|
final pressed = platform.actions.first;
|
||||||
await platform.show(notice(PomodoroRun.ready(PomodoroProfile.classic)));
|
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', () {
|
test('an icon of the timer is drawn for the top bar', () {
|
||||||
final icon = timerIcon(24);
|
final icon = timerIcon(24);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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', () {
|
test('survives being kept and read back', () {
|
||||||
final run = PomodoroRun.ready(classic).next(now).started(now);
|
final run = PomodoroRun.ready(classic).next(now).started(now);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,24 @@ class FakePomodoroPlatform() implements PomodoroPlatform {
|
||||||
@override
|
@override
|
||||||
Stream<PomodoroAction> get actions => _actions.stream;
|
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
|
@override
|
||||||
Future<bool> mayKeepQuiet({bool ask = false}) async {
|
Future<bool> mayKeepQuiet({bool ask = false}) async {
|
||||||
if (ask) {
|
if (ask) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue