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:
Tizian 「ティツィアーン」 2026-10-07 10:27:31 +02:00
commit d9f7498cd4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
13 changed files with 568 additions and 275 deletions

View file

@ -859,5 +859,8 @@
"pathImportNotText": "„{detail}“ muss Text sein.",
"pathImportTooFew": "es braucht mindestens zwei „{detail}“.",
"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"
}

View file

@ -1033,5 +1033,8 @@
"pathImportTooFew": "it needs at least two \"{detail}\".",
"@pathImportTooFew": {"placeholders": {"detail": {"type": "String"}}},
"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"
}

View file

@ -5150,6 +5150,24 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'a title must be text.'**
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

View file

@ -3121,4 +3121,14 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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';
}

View file

@ -3090,6 +3090,16 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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`).

View file

@ -37,6 +37,16 @@ class AndroidPomodoroPlatform() implements PomodoroPlatform {
Future<bool> takeStoppedOutside() async =>
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
Future<bool> mayKeepQuiet({bool ask = false}) async =>
await _channel.invokeMethod<bool>('mayKeepQuiet', ask) ?? false;

View file

@ -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/status_notifier_item.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.
typedef RunCommand = Future<ProcessResult> Function(
@ -15,126 +17,90 @@ typedef RunCommand = Future<ProcessResult> Function(
List<String> arguments,
);
/// The Pomodoro timer on a Linux desktop, through the session bus's
/// 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.
/// The Pomodoro timer on a Linux desktop, over the session bus.
///
/// 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,
/// with its buttons in the icon's menu; elsewhere a notification says until
/// when the phase runs, since GNOME shows no countdown in one.
/// with its buttons in the icon's menu (`StatusNotifierItem`). Elsewhere the
/// 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(
final DBusClient _bus,
final File _quietBefore, {
final Directory _folder, {
RunCommand run = Process.run,
Future<void> Function(Uri page) open = _openPage,
String? desktop,
}) implements PomodoroPlatform {
/// The timer in the top bar, where the desktop shows such icons; the
/// notification stands in elsewhere.
static const _notifications = 'org.freedesktop.Notifications';
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')
..chosen.listen((key) {
if (PomodoroAction.values.asNameMap()[key] case final action?) {
_actions.add(action);
}
});
final _actions = StreamController<PomodoroAction>.broadcast();
final _suggestionChanges = StreamController<void>.broadcast();
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(
_bus,
name: _interface,
name: _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
Future<void> show(PomodoroNotice notice) => _queue.run(() => _show(notice));
Future<void> _show(PomodoroNotice notice) async {
_listen();
final labels = notice.actions;
final buttons = [
if (notice.isRunning) PomodoroAction.pause else PomodoroAction.resume,
@ -168,40 +134,11 @@ class LinuxPomodoroPlatform(
error: error,
);
}
if (inTopBar) {
await _closeNotification();
if (notice.isRunning) {
_ticks = Timer.periodic(
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);
}
if (inTopBar && notice.isRunning) {
_ticks = Timer.periodic(
const Duration(seconds: 1),
(_) => unawaited(_indicator.label(left())),
);
}
_alert?.cancel();
if (notice.endsAt case final endsAt?) {
@ -217,20 +154,38 @@ class LinuxPomodoroPlatform(
// The alert is heard, the quiet over first.
await _restoreQuiet();
try {
await _notify(
replaces: 0,
summary: notice.alertTitle,
body: notice.alertText,
hints: {
'sound-name': const DBusString('complete'),
'urgency': const DBusByte(1),
},
await _server.callMethod(
_notifications,
'Notify',
[
const DBusString('Halkyon Learn'),
const DBusUint32(0),
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) {
// No notification server, or none on the bus.
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
Future<void> clear() => _queue.run(_clear);
@ -242,23 +197,9 @@ class LinuxPomodoroPlatform(
} on Exception catch (error) {
log("Couldn't take the timer away", name: 'Pomodoro', error: error);
}
await _closeNotification();
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
Stream<PomodoroAction> get actions => _actions.stream;
@ -270,6 +211,99 @@ class LinuxPomodoroPlatform(
@override
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'];
@override
@ -288,7 +322,9 @@ class LinuxPomodoroPlatform(
if (before.exitCode != 0) {
return;
}
_quietBefore.writeAsStringSync((before.stdout as String).trim());
_quietBefore
..createSync(recursive: true)
..writeAsStringSync((before.stdout as String).trim());
}
await _run('gsettings', ['set', ..._banners, 'false']);
} on ProcessException catch (error) {

View file

@ -115,6 +115,19 @@ abstract interface class PomodoroPlatform() {
/// Whether the person allowed the app to keep the device quiet; asks
/// them (the system's settings may open) when [ask].
Future<bool> mayKeepQuiet({bool ask = false});
/// 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.
@ -136,4 +149,13 @@ class const NoPomodoroPlatform() implements PomodoroPlatform {
@override
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 {}
}

View file

@ -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/linux_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
/// on, keeping what it must in [supportFolder]; none where there is none
@ -14,7 +13,7 @@ PomodoroPlatform pomodoroPlatform(String supportFolder) =>
'android' => AndroidPomodoroPlatform(),
'linux' => LinuxPomodoroPlatform(
DBusClient.session(),
File(p.join(supportFolder, 'pomodoro_quiet_before')),
Directory(supportFolder),
),
_ => const NoPomodoroPlatform(),
};

View file

@ -3,8 +3,10 @@ import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.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/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/widget/pomodoro_profiles_sheet.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
/// 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 {
@override
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.
class const PomodoroCountdown({
required final PomodoroRun run,

View file

@ -17,20 +17,12 @@ class _Notifications() extends DBusObject {
this : super(DBusObjectPath('/org/freedesktop/Notifications'));
final notified = <List<DBusValue>>[];
final closed = <int>[];
@override
Future<DBusMethodResponse> handleMethodCall(DBusMethodCall call) async {
switch (call.name) {
case 'Notify':
notified.add(call.values);
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();
if (call.name == 'Notify') {
notified.add(call.values);
return DBusMethodSuccessResponse([DBusUint32(notified.length)]);
}
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() {
// The app has them through Material's localizations.
setUpAll(initializeDateFormatting);
@ -62,10 +81,23 @@ void main() {
late DBusClient app;
late _Notifications notifications;
late Directory folder;
late List<List<String>> commands;
late String banners;
late List<Uri> opened;
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 {
server = DBusServer();
address = await server.listenAddress(
@ -77,19 +109,9 @@ void main() {
await serverSide.registerObject(notifications);
app = DBusClient(address);
folder = await Directory.systemTemp.createTemp('pomodoro_linux_test');
commands = [];
banners = 'true';
platform = LinuxPomodoroPlatform(
app,
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', '');
},
);
opened = [];
platform = platformOn('ubuntu:GNOME');
});
tearDown(() async {
await app.close();
@ -101,84 +123,37 @@ void main() {
PomodoroNotice notice(PomodoroRun run) =>
english.pomodoroNotice(run, DateTime.now().toUtc());
test('shows the timer with its buttons, and updates the same '
'notification', () async {
/// GNOME's AppIndicator extension, on the bus until its client closes.
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);
await platform.show(notice(run.started(DateTime.now().toUtc())));
await platform.show(notice(run));
final [first, second] = notifications.notified;
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'),
);
expect(notifications.notified, isEmpty);
});
test('changes that come at once still make one notification', () async {
final run = PomodoroRun.ready(PomodoroProfile.classic);
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());
await Future.wait([
platform.show(notice(run.started(DateTime.now().toUtc()))),
platform.show(notice(run)),
]);
expect(await platform.mayKeepQuiet(), isTrue);
await platform.show(notice(run));
expect(banners, 'false');
expect(
notifications.notified.map((call) => (call[1] as DBusUint32).value),
[0, 1],
reason: 'the second replaces the first',
);
await platform.clear();
expect(banners, 'true');
});
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 {
const short = PomodoroProfile(id: 'short', focusMinutes: 1);
final run = PomodoroRun(
@ -190,7 +165,7 @@ void main() {
await platform.show(notice(run));
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[6] as DBusDict).children[const DBusString('sound-name')],
@ -202,16 +177,10 @@ void main() {
late _Watcher watcher;
late DBusClient shell;
setUp(() async {
watcher = _Watcher();
shell = DBusClient(address);
await shell.requestName('org.kde.StatusNotifierWatcher');
await shell.registerObject(watcher);
});
setUp(() async => (watcher, shell) = await startWatcher());
tearDown(() => shell.close());
test('the timer counts down there, its buttons in its menu, and no '
'notification shows', () async {
test('the timer counts down there, its buttons in its menu', () async {
final run = PomodoroRun.ready(
PomodoroProfile.classic,
).started(DateTime.now().toUtc());
@ -273,26 +242,103 @@ void main() {
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 {
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));
group('without a top bar for it', () {
test('suggests setting one up on GNOME, until dismissed', () async {
final suggestions = <bool>[];
final subscription = platform.suggestsTopBar.listen(suggestions.add);
addTearDown(subscription.cancel);
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(suggestions, [true]);
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));
await platform.dismissTopBarSuggestion();
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(suggestions, [true, false]);
expect(await platformOn('ubuntu:GNOME').suggestsTopBar.first, isFalse);
});
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', () {

View file

@ -155,6 +155,32 @@ void main() {
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', (
tester,
) async {

View file

@ -49,6 +49,30 @@ class FakePomodoroPlatform() implements PomodoroPlatform {
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
Future<bool> mayKeepQuiet({bool ask = false}) async {
if (ask) {