fix(app): the Pomodoro timer holds up with the app gone, asleep or edited
A review of the timer found:
- Stop in the notification with the app swiped away took the timer
away there, but the app showed it again at its next start. The
service now leaves a mark the app takes when it starts, and the timer
it kept is forgotten.
- Pause, Resume and Skip did nothing with the app gone: since Android
12 a service may not start an activity from a notification's button.
The notification is posted again with buttons that open the app.
- A timer kept while the app was closed was shown as it was before it
caught up, so its ended phase alerted a second time. It is now caught
up before it first shows.
- Dart's timers sleep with the device, so a phase that ended while the
phone or laptop slept went unnoticed until the app came back. While a
phase runs the timer looks every 15 seconds, and on Android the
alarm's receiver wakes it too.
- On Linux, changes that came at once overlapped on the bus (an
indicator registered twice, notifications left over, a ticking label
never stopped); they now go one at a time. The indicator registers
again when GNOME's watcher restarts, and with a watcher that only
turns up later.
- Changing the running profile could put the timer outside its cycle
("Focus 6 of 4", the Live Update's bar past its end); where it is
stays within the new cycle, and a phase waiting at its full length
takes the new length. A countdown past its end shows 00:00.
- Writes of the kept timer go one at a time, and a closed timer arms no
more timers.
- The alert no longer renames the timer's notification channel, and a
German hint said the alert came at the device's end.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
6ba229be2c
commit
a822763df8
17 changed files with 420 additions and 65 deletions
|
|
@ -16,6 +16,7 @@ class MainActivity : FlutterActivity() {
|
|||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||
PomodoroService.appBack()
|
||||
// The Pomodoro timer's side of the system (PomodoroService).
|
||||
MethodChannel(messenger, "app.halkyon.learn/pomodoro").setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
|
|
@ -28,6 +29,7 @@ class MainActivity : FlutterActivity() {
|
|||
PomodoroService.clear(this)
|
||||
result.success(null)
|
||||
}
|
||||
"takeStoppedOutside" -> result.success(PomodoroService.takeStoppedOutside(this))
|
||||
"mayKeepQuiet" -> {
|
||||
val allowed = Quiet.allowed(this)
|
||||
if (!allowed && call.arguments == true) {
|
||||
|
|
@ -53,6 +55,7 @@ class MainActivity : FlutterActivity() {
|
|||
|
||||
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
PomodoroService.buttons = null
|
||||
PomodoroService.appGone()
|
||||
super.cleanUpFlutterEngine(flutterEngine)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,31 @@ class PomodoroService : Service() {
|
|||
/** The service while it runs. */
|
||||
@Volatile private var running: PomodoroService? = null
|
||||
|
||||
/** Whether the app is gone (swiped away): its buttons then open it. */
|
||||
@Volatile private var appGone = false
|
||||
|
||||
private const val PREFERENCES = "pomodoro"
|
||||
private const val STOPPED_OUTSIDE = "stoppedOutside"
|
||||
|
||||
/** The app went away: its buttons open it from now on. */
|
||||
fun appGone() {
|
||||
appGone = true
|
||||
running?.repost()
|
||||
}
|
||||
|
||||
/** The app is back, and shows its timer anew. */
|
||||
fun appBack() {
|
||||
appGone = false
|
||||
}
|
||||
|
||||
/** Whether the timer was stopped here while the app was gone; forgets it. */
|
||||
fun takeStoppedOutside(context: Context): Boolean {
|
||||
val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
val stopped = preferences.getBoolean(STOPPED_OUTSIDE, false)
|
||||
preferences.edit().remove(STOPPED_OUTSIDE).apply()
|
||||
return stopped
|
||||
}
|
||||
|
||||
/** Shows [notice] (JSON), starting the service if it doesn't run yet. */
|
||||
fun show(context: Context, notice: String) {
|
||||
val service = running
|
||||
|
|
@ -95,9 +120,16 @@ class PomodoroService : Service() {
|
|||
val app = buttons
|
||||
when {
|
||||
app != null -> app(button)
|
||||
// The app is gone (swiped away): Stop needs it not; the others
|
||||
// open it, where the timer catches up and goes on.
|
||||
button == "stop" -> clear(this)
|
||||
// The app is gone (swiped away): Stop needs it not, and the app
|
||||
// forgets its timer when it opens next; the others open it
|
||||
// (their buttons do so by themselves once the app is gone).
|
||||
button == "stop" -> {
|
||||
getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(STOPPED_OUTSIDE, true)
|
||||
.apply()
|
||||
clear(this)
|
||||
}
|
||||
else -> packageManager.getLaunchIntentForPackage(packageName)?.let {
|
||||
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
startActivity(it)
|
||||
|
|
@ -108,9 +140,28 @@ class PomodoroService : Service() {
|
|||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
/** The last notice shown, to show again as the app comes and goes. */
|
||||
private var last: JSONObject? = null
|
||||
|
||||
/** Shows the last notice again. */
|
||||
fun repost() {
|
||||
last?.let { notice ->
|
||||
NotificationManagerCompat.from(this).let {
|
||||
if (it.areNotificationsEnabled()) {
|
||||
try {
|
||||
it.notify(TIMER_ID, notification(notice))
|
||||
} catch (_: SecurityException) {
|
||||
// Notifications not allowed: nothing to show.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows [json], schedules its phase's end and keeps the device quiet as it says. */
|
||||
fun present(json: String) {
|
||||
val notice = JSONObject(json)
|
||||
last = notice
|
||||
channels(this, notice)
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
|
|
@ -170,7 +221,11 @@ class PomodoroService : Service() {
|
|||
}
|
||||
|
||||
private fun button(name: String): PendingIntent =
|
||||
PendingIntent.getService(
|
||||
// Without the app, Pause, Resume and Skip open it: a service may not
|
||||
// start an activity from a notification's button (Android 12 on).
|
||||
if (appGone && name != "stop") {
|
||||
openApp(this)!!
|
||||
} else PendingIntent.getService(
|
||||
this,
|
||||
name.hashCode(),
|
||||
Intent(this, PomodoroService::class.java).setAction(ACTION_BUTTON).putExtra(EXTRA_BUTTON, name),
|
||||
|
|
@ -192,21 +247,26 @@ internal fun openApp(context: Context): PendingIntent? =
|
|||
|
||||
/** The timer's channels, named in the app's language: the countdown, silent; the alerts, with sound and vibration. */
|
||||
internal fun channels(context: Context, notice: JSONObject) {
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
PomodoroService.TIMER_CHANNEL,
|
||||
notice.optString("timerName", "Pomodoro"),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply { setShowBadge(false) },
|
||||
)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
PomodoroService.ALERT_CHANNEL,
|
||||
notice.optString("alertsName", "Pomodoro"),
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply { enableVibration(true) },
|
||||
)
|
||||
context
|
||||
.getSystemService(NotificationManager::class.java)
|
||||
.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
PomodoroService.TIMER_CHANNEL,
|
||||
notice.optString("timerName", "Pomodoro"),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply { setShowBadge(false) },
|
||||
)
|
||||
alertChannel(context, notice.optString("alertsName", "Pomodoro"))
|
||||
}
|
||||
|
||||
/** The alerts' channel, with sound and vibration. */
|
||||
internal fun alertChannel(context: Context, name: String) {
|
||||
context
|
||||
.getSystemService(NotificationManager::class.java)
|
||||
.createNotificationChannel(
|
||||
NotificationChannel(PomodoroService.ALERT_CHANNEL, name, NotificationManager.IMPORTANCE_HIGH)
|
||||
.apply { enableVibration(true) },
|
||||
)
|
||||
}
|
||||
|
||||
/** The alarm at the end of a phase, which alerts even with the app closed. */
|
||||
|
|
@ -253,13 +313,15 @@ internal object PomodoroAlarm {
|
|||
class PomodoroAlarmReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Quiet.restore(context)
|
||||
// The app's own timers may have slept with the device: it looks now.
|
||||
PomodoroService.buttons?.invoke("phaseEnded")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
channels(context, JSONObject().put("alertsName", intent.getStringExtra("alertsName")))
|
||||
alertChannel(context, intent.getStringExtra("alertsName") ?: "Pomodoro")
|
||||
NotificationManagerCompat.from(context)
|
||||
.notify(
|
||||
PomodoroService.ALERT_ID,
|
||||
|
|
|
|||
|
|
@ -820,7 +820,7 @@
|
|||
"pomodoroAutoStartBreaks": "Pausen von selbst starten",
|
||||
"pomodoroAutoStartFocus": "Fokus nach einer Pause von selbst starten",
|
||||
"pomodoroDoNotDisturb": "Nicht stören während des Fokus",
|
||||
"pomodoroDoNotDisturbHint": "Das Gerät bleibt still, während du dich konzentrierst; die Meldung an seinem Ende ist trotzdem zu hören.",
|
||||
"pomodoroDoNotDisturbHint": "Das Gerät bleibt still, während du dich konzentrierst; die Meldung am Ende des Fokus ist trotzdem zu hören.",
|
||||
"pomodoroDoNotDisturbDenied": "Die App darf dieses Gerät nicht stummschalten. Erlaube es in den Systemeinstellungen.",
|
||||
"pomodoroDeleteProfile": "Profil löschen",
|
||||
"pomodoroSummary": "{focus} Min. Fokus, {shortBreak} Min. Pause",
|
||||
|
|
|
|||
|
|
@ -2962,7 +2962,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get pomodoroDoNotDisturbHint =>
|
||||
'Das Gerät bleibt still, während du dich konzentrierst; die Meldung an seinem Ende ist trotzdem zu hören.';
|
||||
'Das Gerät bleibt still, während du dich konzentrierst; die Meldung am Ende des Fokus ist trotzdem zu hören.';
|
||||
|
||||
@override
|
||||
String get pomodoroDoNotDisturbDenied =>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||
import 'package:go_router/go_router.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/app/app_router.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/device_settings/widget/device_settings_scope.dart';
|
||||
|
|
@ -89,6 +90,9 @@ class _PersonAppState() extends State<PersonApp> {
|
|||
create: (context) => PomodoroBloc(
|
||||
PersonScope.of(context).settings,
|
||||
PersonScope.of(context).pomodoroRuns,
|
||||
stoppedOutside: DependenciesScope.of(
|
||||
context,
|
||||
).pomodoro.takeStoppedOutside,
|
||||
)..add(const PomodoroEventStarted()),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -18,8 +18,12 @@ import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_reposit
|
|||
/// state, through `PomodoroPresenter`.
|
||||
class PomodoroBloc(
|
||||
final SettingsRepository _settings,
|
||||
final PomodoroRunStore _runs,
|
||||
) extends Bloc<PomodoroEvent, PomodoroState> {
|
||||
final PomodoroRunStore _runs, {
|
||||
|
||||
/// Whether the timer was stopped outside the app while it was gone, so
|
||||
/// the timer kept is no more (`PomodoroPlatform.takeStoppedOutside`).
|
||||
Future<bool> Function()? stoppedOutside,
|
||||
}) extends Bloc<PomodoroEvent, PomodoroState> {
|
||||
this : super(const PomodoroState()) {
|
||||
on<PomodoroEventStarted>(_onStarted);
|
||||
on<PomodoroEventSettingsChanged>((event, emit) async {
|
||||
|
|
@ -73,17 +77,28 @@ class PomodoroBloc(
|
|||
);
|
||||
}
|
||||
|
||||
final Future<bool> Function()? _stoppedOutside = stoppedOutside;
|
||||
StreamSubscription<void>? _settingsChanges;
|
||||
Timer? _phaseEnd;
|
||||
Timer? _looks;
|
||||
|
||||
Future<void> _onStarted(
|
||||
PomodoroEventStarted event,
|
||||
Emitter<PomodoroState> emit,
|
||||
) async {
|
||||
final settings = await _settings.read();
|
||||
final kept = await _runs.read();
|
||||
await _emit(emit, settings: settings.pomodoro, run: kept);
|
||||
await _catchUp(emit);
|
||||
var kept = await _runs.read();
|
||||
if (kept != null && (await _stoppedOutside?.call() ?? false)) {
|
||||
kept = null;
|
||||
await _runs.write(null);
|
||||
}
|
||||
// Caught up before it shows: a phase that ended while the app was
|
||||
// closed was alerted already.
|
||||
await _emit(
|
||||
emit,
|
||||
settings: settings.pomodoro,
|
||||
run: kept?.caughtUpTo(clock.now().toUtc()).$1,
|
||||
);
|
||||
_settingsChanges = _settings.watch().listen(
|
||||
(settings) => add(PomodoroEventSettingsChanged(settings.pomodoro)),
|
||||
// A read that failed leaves the profiles as they were.
|
||||
|
|
@ -119,18 +134,29 @@ class PomodoroBloc(
|
|||
await _runs.write(next.run);
|
||||
}
|
||||
_phaseEnd?.cancel();
|
||||
_looks?.cancel();
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
if (next.run?.endsAt case final endsAt?) {
|
||||
_phaseEnd = Timer(
|
||||
endsAt.difference(clock.now().toUtc()) +
|
||||
const Duration(milliseconds: 1),
|
||||
() => add(const PomodoroEventCaughtUp()),
|
||||
);
|
||||
// Timers sleep with the device: a phase that ended meanwhile is seen
|
||||
// to soon after it wakes.
|
||||
_looks = Timer.periodic(
|
||||
const Duration(seconds: 15),
|
||||
(_) => add(const PomodoroEventCaughtUp()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
_phaseEnd?.cancel();
|
||||
_looks?.cancel();
|
||||
await _settingsChanges?.cancel();
|
||||
await super.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,10 +18,24 @@ class AndroidPomodoroPlatform() implements PomodoroPlatform {
|
|||
@override
|
||||
Future<void> clear() => _channel.invokeMethod<void>('clear');
|
||||
|
||||
@override
|
||||
late final Stream<PomodoroAction> actions = _buttons
|
||||
late final Stream<String> _events = _buttons
|
||||
.receiveBroadcastStream()
|
||||
.map((button) => PomodoroAction.values.byName(button as String));
|
||||
.cast<String>()
|
||||
.asBroadcastStream();
|
||||
|
||||
@override
|
||||
late final Stream<PomodoroAction> actions = _events
|
||||
.where(PomodoroAction.values.asNameMap().containsKey)
|
||||
.map(PomodoroAction.values.byName);
|
||||
|
||||
@override
|
||||
late final Stream<void> phaseEnds = _events.where(
|
||||
(event) => event == 'phaseEnded',
|
||||
);
|
||||
|
||||
@override
|
||||
Future<bool> takeStoppedOutside() async =>
|
||||
await _channel.invokeMethod<bool>('takeStoppedOutside') ?? false;
|
||||
|
||||
@override
|
||||
Future<bool> mayKeepQuiet({bool ask = false}) async =>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:dbus/dbus.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_platform.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/status_notifier_item.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_run.dart';
|
||||
|
|
@ -125,8 +126,14 @@ class LinuxPomodoroPlatform(
|
|||
return (reply.values.single as DBusUint32).value;
|
||||
}
|
||||
|
||||
// One change at a time: each waits for the bus, and the next mustn't meet
|
||||
// it half done (an indicator registered twice, a notification left over).
|
||||
final _queue = SerialQueue();
|
||||
|
||||
@override
|
||||
Future<void> show(PomodoroNotice notice) async {
|
||||
Future<void> show(PomodoroNotice notice) => _queue.run(() => _show(notice));
|
||||
|
||||
Future<void> _show(PomodoroNotice notice) async {
|
||||
_listen();
|
||||
final labels = notice.actions;
|
||||
final buttons = [
|
||||
|
|
@ -198,7 +205,10 @@ class LinuxPomodoroPlatform(
|
|||
}
|
||||
_alert?.cancel();
|
||||
if (notice.endsAt case final endsAt?) {
|
||||
_alert = Timer(endsAt.difference(clock.now()), () => _alertEnd(notice));
|
||||
_alert = Timer(
|
||||
endsAt.difference(clock.now()),
|
||||
() => _queue.run(() => _alertEnd(notice)),
|
||||
);
|
||||
}
|
||||
await (notice.quiet ? _keepQuiet() : _restoreQuiet());
|
||||
}
|
||||
|
|
@ -222,7 +232,9 @@ class LinuxPomodoroPlatform(
|
|||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
Future<void> clear() => _queue.run(_clear);
|
||||
|
||||
Future<void> _clear() async {
|
||||
_alert?.cancel();
|
||||
_ticks?.cancel();
|
||||
try {
|
||||
|
|
@ -250,6 +262,14 @@ class LinuxPomodoroPlatform(
|
|||
@override
|
||||
Stream<PomodoroAction> get actions => _actions.stream;
|
||||
|
||||
// A desktop app's timer lives no longer than the app, and the bloc looks
|
||||
// again after a suspend.
|
||||
@override
|
||||
Stream<void> get phaseEnds => const Stream.empty();
|
||||
|
||||
@override
|
||||
Future<bool> takeStoppedOutside() async => false;
|
||||
|
||||
static const _banners = ['org.gnome.desktop.notifications', 'show-banners'];
|
||||
|
||||
@override
|
||||
|
|
@ -289,6 +309,8 @@ class LinuxPomodoroPlatform(
|
|||
_quietBefore.deleteSync();
|
||||
} on ProcessException catch (error) {
|
||||
log("Couldn't end the quiet", name: 'Pomodoro', error: error);
|
||||
} on FileSystemException catch (error) {
|
||||
log("Couldn't end the quiet", name: 'Pomodoro', error: error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,14 @@ abstract interface class PomodoroPlatform() {
|
|||
/// The buttons pressed outside the app.
|
||||
Stream<PomodoroAction> get actions;
|
||||
|
||||
/// The system telling that a phase ended (its alarm), which the app's own
|
||||
/// timers may have slept through with the device.
|
||||
Stream<void> get phaseEnds;
|
||||
|
||||
/// Whether the timer was stopped outside the app while the app was gone
|
||||
/// (its notification's Stop); forgets it.
|
||||
Future<bool> takeStoppedOutside();
|
||||
|
||||
/// Whether the person allowed the app to keep the device quiet; asks
|
||||
/// them (the system's settings may open) when [ask].
|
||||
Future<bool> mayKeepQuiet({bool ask = false});
|
||||
|
|
@ -120,6 +128,12 @@ class const NoPomodoroPlatform() implements PomodoroPlatform {
|
|||
@override
|
||||
Stream<PomodoroAction> get actions => const Stream.empty();
|
||||
|
||||
@override
|
||||
Stream<void> get phaseEnds => const Stream.empty();
|
||||
|
||||
@override
|
||||
Future<bool> takeStoppedOutside() async => false;
|
||||
|
||||
@override
|
||||
Future<bool> mayKeepQuiet({bool ask = false}) async => false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
|||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_run.dart';
|
||||
|
||||
/// Where this device keeps its Pomodoro timer while the app is closed: the
|
||||
|
|
@ -17,6 +18,9 @@ abstract interface class PomodoroRunStore() {
|
|||
/// Keeps the timer as JSON in [_file], in the person's folder, which goes
|
||||
/// with them; written to a temporary file first and renamed into place.
|
||||
class FilePomodoroRunStore(final File _file) implements PomodoroRunStore {
|
||||
// One write at a time: they share the temporary file.
|
||||
final _queue = SerialQueue();
|
||||
|
||||
@override
|
||||
Future<PomodoroRun?> read() async {
|
||||
try {
|
||||
|
|
@ -30,7 +34,9 @@ class FilePomodoroRunStore(final File _file) implements PomodoroRunStore {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<void> write(PomodoroRun? run) async {
|
||||
Future<void> write(PomodoroRun? run) => _queue.run(() => _write(run));
|
||||
|
||||
Future<void> _write(PomodoroRun? run) async {
|
||||
if (run == null) {
|
||||
try {
|
||||
await _file.delete();
|
||||
|
|
|
|||
|
|
@ -20,15 +20,26 @@ typedef IndicatorEntry = ({String key, String label, bool enabled});
|
|||
/// desktop without a watcher (stock GNOME without the extension) shows
|
||||
/// nothing, and [show] says so.
|
||||
class StatusNotifierItem(final DBusClient _bus, {required final String id}) {
|
||||
static const _watcher = 'org.kde.StatusNotifierWatcher';
|
||||
|
||||
late final _item = _Item(id);
|
||||
late final _menu = _Menu();
|
||||
final _name = 'org.freedesktop.StatusNotifierItem-$pid-1';
|
||||
|
||||
/// Whether the item and its menu are on the bus, under their name.
|
||||
var _exported = false;
|
||||
|
||||
/// Whether the top bar's watcher knows the item.
|
||||
var _registered = false;
|
||||
StreamSubscription<DBusNameOwnerChangedEvent>? _watcherChanges;
|
||||
|
||||
/// The keys of the menu entries chosen.
|
||||
Stream<String> get chosen => _menu.chosen.stream;
|
||||
|
||||
/// Shows the icon with [label] beside it, [title] as its name, and
|
||||
/// [entries] as its menu; whether a top bar shows it.
|
||||
/// [entries] as its menu; whether a top bar shows it. Asks the top bar's
|
||||
/// watcher again each time until one knows the item, and again when the
|
||||
/// watcher restarts (the shell, or its extension, turned off and on).
|
||||
Future<bool> show({
|
||||
required String title,
|
||||
required String label,
|
||||
|
|
@ -36,30 +47,43 @@ class StatusNotifierItem(final DBusClient _bus, {required final String id}) {
|
|||
}) async {
|
||||
await _menu.update(entries);
|
||||
await _item.update(title: title, label: label, active: true);
|
||||
if (_registered) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!_exported) {
|
||||
await _bus.registerObject(_item);
|
||||
await _bus.registerObject(_menu);
|
||||
final name = 'org.freedesktop.StatusNotifierItem-$pid-1';
|
||||
await _bus.requestName(name);
|
||||
await _bus.requestName(_name);
|
||||
_exported = true;
|
||||
_watcherChanges = _bus.nameOwnerChanged
|
||||
.where((change) => change.name == _watcher)
|
||||
.listen((change) {
|
||||
_registered = false;
|
||||
if (change.newOwner != null && _item.active) {
|
||||
unawaited(_register());
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!_registered) {
|
||||
await _register();
|
||||
}
|
||||
return _registered;
|
||||
}
|
||||
|
||||
Future<void> _register() async {
|
||||
try {
|
||||
await DBusRemoteObject(
|
||||
_bus,
|
||||
name: 'org.kde.StatusNotifierWatcher',
|
||||
name: _watcher,
|
||||
path: DBusObjectPath('/StatusNotifierWatcher'),
|
||||
).callMethod(
|
||||
'org.kde.StatusNotifierWatcher',
|
||||
_watcher,
|
||||
'RegisterStatusNotifierItem',
|
||||
[DBusString(name)],
|
||||
[DBusString(_name)],
|
||||
replySignature: DBusSignature(''),
|
||||
);
|
||||
_registered = true;
|
||||
} on DBusMethodResponseException {
|
||||
// No watcher: no top bar shows such icons here.
|
||||
return false;
|
||||
// No watcher: no top bar shows such icons here, for now.
|
||||
_registered = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Only the text beside the icon changes, as a countdown does.
|
||||
|
|
@ -67,6 +91,9 @@ class StatusNotifierItem(final DBusClient _bus, {required final String id}) {
|
|||
|
||||
/// Hides the icon.
|
||||
Future<void> hide() => _item.update(active: false);
|
||||
|
||||
/// Stops following the watcher.
|
||||
Future<void> dispose() async => await _watcherChanges?.cancel();
|
||||
}
|
||||
|
||||
const _itemInterface = 'org.kde.StatusNotifierItem';
|
||||
|
|
@ -80,6 +107,9 @@ class _Item(final String _id) extends DBusObject {
|
|||
var _label = '';
|
||||
var _active = false;
|
||||
|
||||
/// Whether the icon shows.
|
||||
bool get active => _active;
|
||||
|
||||
Future<void> update({String? title, String? label, bool? active}) async {
|
||||
if (title != null && title != _title) {
|
||||
_title = title;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_profile.dart';
|
||||
|
||||
|
|
@ -162,14 +164,24 @@ final class const PomodoroRun({
|
|||
return (run, ended);
|
||||
}
|
||||
|
||||
/// This timer on [profile] from now on; the phase under way keeps its end.
|
||||
PomodoroRun withProfile(PomodoroProfile profile) => PomodoroRun(
|
||||
profile: profile,
|
||||
phase: phase,
|
||||
focusDone: focusDone,
|
||||
endsAt: endsAt,
|
||||
left: left,
|
||||
);
|
||||
/// This timer on [profile] from now on: the phase under way keeps its
|
||||
/// end (a phase waiting at its full length takes the new one), and where
|
||||
/// the timer is stays within the new profile's cycle.
|
||||
PomodoroRun withProfile(PomodoroProfile profile) {
|
||||
final after = profile.longBreakAfter;
|
||||
return PomodoroRun(
|
||||
profile: profile,
|
||||
phase: phase,
|
||||
focusDone: switch (phase) {
|
||||
_ when !profile.hasLongBreak => phase.isBreak ? 1 : 0,
|
||||
PomodoroPhase.focus => focusDone.clamp(0, after - 1),
|
||||
PomodoroPhase.shortBreak => focusDone.clamp(1, after - 1),
|
||||
PomodoroPhase.longBreak => after,
|
||||
},
|
||||
endsAt: endsAt,
|
||||
left: !isRunning && left == length ? _lengthOf(profile, phase) : left,
|
||||
);
|
||||
}
|
||||
|
||||
/// The phases of a whole cycle of the profile: focus and short breaks up to
|
||||
/// the long break, or one focus and one break without long breaks.
|
||||
|
|
@ -188,20 +200,23 @@ final class const PomodoroRun({
|
|||
];
|
||||
|
||||
/// Where in [cycle] the timer is.
|
||||
int get indexInCycle => !profile.hasLongBreak
|
||||
? (phase.isBreak ? 1 : 0)
|
||||
: switch (phase) {
|
||||
PomodoroPhase.longBreak => cycle.length - 1,
|
||||
PomodoroPhase.focus => focusDone * 2,
|
||||
PomodoroPhase.shortBreak => focusDone * 2 - 1,
|
||||
};
|
||||
int get indexInCycle =>
|
||||
(!profile.hasLongBreak
|
||||
? (phase.isBreak ? 1 : 0)
|
||||
: switch (phase) {
|
||||
PomodoroPhase.longBreak => cycle.length - 1,
|
||||
PomodoroPhase.focus => focusDone * 2,
|
||||
PomodoroPhase.shortBreak => focusDone * 2 - 1,
|
||||
})
|
||||
.clamp(0, cycle.length - 1);
|
||||
|
||||
/// How long a phase of [cycle] lasts.
|
||||
Duration lengthIn(PomodoroPhase phase) => _lengthOf(profile, phase);
|
||||
|
||||
/// How much of [cycle] has passed at [now].
|
||||
Duration elapsedInCycleAt(DateTime now) {
|
||||
var elapsed = length - leftAt(now);
|
||||
final passed = length - leftAt(now);
|
||||
var elapsed = passed.isNegative ? Duration.zero : passed;
|
||||
for (final phase in cycle.take(indexInCycle)) {
|
||||
elapsed += _lengthOf(profile, phase);
|
||||
}
|
||||
|
|
@ -222,7 +237,7 @@ Duration _lengthOf(PomodoroProfile profile, PomodoroPhase phase) => Duration(
|
|||
|
||||
/// [left] as a countdown: minutes and seconds, hours first from an hour on.
|
||||
String formatPomodoroLeft(Duration left) {
|
||||
final seconds = (left.inMilliseconds / 1000).ceil();
|
||||
final seconds = math.max(0, (left.inMilliseconds / 1000).ceil());
|
||||
String two(int value) => value.toString().padLeft(2, '0');
|
||||
final hours = seconds ~/ 3600;
|
||||
final minutes = seconds % 3600 ~/ 60;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class _PomodoroPresenterState() extends State<PomodoroPresenter> {
|
|||
context,
|
||||
).pomodoro;
|
||||
StreamSubscription<PomodoroAction>? _actions;
|
||||
StreamSubscription<void>? _phaseEnds;
|
||||
late final AppLifecycleListener _lifecycle;
|
||||
Timer? _refresh;
|
||||
var _showing = false;
|
||||
|
|
@ -43,6 +44,9 @@ class _PomodoroPresenterState() extends State<PomodoroPresenter> {
|
|||
PomodoroAction.stop => const PomodoroEventStopRequested(),
|
||||
}),
|
||||
);
|
||||
_phaseEnds = _platform.phaseEnds.listen(
|
||||
(_) => bloc.add(const PomodoroEventCaughtUp()),
|
||||
);
|
||||
_lifecycle = AppLifecycleListener(
|
||||
onResume: () => bloc.add(const PomodoroEventCaughtUp()),
|
||||
);
|
||||
|
|
@ -52,6 +56,7 @@ class _PomodoroPresenterState() extends State<PomodoroPresenter> {
|
|||
void dispose() {
|
||||
_refresh?.cancel();
|
||||
unawaited(_actions?.cancel());
|
||||
unawaited(_phaseEnds?.cancel());
|
||||
_lifecycle.dispose();
|
||||
// A change of person takes their timer away with their app.
|
||||
if (_showing) {
|
||||
|
|
|
|||
|
|
@ -144,4 +144,56 @@ void main() {
|
|||
expect(bloc.state.settings.profiles, hasLength(2));
|
||||
});
|
||||
});
|
||||
|
||||
test('a timer kept while the app was closed shows caught up, never as it '
|
||||
'was', () {
|
||||
fakeAsync((async) {
|
||||
withClock(async.getClock(start), () {
|
||||
final settings = SettingsRepository(FakeSettingsGateway());
|
||||
final store = MemoryPomodoroRunStore(
|
||||
PomodoroRun.ready(
|
||||
PomodoroProfile.classic,
|
||||
).started(start.subtract(const Duration(hours: 1))),
|
||||
);
|
||||
final bloc = PomodoroBloc(settings, store);
|
||||
final states = <PomodoroState>[];
|
||||
bloc.stream.listen(states.add);
|
||||
bloc.add(const PomodoroEventStarted());
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(
|
||||
states.where((state) => state.run?.isRunning ?? false),
|
||||
isEmpty,
|
||||
);
|
||||
expect(states.last.run?.phase, PomodoroPhase.shortBreak);
|
||||
bloc.close();
|
||||
settings.dispose();
|
||||
async.flushMicrotasks();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('a timer stopped outside the app while it was gone is no more', () {
|
||||
fakeAsync((async) {
|
||||
withClock(async.getClock(start), () {
|
||||
final settings = SettingsRepository(FakeSettingsGateway());
|
||||
final store = MemoryPomodoroRunStore(
|
||||
PomodoroRun.ready(PomodoroProfile.classic).started(start),
|
||||
);
|
||||
final bloc = PomodoroBloc(
|
||||
settings,
|
||||
store,
|
||||
stoppedOutside: () async => true,
|
||||
)..add(const PomodoroEventStarted());
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(bloc.state.loaded, isTrue);
|
||||
expect(bloc.state.run, isNull);
|
||||
expect(store.kept, isNull);
|
||||
bloc.close();
|
||||
settings.dispose();
|
||||
async.flushMicrotasks();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,21 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('changes that come at once still make one notification', () async {
|
||||
final run = PomodoroRun.ready(PomodoroProfile.classic);
|
||||
|
||||
await Future.wait([
|
||||
platform.show(notice(run.started(DateTime.now().toUtc()))),
|
||||
platform.show(notice(run)),
|
||||
]);
|
||||
|
||||
expect(
|
||||
notifications.notified.map((call) => (call[1] as DBusUint32).value),
|
||||
[0, 1],
|
||||
reason: 'the second replaces the first',
|
||||
);
|
||||
});
|
||||
|
||||
test('hands the buttons pressed to the app', () async {
|
||||
final pressed = platform.actions.first;
|
||||
await platform.show(notice(PomodoroRun.ready(PomodoroProfile.classic)));
|
||||
|
|
@ -260,6 +275,26 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
test('the item registers again with a watcher that restarts', () async {
|
||||
var watcher = _Watcher();
|
||||
var shell = DBusClient(address);
|
||||
await shell.requestName('org.kde.StatusNotifierWatcher');
|
||||
await shell.registerObject(watcher);
|
||||
final run = PomodoroRun.ready(PomodoroProfile.classic);
|
||||
await platform.show(notice(run));
|
||||
expect(watcher.registered, hasLength(1));
|
||||
|
||||
await shell.close();
|
||||
watcher = _Watcher();
|
||||
shell = DBusClient(address);
|
||||
await shell.requestName('org.kde.StatusNotifierWatcher');
|
||||
await shell.registerObject(watcher);
|
||||
addTearDown(shell.close);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
expect(watcher.registered, hasLength(1));
|
||||
});
|
||||
|
||||
test('an icon of the timer is drawn for the top bar', () {
|
||||
final icon = timerIcon(24);
|
||||
|
||||
|
|
|
|||
|
|
@ -178,6 +178,55 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('a profile changed under way keeps where the timer is within its '
|
||||
'cycle', () {
|
||||
var run = PomodoroRun.ready(PomodoroProfile.fiftyTwoSeventeen);
|
||||
for (var i = 0; i < 10; i++) {
|
||||
run = run.next(now);
|
||||
}
|
||||
final withLongBreaks = run.withProfile(
|
||||
PomodoroProfile.fiftyTwoSeventeen.copyWith(
|
||||
longBreakMinutes: 30,
|
||||
longBreakAfter: 4,
|
||||
),
|
||||
);
|
||||
|
||||
expect(withLongBreaks.phase, PomodoroPhase.focus);
|
||||
expect(
|
||||
withLongBreaks.indexInCycle,
|
||||
lessThan(withLongBreaks.cycle.length),
|
||||
);
|
||||
expect(withLongBreaks.focusDone, lessThan(4));
|
||||
final cycleLength = withLongBreaks.cycle.fold(
|
||||
Duration.zero,
|
||||
(sum, phase) => sum + withLongBreaks.lengthIn(phase),
|
||||
);
|
||||
expect(
|
||||
withLongBreaks.elapsedInCycleAt(now),
|
||||
lessThanOrEqualTo(cycleLength),
|
||||
);
|
||||
});
|
||||
|
||||
test('a phase waiting at its full length takes the length of the changed '
|
||||
'profile; one under way keeps its end', () {
|
||||
final waiting = PomodoroRun.ready(classic);
|
||||
expect(
|
||||
waiting.withProfile(classic.copyWith(focusMinutes: 40)).leftAt(now),
|
||||
const Duration(minutes: 40),
|
||||
);
|
||||
|
||||
final running = waiting.started(now);
|
||||
expect(
|
||||
running.withProfile(classic.copyWith(focusMinutes: 40)).endsAt,
|
||||
running.endsAt,
|
||||
);
|
||||
});
|
||||
|
||||
test('a countdown past its end shows no time left', () {
|
||||
expect(formatPomodoroLeft(const Duration(seconds: -13)), '00:00');
|
||||
expect(formatPomodoroLeft(const Duration(minutes: 61)), '1:01:00');
|
||||
});
|
||||
|
||||
test('survives being kept and read back', () {
|
||||
final run = PomodoroRun.ready(classic).next(now).started(now);
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,24 @@ class FakePomodoroPlatform() implements PomodoroPlatform {
|
|||
@override
|
||||
Stream<PomodoroAction> get actions => _actions.stream;
|
||||
|
||||
final _phaseEnds = StreamController<void>.broadcast();
|
||||
|
||||
/// Tells, as the system's alarm does, that a phase ended.
|
||||
void endPhase() => _phaseEnds.add(null);
|
||||
|
||||
@override
|
||||
Stream<void> get phaseEnds => _phaseEnds.stream;
|
||||
|
||||
/// Whether the timer was stopped outside the app, as the next start asks.
|
||||
bool stoppedOutside = false;
|
||||
|
||||
@override
|
||||
Future<bool> takeStoppedOutside() async {
|
||||
final stopped = stoppedOutside;
|
||||
stoppedOutside = false;
|
||||
return stopped;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> mayKeepQuiet({bool ask = false}) async {
|
||||
if (ask) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue