feat(linux): suggest the top bar's extension instead of a notification
Decided 2026-10-07: where GNOME's top bar shows no icons (the AppIndicator extension missing or off), the timer shows in the app alone, and no notification stands in for it. The Pomodoro card says that the timer can count down in the top bar, with a button that asks GNOME Shell to switch the extension on where it is installed or to install it (GNOME's own dialog), or opens its page on extensions.gnome.org where Shell can't be asked; until the person says not to suggest it again. An app can't install or switch on a Shell extension by itself. The alert at a phase's end stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
ad28a5d8c2
commit
d9f7498cd4
14 changed files with 568 additions and 275 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), 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.
|
- **The Pomodoro timer** (`feature/pomodoro/`): the profiles follow the person (`PersonSettings.pomodoro`, settings schema 4), 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`, over package:dbus: where the top bar shows icons (GNOME with the AppIndicator extension, built into Ubuntu; KDE) the timer counts down there with its buttons in the icon's menu (`StatusNotifierItem`, written in Dart on the bus name `org.freedesktop.StatusNotifierItem-<pid>-1`, `/StatusNotifierItem` and `/MenuBar`, which the snap's `desktop` plug allows; registered again when the watcher restarts); elsewhere the timer shows in the app alone, and on GNOME the Pomodoro card suggests the extension until dismissed, asking GNOME Shell to install or switch it on (`InstallRemoteExtension`, `EnableExtension`) or opening its page (decided 2026-10-07: no notification stands in; xdg-desktop-portal's Background status is for Flatpaks only). An alert with sound comes through `org.freedesktop.Notifications` when a phase ends while the app runs, and GNOME's `show-banners` goes off while focusing, through `gsettings`; the value before and the dismissed suggestion are kept in `<app support>/`. Calls to the bus go one at a time (`SerialQueue`).
|
||||||
- **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.
|
||||||
|
|
|
||||||
|
|
@ -859,5 +859,8 @@
|
||||||
"pathImportNotText": "„{detail}“ muss Text sein.",
|
"pathImportNotText": "„{detail}“ muss Text sein.",
|
||||||
"pathImportTooFew": "es braucht mindestens zwei „{detail}“.",
|
"pathImportTooFew": "es braucht mindestens zwei „{detail}“.",
|
||||||
"pathImportMalformedNumber": "eine fehlerhafte Zahl.",
|
"pathImportMalformedNumber": "eine fehlerhafte Zahl.",
|
||||||
"pathImportTitleNotText": "ein Titel muss Text sein."
|
"pathImportTitleNotText": "ein Titel muss Text sein.",
|
||||||
|
"pomodoroTopBarHint": "Der Timer kann in der oberen Leiste von GNOME herunterzählen, mit seinen Knöpfen, sobald die Erweiterung „AppIndicator“ an ist.",
|
||||||
|
"pomodoroTopBarSetUp": "Einrichten",
|
||||||
|
"pomodoroTopBarDismiss": "Nicht mehr vorschlagen"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1033,5 +1033,8 @@
|
||||||
"pathImportTooFew": "it needs at least two \"{detail}\".",
|
"pathImportTooFew": "it needs at least two \"{detail}\".",
|
||||||
"@pathImportTooFew": {"placeholders": {"detail": {"type": "String"}}},
|
"@pathImportTooFew": {"placeholders": {"detail": {"type": "String"}}},
|
||||||
"pathImportMalformedNumber": "a malformed number.",
|
"pathImportMalformedNumber": "a malformed number.",
|
||||||
"pathImportTitleNotText": "a title must be text."
|
"pathImportTitleNotText": "a title must be text.",
|
||||||
|
"pomodoroTopBarHint": "The timer can count down in GNOME's top bar, with its buttons, once the AppIndicator extension is on.",
|
||||||
|
"pomodoroTopBarSetUp": "Set up",
|
||||||
|
"pomodoroTopBarDismiss": "Don't suggest again"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5150,6 +5150,24 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'a title must be text.'**
|
/// **'a title must be text.'**
|
||||||
String get pathImportTitleNotText;
|
String get pathImportTitleNotText;
|
||||||
|
|
||||||
|
/// No description provided for @pomodoroTopBarHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The timer can count down in GNOME\'s top bar, with its buttons, once the AppIndicator extension is on.'**
|
||||||
|
String get pomodoroTopBarHint;
|
||||||
|
|
||||||
|
/// No description provided for @pomodoroTopBarSetUp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Set up'**
|
||||||
|
String get pomodoroTopBarSetUp;
|
||||||
|
|
||||||
|
/// No description provided for @pomodoroTopBarDismiss.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Don\'t suggest again'**
|
||||||
|
String get pomodoroTopBarDismiss;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -3121,4 +3121,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathImportTitleNotText => 'ein Titel muss Text sein.';
|
String get pathImportTitleNotText => 'ein Titel muss Text sein.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pomodoroTopBarHint =>
|
||||||
|
'Der Timer kann in der oberen Leiste von GNOME herunterzählen, mit seinen Knöpfen, sobald die Erweiterung „AppIndicator“ an ist.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pomodoroTopBarSetUp => 'Einrichten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pomodoroTopBarDismiss => 'Nicht mehr vorschlagen';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3090,6 +3090,16 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathImportTitleNotText => 'a title must be text.';
|
String get pathImportTitleNotText => 'a title must be text.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pomodoroTopBarHint =>
|
||||||
|
'The timer can count down in GNOME\'s top bar, with its buttons, once the AppIndicator extension is on.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pomodoroTopBarSetUp => 'Set up';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pomodoroTopBarDismiss => 'Don\'t suggest again';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,16 @@ class AndroidPomodoroPlatform() implements PomodoroPlatform {
|
||||||
Future<bool> takeStoppedOutside() async =>
|
Future<bool> takeStoppedOutside() async =>
|
||||||
await _channel.invokeMethod<bool>('takeStoppedOutside') ?? false;
|
await _channel.invokeMethod<bool>('takeStoppedOutside') ?? false;
|
||||||
|
|
||||||
|
// Android's notification shows the timer; there is no top bar to set up.
|
||||||
|
@override
|
||||||
|
Stream<bool> get suggestsTopBar => Stream.value(false);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setUpTopBar() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dismissTopBarSuggestion() async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> mayKeepQuiet({bool ask = false}) async =>
|
Future<bool> mayKeepQuiet({bool ask = false}) async =>
|
||||||
await _channel.invokeMethod<bool>('mayKeepQuiet', ask) ?? false;
|
await _channel.invokeMethod<bool>('mayKeepQuiet', ask) ?? false;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ 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';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
/// Runs a command, as [Process.run] does; given for tests.
|
/// Runs a command, as [Process.run] does; given for tests.
|
||||||
typedef RunCommand = Future<ProcessResult> Function(
|
typedef RunCommand = Future<ProcessResult> Function(
|
||||||
|
|
@ -15,126 +17,90 @@ typedef RunCommand = Future<ProcessResult> Function(
|
||||||
List<String> arguments,
|
List<String> arguments,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// The Pomodoro timer on a Linux desktop, through the session bus's
|
/// The Pomodoro timer on a Linux desktop, over the session bus.
|
||||||
/// notification server (`org.freedesktop.Notifications`, which the snap's
|
|
||||||
/// `desktop` plug lets it use): a notification with the timer's buttons,
|
|
||||||
/// kept up to date; an alert with sound when a phase ends, while the app
|
|
||||||
/// runs (a desktop app's timer lives no longer than the app); and GNOME's
|
|
||||||
/// Do Not Disturb (`show-banners` off) while focusing, where `gsettings` can
|
|
||||||
/// change it. The value before is kept in [_quietBefore], so that it comes
|
|
||||||
/// back also after a crash.
|
|
||||||
///
|
///
|
||||||
/// Where the desktop shows icons in its top bar (GNOME with the AppIndicator
|
/// Where the desktop shows icons in its top bar (GNOME with the AppIndicator
|
||||||
/// extension, which Ubuntu has built in; KDE), the timer counts down there,
|
/// extension, which Ubuntu has built in; KDE), the timer counts down there,
|
||||||
/// with its buttons in the icon's menu; elsewhere a notification says until
|
/// with its buttons in the icon's menu (`StatusNotifierItem`). Elsewhere the
|
||||||
/// when the phase runs, since GNOME shows no countdown in one.
|
/// timer shows in the app alone, and on GNOME the app suggests the extension
|
||||||
|
/// (decided 2026-10-07: a suggestion, no notification standing in; an app
|
||||||
|
/// can't install or switch on a Shell extension by itself).
|
||||||
|
///
|
||||||
|
/// When a phase ends, an alert with sound comes through the notification
|
||||||
|
/// server (`org.freedesktop.Notifications`, which the snap's `desktop` plug
|
||||||
|
/// allows), while the app runs: a desktop app's timer lives no longer than
|
||||||
|
/// the app. GNOME's banners stay off while focusing (`show-banners`, through
|
||||||
|
/// `gsettings`), the value before kept in [_folder], so that it comes back
|
||||||
|
/// also after a crash; so is the person's no to the suggestion.
|
||||||
class LinuxPomodoroPlatform(
|
class LinuxPomodoroPlatform(
|
||||||
final DBusClient _bus,
|
final DBusClient _bus,
|
||||||
final File _quietBefore, {
|
final Directory _folder, {
|
||||||
RunCommand run = Process.run,
|
RunCommand run = Process.run,
|
||||||
|
Future<void> Function(Uri page) open = _openPage,
|
||||||
|
String? desktop,
|
||||||
}) implements PomodoroPlatform {
|
}) implements PomodoroPlatform {
|
||||||
/// The timer in the top bar, where the desktop shows such icons; the
|
static const _notifications = 'org.freedesktop.Notifications';
|
||||||
/// notification stands in elsewhere.
|
static const _watcher = 'org.kde.StatusNotifierWatcher';
|
||||||
|
static const _extensions = 'org.gnome.Shell.Extensions';
|
||||||
|
|
||||||
|
/// AppIndicator support: Ubuntu's own build, then the one on
|
||||||
|
/// extensions.gnome.org.
|
||||||
|
static const _indicatorExtensions = [
|
||||||
|
'ubuntu-appindicators@ubuntu.com',
|
||||||
|
'appindicatorsupport@rgcjonas.gmail.com',
|
||||||
|
];
|
||||||
|
static final _extensionPage = Uri.parse(
|
||||||
|
'https://extensions.gnome.org/extension/615/appindicator-support/',
|
||||||
|
);
|
||||||
|
|
||||||
|
final RunCommand _run = run;
|
||||||
|
final Future<void> Function(Uri page) _open = open;
|
||||||
|
final String? _desktop = desktop;
|
||||||
|
|
||||||
|
/// Whether the desktop is GNOME, where the extension can be suggested.
|
||||||
|
late final bool _gnome =
|
||||||
|
(_desktop ?? Platform.environment['XDG_CURRENT_DESKTOP'] ?? '')
|
||||||
|
.toUpperCase()
|
||||||
|
.split(':')
|
||||||
|
.contains('GNOME');
|
||||||
|
|
||||||
|
late final File _quietBefore = File(
|
||||||
|
p.join(_folder.path, 'pomodoro_quiet_before'),
|
||||||
|
);
|
||||||
|
late final File _topBarDismissed = File(
|
||||||
|
p.join(_folder.path, 'pomodoro_top_bar_dismissed'),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The timer in the top bar, where the desktop shows such icons.
|
||||||
late final _indicator = StatusNotifierItem(_bus, id: 'halkyon-learn')
|
late final _indicator = StatusNotifierItem(_bus, id: 'halkyon-learn')
|
||||||
..chosen.listen((key) {
|
..chosen.listen((key) {
|
||||||
if (PomodoroAction.values.asNameMap()[key] case final action?) {
|
if (PomodoroAction.values.asNameMap()[key] case final action?) {
|
||||||
_actions.add(action);
|
_actions.add(action);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
final _actions = StreamController<PomodoroAction>.broadcast();
|
||||||
|
final _suggestionChanges = StreamController<void>.broadcast();
|
||||||
Timer? _ticks;
|
Timer? _ticks;
|
||||||
static const _interface = 'org.freedesktop.Notifications';
|
Timer? _alert;
|
||||||
|
|
||||||
|
// One change at a time: each waits for the bus, and the next mustn't meet
|
||||||
|
// it half done (an indicator registered twice, a ticking label left over).
|
||||||
|
final _queue = SerialQueue();
|
||||||
|
|
||||||
|
static Future<void> _openPage(Uri page) async {
|
||||||
|
await launchUrl(page);
|
||||||
|
}
|
||||||
|
|
||||||
late final _server = DBusRemoteObject(
|
late final _server = DBusRemoteObject(
|
||||||
_bus,
|
_bus,
|
||||||
name: _interface,
|
name: _notifications,
|
||||||
path: DBusObjectPath('/org/freedesktop/Notifications'),
|
path: DBusObjectPath('/org/freedesktop/Notifications'),
|
||||||
);
|
);
|
||||||
final RunCommand _run = run;
|
|
||||||
final _actions = StreamController<PomodoroAction>.broadcast();
|
|
||||||
StreamSubscription<DBusSignal>? _invoked;
|
|
||||||
StreamSubscription<DBusSignal>? _closed;
|
|
||||||
|
|
||||||
/// The id of the timer's notification while one shows.
|
|
||||||
int? _id;
|
|
||||||
Timer? _alert;
|
|
||||||
|
|
||||||
/// The desktop file the notifications belong to, for their icon and name.
|
|
||||||
static String get _desktopEntry =>
|
|
||||||
switch (Platform.environment['SNAP_NAME']) {
|
|
||||||
final snap? => '${snap}_halkyon-learn',
|
|
||||||
null => 'halkyon-learn',
|
|
||||||
};
|
|
||||||
|
|
||||||
void _listen() {
|
|
||||||
_invoked ??=
|
|
||||||
DBusRemoteObjectSignalStream(
|
|
||||||
object: _server,
|
|
||||||
interface: _interface,
|
|
||||||
name: 'ActionInvoked',
|
|
||||||
signature: DBusSignature('us'),
|
|
||||||
).listen((signal) {
|
|
||||||
if (signal.values
|
|
||||||
case [
|
|
||||||
DBusUint32(:final value),
|
|
||||||
DBusString(value: final key),
|
|
||||||
]
|
|
||||||
when value == _id) {
|
|
||||||
if (PomodoroAction.values.asNameMap()[key] case final action?) {
|
|
||||||
_actions.add(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
_closed ??=
|
|
||||||
DBusRemoteObjectSignalStream(
|
|
||||||
object: _server,
|
|
||||||
interface: _interface,
|
|
||||||
name: 'NotificationClosed',
|
|
||||||
signature: DBusSignature('uu'),
|
|
||||||
).listen((signal) {
|
|
||||||
if ((signal.values.first as DBusUint32).value == _id) {
|
|
||||||
_id = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<int> _notify({
|
|
||||||
required int replaces,
|
|
||||||
required String summary,
|
|
||||||
required String body,
|
|
||||||
List<String> actions = const [],
|
|
||||||
required Map<String, DBusValue> hints,
|
|
||||||
int expireMs = -1,
|
|
||||||
}) async {
|
|
||||||
final reply = await _server.callMethod(
|
|
||||||
_interface,
|
|
||||||
'Notify',
|
|
||||||
[
|
|
||||||
const DBusString('Halkyon Learn'),
|
|
||||||
DBusUint32(replaces),
|
|
||||||
const DBusString(''),
|
|
||||||
DBusString(summary),
|
|
||||||
DBusString(body),
|
|
||||||
DBusArray.string(actions),
|
|
||||||
DBusDict.stringVariant({
|
|
||||||
'desktop-entry': DBusString(_desktopEntry),
|
|
||||||
...hints,
|
|
||||||
}),
|
|
||||||
DBusInt32(expireMs),
|
|
||||||
],
|
|
||||||
replySignature: DBusSignature('u'),
|
|
||||||
);
|
|
||||||
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) => _queue.run(() => _show(notice));
|
Future<void> show(PomodoroNotice notice) => _queue.run(() => _show(notice));
|
||||||
|
|
||||||
Future<void> _show(PomodoroNotice notice) async {
|
Future<void> _show(PomodoroNotice notice) async {
|
||||||
_listen();
|
|
||||||
final labels = notice.actions;
|
final labels = notice.actions;
|
||||||
final buttons = [
|
final buttons = [
|
||||||
if (notice.isRunning) PomodoroAction.pause else PomodoroAction.resume,
|
if (notice.isRunning) PomodoroAction.pause else PomodoroAction.resume,
|
||||||
|
|
@ -168,40 +134,11 @@ class LinuxPomodoroPlatform(
|
||||||
error: error,
|
error: error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (inTopBar) {
|
if (inTopBar && notice.isRunning) {
|
||||||
await _closeNotification();
|
_ticks = Timer.periodic(
|
||||||
if (notice.isRunning) {
|
const Duration(seconds: 1),
|
||||||
_ticks = Timer.periodic(
|
(_) => unawaited(_indicator.label(left())),
|
||||||
const Duration(seconds: 1),
|
);
|
||||||
(_) => unawaited(_indicator.label(left())),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
_id = await _notify(
|
|
||||||
replaces: _id ?? 0,
|
|
||||||
summary: notice.title,
|
|
||||||
body: '${notice.text} · ${notice.status}',
|
|
||||||
actions: [
|
|
||||||
for (final action in buttons) ...[
|
|
||||||
action.name,
|
|
||||||
labels[action] ?? action.name,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
hints: {
|
|
||||||
// It stays when a button is pressed, and keeps quiet when
|
|
||||||
// updated.
|
|
||||||
'resident': const DBusBoolean(true),
|
|
||||||
'suppress-sound': const DBusBoolean(true),
|
|
||||||
'urgency': const DBusByte(0),
|
|
||||||
},
|
|
||||||
expireMs: 0,
|
|
||||||
);
|
|
||||||
} on Exception catch (error) {
|
|
||||||
// No notification server, or none on the bus: the app shows it
|
|
||||||
// alone.
|
|
||||||
log("Couldn't show the timer", name: 'Pomodoro', error: error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_alert?.cancel();
|
_alert?.cancel();
|
||||||
if (notice.endsAt case final endsAt?) {
|
if (notice.endsAt case final endsAt?) {
|
||||||
|
|
@ -217,20 +154,38 @@ class LinuxPomodoroPlatform(
|
||||||
// The alert is heard, the quiet over first.
|
// The alert is heard, the quiet over first.
|
||||||
await _restoreQuiet();
|
await _restoreQuiet();
|
||||||
try {
|
try {
|
||||||
await _notify(
|
await _server.callMethod(
|
||||||
replaces: 0,
|
_notifications,
|
||||||
summary: notice.alertTitle,
|
'Notify',
|
||||||
body: notice.alertText,
|
[
|
||||||
hints: {
|
const DBusString('Halkyon Learn'),
|
||||||
'sound-name': const DBusString('complete'),
|
const DBusUint32(0),
|
||||||
'urgency': const DBusByte(1),
|
const DBusString(''),
|
||||||
},
|
DBusString(notice.alertTitle),
|
||||||
|
DBusString(notice.alertText),
|
||||||
|
DBusArray.string(const []),
|
||||||
|
DBusDict.stringVariant({
|
||||||
|
'desktop-entry': DBusString(_desktopEntry),
|
||||||
|
'sound-name': const DBusString('complete'),
|
||||||
|
'urgency': const DBusByte(1),
|
||||||
|
}),
|
||||||
|
const DBusInt32(-1),
|
||||||
|
],
|
||||||
|
replySignature: DBusSignature('u'),
|
||||||
);
|
);
|
||||||
} on Exception catch (error) {
|
} on Exception catch (error) {
|
||||||
|
// No notification server, or none on the bus.
|
||||||
log("Couldn't alert", name: 'Pomodoro', error: error);
|
log("Couldn't alert", name: 'Pomodoro', error: error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The desktop file the alerts belong to, for their icon and name.
|
||||||
|
static String get _desktopEntry =>
|
||||||
|
switch (Platform.environment['SNAP_NAME']) {
|
||||||
|
final snap? => '${snap}_halkyon-learn',
|
||||||
|
null => 'halkyon-learn',
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> clear() => _queue.run(_clear);
|
Future<void> clear() => _queue.run(_clear);
|
||||||
|
|
||||||
|
|
@ -242,23 +197,9 @@ class LinuxPomodoroPlatform(
|
||||||
} on Exception catch (error) {
|
} on Exception catch (error) {
|
||||||
log("Couldn't take the timer away", name: 'Pomodoro', error: error);
|
log("Couldn't take the timer away", name: 'Pomodoro', error: error);
|
||||||
}
|
}
|
||||||
await _closeNotification();
|
|
||||||
await _restoreQuiet();
|
await _restoreQuiet();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _closeNotification() async {
|
|
||||||
if (_id case final id?) {
|
|
||||||
_id = null;
|
|
||||||
try {
|
|
||||||
await _server.callMethod(_interface, 'CloseNotification', [
|
|
||||||
DBusUint32(id),
|
|
||||||
], replySignature: DBusSignature(''));
|
|
||||||
} on Exception catch (error) {
|
|
||||||
log("Couldn't take the timer away", name: 'Pomodoro', error: error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<PomodoroAction> get actions => _actions.stream;
|
Stream<PomodoroAction> get actions => _actions.stream;
|
||||||
|
|
||||||
|
|
@ -270,6 +211,99 @@ class LinuxPomodoroPlatform(
|
||||||
@override
|
@override
|
||||||
Future<bool> takeStoppedOutside() async => false;
|
Future<bool> takeStoppedOutside() async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<bool> get suggestsTopBar {
|
||||||
|
late final StreamController<bool> suggestions;
|
||||||
|
StreamSubscription<void>? watcherChanges;
|
||||||
|
StreamSubscription<void>? dismissals;
|
||||||
|
Future<void> check() async {
|
||||||
|
final suggests = await _suggestsTopBar();
|
||||||
|
if (!suggestions.isClosed) {
|
||||||
|
suggestions.add(suggests);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions = StreamController<bool>(
|
||||||
|
onListen: () {
|
||||||
|
watcherChanges = _bus.nameOwnerChanged
|
||||||
|
.where((change) => change.name == _watcher)
|
||||||
|
.listen((_) => unawaited(check()));
|
||||||
|
dismissals = _suggestionChanges.stream.listen(
|
||||||
|
(_) => unawaited(check()),
|
||||||
|
);
|
||||||
|
unawaited(check());
|
||||||
|
},
|
||||||
|
onCancel: () async {
|
||||||
|
await watcherChanges?.cancel();
|
||||||
|
await dismissals?.cancel();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return suggestions.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _suggestsTopBar() async {
|
||||||
|
if (!_gnome || _topBarDismissed.existsSync()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return !await _bus.nameHasOwner(_watcher);
|
||||||
|
} on Exception {
|
||||||
|
// No session bus to ask: nothing to set up either.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setUpTopBar() async {
|
||||||
|
final shell = DBusRemoteObject(
|
||||||
|
_bus,
|
||||||
|
name: _extensions,
|
||||||
|
path: DBusObjectPath('/org/gnome/Shell/Extensions'),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
for (final uuid in _indicatorExtensions) {
|
||||||
|
final info = await shell.callMethod(
|
||||||
|
_extensions,
|
||||||
|
'GetExtensionInfo',
|
||||||
|
[DBusString(uuid)],
|
||||||
|
replySignature: DBusSignature('a{sv}'),
|
||||||
|
);
|
||||||
|
if ((info.values.single as DBusDict).children.isNotEmpty) {
|
||||||
|
// Installed, but switched off.
|
||||||
|
await shell.callMethod(
|
||||||
|
_extensions,
|
||||||
|
'EnableExtension',
|
||||||
|
[DBusString(uuid)],
|
||||||
|
replySignature: DBusSignature('b'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// GNOME asks the person, and fetches it from extensions.gnome.org.
|
||||||
|
await shell.callMethod(
|
||||||
|
_extensions,
|
||||||
|
'InstallRemoteExtension',
|
||||||
|
[DBusString(_indicatorExtensions.last)],
|
||||||
|
replySignature: DBusSignature('s'),
|
||||||
|
);
|
||||||
|
} on Exception catch (error) {
|
||||||
|
// Not GNOME's Shell on the bus, or a sandbox that won't let the app
|
||||||
|
// ask it: the extension's page, where the person can.
|
||||||
|
log(
|
||||||
|
"Couldn't ask GNOME for the extension",
|
||||||
|
name: 'Pomodoro',
|
||||||
|
error: error,
|
||||||
|
);
|
||||||
|
await _open(_extensionPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dismissTopBarSuggestion() async {
|
||||||
|
_topBarDismissed.createSync(recursive: true);
|
||||||
|
_suggestionChanges.add(null);
|
||||||
|
}
|
||||||
|
|
||||||
static const _banners = ['org.gnome.desktop.notifications', 'show-banners'];
|
static const _banners = ['org.gnome.desktop.notifications', 'show-banners'];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -288,7 +322,9 @@ class LinuxPomodoroPlatform(
|
||||||
if (before.exitCode != 0) {
|
if (before.exitCode != 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_quietBefore.writeAsStringSync((before.stdout as String).trim());
|
_quietBefore
|
||||||
|
..createSync(recursive: true)
|
||||||
|
..writeAsStringSync((before.stdout as String).trim());
|
||||||
}
|
}
|
||||||
await _run('gsettings', ['set', ..._banners, 'false']);
|
await _run('gsettings', ['set', ..._banners, 'false']);
|
||||||
} on ProcessException catch (error) {
|
} on ProcessException catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,19 @@ abstract interface class PomodoroPlatform() {
|
||||||
/// 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});
|
||||||
|
|
||||||
|
/// Whether to suggest what lets the desktop's top bar show the timer, now
|
||||||
|
/// and on every change: on a desktop whose top bar shows no icons, until
|
||||||
|
/// the person dismisses the suggestion (decided 2026-10-07: a suggestion,
|
||||||
|
/// no notification standing in).
|
||||||
|
Stream<bool> get suggestsTopBar;
|
||||||
|
|
||||||
|
/// Asks the desktop to install or switch on what shows the timer in the
|
||||||
|
/// top bar (its own dialog follows), or opens where the person can.
|
||||||
|
Future<void> setUpTopBar();
|
||||||
|
|
||||||
|
/// Stops suggesting it on this device.
|
||||||
|
Future<void> dismissTopBarSuggestion();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A platform without any of it: the timer shows in the app alone.
|
/// A platform without any of it: the timer shows in the app alone.
|
||||||
|
|
@ -136,4 +149,13 @@ class const NoPomodoroPlatform() implements PomodoroPlatform {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> mayKeepQuiet({bool ask = false}) async => false;
|
Future<bool> mayKeepQuiet({bool ask = false}) async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<bool> get suggestsTopBar => Stream.value(false);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setUpTopBar() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dismissTopBarSuggestion() async {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import 'package:dbus/dbus.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/android_pomodoro_platform.dart';
|
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/android_pomodoro_platform.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/linux_pomodoro_platform.dart';
|
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/linux_pomodoro_platform.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:path/path.dart' as p;
|
|
||||||
|
|
||||||
/// The platform side of the Pomodoro timer for the platform the app runs
|
/// The platform side of the Pomodoro timer for the platform the app runs
|
||||||
/// on, keeping what it must in [supportFolder]; none where there is none
|
/// on, keeping what it must in [supportFolder]; none where there is none
|
||||||
|
|
@ -14,7 +13,7 @@ PomodoroPlatform pomodoroPlatform(String supportFolder) =>
|
||||||
'android' => AndroidPomodoroPlatform(),
|
'android' => AndroidPomodoroPlatform(),
|
||||||
'linux' => LinuxPomodoroPlatform(
|
'linux' => LinuxPomodoroPlatform(
|
||||||
DBusClient.session(),
|
DBusClient.session(),
|
||||||
File(p.join(supportFolder, 'pomodoro_quiet_before')),
|
Directory(supportFolder),
|
||||||
),
|
),
|
||||||
_ => const NoPomodoroPlatform(),
|
_ => const NoPomodoroPlatform(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ import 'dart:async';
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.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/core/dependencies/dependencies_scope.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/bloc/pomodoro_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/pomodoro/bloc/pomodoro_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_platform.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';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_profiles_sheet.dart';
|
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_profiles_sheet.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_texts.dart';
|
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_texts.dart';
|
||||||
|
|
@ -13,7 +15,8 @@ import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// The Pomodoro timer on Home: the profile it runs (chosen here while no
|
/// The Pomodoro timer on Home: the profile it runs (chosen here while no
|
||||||
/// timer is set), the time left of the phase and which phase it is, and
|
/// timer is set), the time left of the phase and which phase it is, and
|
||||||
/// starting, pausing, skipping and stopping it.
|
/// starting, pausing, skipping and stopping it; where the desktop's top bar
|
||||||
|
/// could show the timer but doesn't, how to set that up.
|
||||||
class const PomodoroCard({super.key}) extends StatelessWidget {
|
class const PomodoroCard({super.key}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -138,12 +141,95 @@ class const PomodoroCard({super.key}) extends StatelessWidget {
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const _TopBarSuggestion(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// On a desktop whose top bar shows no icons (GNOME without the AppIndicator
|
||||||
|
/// extension), a suggestion to set it up, until the person dismisses it.
|
||||||
|
class const _TopBarSuggestion() extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_TopBarSuggestion> createState() => _TopBarSuggestionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TopBarSuggestionState() extends State<_TopBarSuggestion> {
|
||||||
|
late final PomodoroPlatform _platform = DependenciesScope.of(
|
||||||
|
context,
|
||||||
|
).pomodoro;
|
||||||
|
StreamSubscription<bool>? _suggestions;
|
||||||
|
var _suggests = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_suggestions = _platform.suggestsTopBar.listen(
|
||||||
|
(suggests) => setState(() => _suggests = suggests),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
unawaited(_suggestions?.cancel());
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!_suggests) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Icon(LucideIcons.panelTop, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.pomodoroTopBarHint,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Wrap(
|
||||||
|
alignment: WrapAlignment.end,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _platform.dismissTopBarSuggestion,
|
||||||
|
child: Text(l10n.pomodoroTopBarDismiss),
|
||||||
|
),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: _platform.setUpTopBar,
|
||||||
|
child: Text(l10n.pomodoroTopBarSetUp),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What is left of [run]'s phase, counting down each second while it runs.
|
/// What is left of [run]'s phase, counting down each second while it runs.
|
||||||
class const PomodoroCountdown({
|
class const PomodoroCountdown({
|
||||||
required final PomodoroRun run,
|
required final PomodoroRun run,
|
||||||
|
|
|
||||||
|
|
@ -17,20 +17,12 @@ class _Notifications() extends DBusObject {
|
||||||
this : super(DBusObjectPath('/org/freedesktop/Notifications'));
|
this : super(DBusObjectPath('/org/freedesktop/Notifications'));
|
||||||
|
|
||||||
final notified = <List<DBusValue>>[];
|
final notified = <List<DBusValue>>[];
|
||||||
final closed = <int>[];
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<DBusMethodResponse> handleMethodCall(DBusMethodCall call) async {
|
Future<DBusMethodResponse> handleMethodCall(DBusMethodCall call) async {
|
||||||
switch (call.name) {
|
if (call.name == 'Notify') {
|
||||||
case 'Notify':
|
notified.add(call.values);
|
||||||
notified.add(call.values);
|
return DBusMethodSuccessResponse([DBusUint32(notified.length)]);
|
||||||
final replaces = (call.values[1] as DBusUint32).value;
|
|
||||||
return DBusMethodSuccessResponse([
|
|
||||||
DBusUint32(replaces == 0 ? notified.length : replaces),
|
|
||||||
]);
|
|
||||||
case 'CloseNotification':
|
|
||||||
closed.add((call.values.single as DBusUint32).value);
|
|
||||||
return DBusMethodSuccessResponse();
|
|
||||||
}
|
}
|
||||||
return DBusMethodErrorResponse.unknownMethod();
|
return DBusMethodErrorResponse.unknownMethod();
|
||||||
}
|
}
|
||||||
|
|
@ -52,6 +44,33 @@ class _Watcher() extends DBusObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GNOME Shell's extensions, with those [installed] (off) noted by id.
|
||||||
|
class _Extensions(final Set<String> installed) extends DBusObject {
|
||||||
|
this : super(DBusObjectPath('/org/gnome/Shell/Extensions'));
|
||||||
|
|
||||||
|
final calls = <String>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DBusMethodResponse> handleMethodCall(DBusMethodCall call) async {
|
||||||
|
final uuid = (call.values.first as DBusString).value;
|
||||||
|
calls.add('${call.name} $uuid');
|
||||||
|
return switch (call.name) {
|
||||||
|
'GetExtensionInfo' => DBusMethodSuccessResponse([
|
||||||
|
DBusDict.stringVariant({
|
||||||
|
if (installed.contains(uuid)) 'uuid': DBusString(uuid),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
'EnableExtension' => DBusMethodSuccessResponse([
|
||||||
|
const DBusBoolean(true),
|
||||||
|
]),
|
||||||
|
'InstallRemoteExtension' => DBusMethodSuccessResponse([
|
||||||
|
const DBusString('successful'),
|
||||||
|
]),
|
||||||
|
_ => DBusMethodErrorResponse.unknownMethod(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
// The app has them through Material's localizations.
|
// The app has them through Material's localizations.
|
||||||
setUpAll(initializeDateFormatting);
|
setUpAll(initializeDateFormatting);
|
||||||
|
|
@ -62,10 +81,23 @@ void main() {
|
||||||
late DBusClient app;
|
late DBusClient app;
|
||||||
late _Notifications notifications;
|
late _Notifications notifications;
|
||||||
late Directory folder;
|
late Directory folder;
|
||||||
late List<List<String>> commands;
|
|
||||||
late String banners;
|
late String banners;
|
||||||
|
late List<Uri> opened;
|
||||||
late LinuxPomodoroPlatform platform;
|
late LinuxPomodoroPlatform platform;
|
||||||
|
|
||||||
|
LinuxPomodoroPlatform platformOn(String desktop) => LinuxPomodoroPlatform(
|
||||||
|
app,
|
||||||
|
folder,
|
||||||
|
desktop: desktop,
|
||||||
|
run: (command, arguments) async {
|
||||||
|
if (arguments.first == 'set') {
|
||||||
|
banners = arguments.last;
|
||||||
|
}
|
||||||
|
return ProcessResult(0, 0, '$banners\n', '');
|
||||||
|
},
|
||||||
|
open: (page) async => opened.add(page),
|
||||||
|
);
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
server = DBusServer();
|
server = DBusServer();
|
||||||
address = await server.listenAddress(
|
address = await server.listenAddress(
|
||||||
|
|
@ -77,19 +109,9 @@ void main() {
|
||||||
await serverSide.registerObject(notifications);
|
await serverSide.registerObject(notifications);
|
||||||
app = DBusClient(address);
|
app = DBusClient(address);
|
||||||
folder = await Directory.systemTemp.createTemp('pomodoro_linux_test');
|
folder = await Directory.systemTemp.createTemp('pomodoro_linux_test');
|
||||||
commands = [];
|
|
||||||
banners = 'true';
|
banners = 'true';
|
||||||
platform = LinuxPomodoroPlatform(
|
opened = [];
|
||||||
app,
|
platform = platformOn('ubuntu:GNOME');
|
||||||
File('${folder.path}/quiet_before'),
|
|
||||||
run: (command, arguments) async {
|
|
||||||
commands.add([command, ...arguments]);
|
|
||||||
if (arguments.first == 'set') {
|
|
||||||
banners = arguments.last;
|
|
||||||
}
|
|
||||||
return ProcessResult(0, 0, '$banners\n', '');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|
@ -101,84 +123,37 @@ void main() {
|
||||||
PomodoroNotice notice(PomodoroRun run) =>
|
PomodoroNotice notice(PomodoroRun run) =>
|
||||||
english.pomodoroNotice(run, DateTime.now().toUtc());
|
english.pomodoroNotice(run, DateTime.now().toUtc());
|
||||||
|
|
||||||
test('shows the timer with its buttons, and updates the same '
|
/// GNOME's AppIndicator extension, on the bus until its client closes.
|
||||||
'notification', () async {
|
Future<(_Watcher, DBusClient)> startWatcher() async {
|
||||||
|
final watcher = _Watcher();
|
||||||
|
final shell = DBusClient(address);
|
||||||
|
await shell.requestName('org.kde.StatusNotifierWatcher');
|
||||||
|
await shell.registerObject(watcher);
|
||||||
|
return (watcher, shell);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('without a top bar for it, shows no notification of its own', () async {
|
||||||
final run = PomodoroRun.ready(PomodoroProfile.classic);
|
final run = PomodoroRun.ready(PomodoroProfile.classic);
|
||||||
|
|
||||||
await platform.show(notice(run.started(DateTime.now().toUtc())));
|
await platform.show(notice(run.started(DateTime.now().toUtc())));
|
||||||
await platform.show(notice(run));
|
await platform.show(notice(run));
|
||||||
|
|
||||||
final [first, second] = notifications.notified;
|
expect(notifications.notified, isEmpty);
|
||||||
expect((first[3] as DBusString).value, 'Focus 1 of 4');
|
|
||||||
expect(
|
|
||||||
(first[5] as DBusArray).children.map(
|
|
||||||
(each) => (each as DBusString).value,
|
|
||||||
),
|
|
||||||
[
|
|
||||||
'pause',
|
|
||||||
'Pause',
|
|
||||||
'skip',
|
|
||||||
'Skip',
|
|
||||||
'stop',
|
|
||||||
'Stop',
|
|
||||||
],
|
|
||||||
);
|
|
||||||
expect((second[1] as DBusUint32).value, 1, reason: 'replaces the first');
|
|
||||||
expect((second[4] as DBusString).value, startsWith('Paused · Classic · '));
|
|
||||||
expect(
|
|
||||||
(second[5] as DBusArray).children.first,
|
|
||||||
const DBusString('resume'),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('changes that come at once still make one notification', () async {
|
test('keeps GNOME quiet while focusing, and gives the banners back '
|
||||||
final run = PomodoroRun.ready(PomodoroProfile.classic);
|
'after', () async {
|
||||||
|
const quiet = PomodoroProfile(id: 'classic', doNotDisturb: true);
|
||||||
|
final run = PomodoroRun.ready(quiet).started(DateTime.now().toUtc());
|
||||||
|
|
||||||
await Future.wait([
|
expect(await platform.mayKeepQuiet(), isTrue);
|
||||||
platform.show(notice(run.started(DateTime.now().toUtc()))),
|
await platform.show(notice(run));
|
||||||
platform.show(notice(run)),
|
expect(banners, 'false');
|
||||||
]);
|
|
||||||
|
|
||||||
expect(
|
await platform.clear();
|
||||||
notifications.notified.map((call) => (call[1] as DBusUint32).value),
|
expect(banners, 'true');
|
||||||
[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)));
|
|
||||||
|
|
||||||
await serverSide.emitSignal(
|
|
||||||
path: DBusObjectPath('/org/freedesktop/Notifications'),
|
|
||||||
interface: 'org.freedesktop.Notifications',
|
|
||||||
name: 'ActionInvoked',
|
|
||||||
values: const [DBusUint32(1), DBusString('skip')],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await pressed.timeout(const Duration(seconds: 5)),
|
|
||||||
PomodoroAction.skip,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
|
||||||
'keeps GNOME quiet while focusing, and gives the banners back after',
|
|
||||||
() async {
|
|
||||||
const quiet = PomodoroProfile(id: 'classic', doNotDisturb: true);
|
|
||||||
final run = PomodoroRun.ready(quiet).started(DateTime.now().toUtc());
|
|
||||||
|
|
||||||
expect(await platform.mayKeepQuiet(), isTrue);
|
|
||||||
await platform.show(notice(run));
|
|
||||||
expect(banners, 'false');
|
|
||||||
|
|
||||||
await platform.clear();
|
|
||||||
expect(banners, 'true');
|
|
||||||
expect(notifications.closed, [1]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test('alerts when the phase ends', () async {
|
test('alerts when the phase ends', () async {
|
||||||
const short = PomodoroProfile(id: 'short', focusMinutes: 1);
|
const short = PomodoroProfile(id: 'short', focusMinutes: 1);
|
||||||
final run = PomodoroRun(
|
final run = PomodoroRun(
|
||||||
|
|
@ -190,7 +165,7 @@ void main() {
|
||||||
await platform.show(notice(run));
|
await platform.show(notice(run));
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 600));
|
await Future<void>.delayed(const Duration(milliseconds: 600));
|
||||||
|
|
||||||
final alert = notifications.notified.last;
|
final alert = notifications.notified.single;
|
||||||
expect((alert[3] as DBusString).value, 'Time for a break');
|
expect((alert[3] as DBusString).value, 'Time for a break');
|
||||||
expect(
|
expect(
|
||||||
(alert[6] as DBusDict).children[const DBusString('sound-name')],
|
(alert[6] as DBusDict).children[const DBusString('sound-name')],
|
||||||
|
|
@ -202,16 +177,10 @@ void main() {
|
||||||
late _Watcher watcher;
|
late _Watcher watcher;
|
||||||
late DBusClient shell;
|
late DBusClient shell;
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async => (watcher, shell) = await startWatcher());
|
||||||
watcher = _Watcher();
|
|
||||||
shell = DBusClient(address);
|
|
||||||
await shell.requestName('org.kde.StatusNotifierWatcher');
|
|
||||||
await shell.registerObject(watcher);
|
|
||||||
});
|
|
||||||
tearDown(() => shell.close());
|
tearDown(() => shell.close());
|
||||||
|
|
||||||
test('the timer counts down there, its buttons in its menu, and no '
|
test('the timer counts down there, its buttons in its menu', () async {
|
||||||
'notification shows', () async {
|
|
||||||
final run = PomodoroRun.ready(
|
final run = PomodoroRun.ready(
|
||||||
PomodoroProfile.classic,
|
PomodoroProfile.classic,
|
||||||
).started(DateTime.now().toUtc());
|
).started(DateTime.now().toUtc());
|
||||||
|
|
@ -273,26 +242,103 @@ void main() {
|
||||||
const DBusString('Passive'),
|
const DBusString('Passive'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('changes that come at once register the item once', () async {
|
||||||
|
final run = PomodoroRun.ready(PomodoroProfile.classic);
|
||||||
|
|
||||||
|
await Future.wait([
|
||||||
|
platform.show(notice(run.started(DateTime.now().toUtc()))),
|
||||||
|
platform.show(notice(run)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(watcher.registered, hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the item registers again with a watcher that restarts', () async {
|
||||||
|
final run = PomodoroRun.ready(PomodoroProfile.classic);
|
||||||
|
await platform.show(notice(run));
|
||||||
|
expect(watcher.registered, hasLength(1));
|
||||||
|
|
||||||
|
await shell.close();
|
||||||
|
(watcher, shell) = await startWatcher();
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
expect(watcher.registered, hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('suggests nothing', () async {
|
||||||
|
expect(await platform.suggestsTopBar.first, isFalse);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the item registers again with a watcher that restarts', () async {
|
group('without a top bar for it', () {
|
||||||
var watcher = _Watcher();
|
test('suggests setting one up on GNOME, until dismissed', () async {
|
||||||
var shell = DBusClient(address);
|
final suggestions = <bool>[];
|
||||||
await shell.requestName('org.kde.StatusNotifierWatcher');
|
final subscription = platform.suggestsTopBar.listen(suggestions.add);
|
||||||
await shell.registerObject(watcher);
|
addTearDown(subscription.cancel);
|
||||||
final run = PomodoroRun.ready(PomodoroProfile.classic);
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
await platform.show(notice(run));
|
expect(suggestions, [true]);
|
||||||
expect(watcher.registered, hasLength(1));
|
|
||||||
|
|
||||||
await shell.close();
|
await platform.dismissTopBarSuggestion();
|
||||||
watcher = _Watcher();
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
shell = DBusClient(address);
|
expect(suggestions, [true, false]);
|
||||||
await shell.requestName('org.kde.StatusNotifierWatcher');
|
expect(await platformOn('ubuntu:GNOME').suggestsTopBar.first, isFalse);
|
||||||
await shell.registerObject(watcher);
|
});
|
||||||
addTearDown(shell.close);
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
|
||||||
|
|
||||||
expect(watcher.registered, hasLength(1));
|
test('suggests nothing elsewhere than on GNOME', () async {
|
||||||
|
expect(await platformOn('KDE').suggestsTopBar.first, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stops suggesting once a watcher turns up', () async {
|
||||||
|
final suggestions = <bool>[];
|
||||||
|
final subscription = platform.suggestsTopBar.listen(suggestions.add);
|
||||||
|
addTearDown(subscription.cancel);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
final (_, shell) = await startWatcher();
|
||||||
|
addTearDown(shell.close);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
expect(suggestions.last, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'setting up switches on the extension where it is installed',
|
||||||
|
() async {
|
||||||
|
final extensions = _Extensions({'ubuntu-appindicators@ubuntu.com'});
|
||||||
|
await serverSide.requestName('org.gnome.Shell.Extensions');
|
||||||
|
await serverSide.registerObject(extensions);
|
||||||
|
|
||||||
|
await platform.setUpTopBar();
|
||||||
|
|
||||||
|
expect(extensions.calls, [
|
||||||
|
'GetExtensionInfo ubuntu-appindicators@ubuntu.com',
|
||||||
|
'EnableExtension ubuntu-appindicators@ubuntu.com',
|
||||||
|
]);
|
||||||
|
expect(opened, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('setting up has GNOME install it where it is not', () async {
|
||||||
|
final extensions = _Extensions({});
|
||||||
|
await serverSide.requestName('org.gnome.Shell.Extensions');
|
||||||
|
await serverSide.registerObject(extensions);
|
||||||
|
|
||||||
|
await platform.setUpTopBar();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
extensions.calls.last,
|
||||||
|
'InstallRemoteExtension appindicatorsupport@rgcjonas.gmail.com',
|
||||||
|
);
|
||||||
|
expect(opened, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("setting up opens the extension's page where GNOME can't be "
|
||||||
|
'asked', () async {
|
||||||
|
await platform.setUpTopBar();
|
||||||
|
|
||||||
|
expect(opened.single.host, 'extensions.gnome.org');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an icon of the timer is drawn for the top bar', () {
|
test('an icon of the timer is drawn for the top bar', () {
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,32 @@ void main() {
|
||||||
expect(tester.widget<SwitchListTile>(quiet).value, isTrue);
|
expect(tester.widget<SwitchListTile>(quiet).value, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('suggests setting up the top bar where it shows no icons, '
|
||||||
|
'until dismissed', (tester) async {
|
||||||
|
platform.suggestTopBar(suggest: true);
|
||||||
|
await showHome(tester);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.textContaining("GNOME's top bar"),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Set up'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(platform.topBarSetUps, 1);
|
||||||
|
|
||||||
|
await tester.tap(find.text("Don't suggest again"));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining("GNOME's top bar"), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('suggests nothing where the top bar shows the timer', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await showHome(tester);
|
||||||
|
|
||||||
|
expect(find.textContaining("GNOME's top bar"), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('the timer shows while studying, and a tap pauses it', (
|
testWidgets('the timer shows while studying, and a tap pauses it', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,30 @@ class FakePomodoroPlatform() implements PomodoroPlatform {
|
||||||
return stopped;
|
return stopped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final _suggestsTopBar = StreamController<bool>.broadcast();
|
||||||
|
bool _suggesting = false;
|
||||||
|
|
||||||
|
/// How often the person asked to set up the top bar.
|
||||||
|
int topBarSetUps = 0;
|
||||||
|
|
||||||
|
/// Suggests, or no longer suggests, setting up the top bar.
|
||||||
|
void suggestTopBar({required bool suggest}) {
|
||||||
|
_suggesting = suggest;
|
||||||
|
_suggestsTopBar.add(suggest);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<bool> get suggestsTopBar async* {
|
||||||
|
yield _suggesting;
|
||||||
|
yield* _suggestsTopBar.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setUpTopBar() async => topBarSetUps++;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dismissTopBarSuggestion() async => suggestTopBar(suggest: false);
|
||||||
|
|
||||||
@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