feat(app): a Pomodoro timer on Home, in the notification and in the top bar

Home has a Pomodoro timer (decided 2026-10-07, after Goodtime): the
profile it runs, chosen there while no timer is set, the time left of
the phase, and Start, Pause or Resume, Skip and Stop. While studying, a
small countdown sits in the study screen's header, and a tap pauses it
or goes on. A phase that ends leads to the next, which starts by itself
where the profile says so and otherwise waits, paused at its full
length.

Profiles follow the person to every device, in their settings (schema
4, still written as schema 3 while the profiles are as they come, so
older apps go on reading the settings): Classic, 25 minutes of focus,
5 of break and 15 after four, and 52/17 without long breaks, built in;
both can be changed, more added and deleted, from Home's card or from
Settings. A profile can start breaks and focus by themselves, and keep
the device quiet while focusing. The running timer is the device's own,
kept in the person's folder: closed and opened again, the app catches
up with the phases that ran out meanwhile.

Android: the app's own foreground service (Kotlin, specialUse, as
Goodtime does) shows an ongoing notification counting the phase down,
with Pause or Resume, Skip and Stop; on Android 16 it is a Live Update,
its progress bar the profile's cycle, each phase a segment, focus in
cobalt and breaks in copper. An exact alarm (USE_EXACT_ALARM) alerts
with sound and vibration when the phase ends, also with the app
closed, and Do Not Disturb goes on while focusing once the person
allowed it, the filter before given back afterwards. No notification
plugin: flutter_local_notifications has Live Updates only in a
prerelease that needs compileSdk 37; androidx.core 1.18, which the
build resolved already, has every API needed.

Linux, over the session bus (package:dbus, already in the build): 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,
the buttons in the icon's menu, as a StatusNotifierItem written in Dart
on the names and paths the snap's desktop plug allows; elsewhere a
notification with the buttons says until when the phase runs. An alert
with sound ends each phase while the app runs, and GNOME's banners stay
off while focusing, through gsettings. xdg-desktop-portal's Background
status, the planned fallback, serves Flatpaks only.

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 02:54:52 +02:00
commit 918e10bb2b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 4517 additions and 49 deletions

View file

@ -0,0 +1,147 @@
import 'package:clock/clock.dart';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/bloc/pomodoro_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_profile.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_run.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import '../../../support/fake_settings_gateway.dart';
import '../../../support/memory_pomodoro_run_store.dart';
void main() {
final start = DateTime.utc(2026, 10, 8, 9);
/// Runs [body] with a bloc over fresh settings and [kept] as the timer the
/// device kept, in fake time from [start].
void withBloc(
void Function(
FakeAsync async,
PomodoroBloc bloc,
SettingsRepository settings,
MemoryPomodoroRunStore store,
)
body, {
PomodoroRun? kept,
}) => fakeAsync((async) {
withClock(async.getClock(start), () {
final settings = SettingsRepository(FakeSettingsGateway());
final store = MemoryPomodoroRunStore(kept);
final bloc = PomodoroBloc(settings, store)
..add(const PomodoroEventStarted());
async.flushMicrotasks();
body(async, bloc, settings, store);
bloc.close();
settings.dispose();
async.flushMicrotasks();
});
});
test("starts with the person's chosen profile and no timer", () {
withBloc((async, bloc, settings, store) {
expect(bloc.state.loaded, isTrue);
expect(bloc.state.settings.selected, PomodoroProfile.classic);
expect(bloc.state.run, isNull);
});
});
test('start runs the focus; pause keeps what is left; resume goes on', () {
withBloc((async, bloc, settings, store) {
bloc.add(const PomodoroEventStartRequested());
async.flushMicrotasks();
expect(bloc.state.run!.isRunning, isTrue);
expect(bloc.state.run!.phase, PomodoroPhase.focus);
expect(store.kept, bloc.state.run, reason: 'kept on the device');
async.elapse(const Duration(minutes: 10));
bloc.add(const PomodoroEventPauseRequested());
async.flushMicrotasks();
expect(bloc.state.run!.isRunning, isFalse);
expect(
bloc.state.run!.leftAt(clock.now()),
const Duration(minutes: 15),
);
async.elapse(const Duration(hours: 1));
bloc.add(const PomodoroEventResumeRequested());
async.flushMicrotasks();
expect(
bloc.state.run!.endsAt,
clock.now().add(const Duration(minutes: 15)),
);
});
});
test('when the focus ends, the break waits to be started', () {
withBloc((async, bloc, settings, store) {
bloc.add(const PomodoroEventStartRequested());
async
..flushMicrotasks()
..elapse(const Duration(minutes: 25, seconds: 1));
expect(bloc.state.run!.phase, PomodoroPhase.shortBreak);
expect(bloc.state.run!.isRunning, isFalse);
});
});
test('skip goes to the next phase, and stop forgets the timer', () {
withBloc((async, bloc, settings, store) {
bloc.add(const PomodoroEventStartRequested());
async.flushMicrotasks();
bloc.add(const PomodoroEventSkipRequested());
async.flushMicrotasks();
expect(bloc.state.run!.phase, PomodoroPhase.shortBreak);
bloc.add(const PomodoroEventStopRequested());
async.flushMicrotasks();
expect(bloc.state.run, isNull);
expect(store.kept, isNull);
});
});
test('a timer the device kept catches up when the app opens', () {
withBloc(
(async, bloc, settings, store) {
expect(bloc.state.run!.phase, PomodoroPhase.shortBreak);
expect(bloc.state.run!.isRunning, isFalse);
},
kept: PomodoroRun.ready(
PomodoroProfile.classic,
).started(start.subtract(const Duration(hours: 1))),
);
});
test('a change to the profile running applies from now on', () {
withBloc((async, bloc, settings, store) {
bloc.add(const PomodoroEventStartRequested());
async.flushMicrotasks();
bloc.add(
PomodoroEventProfileSaved(
PomodoroProfile.classic.copyWith(shortBreakMinutes: 10),
),
);
async.flushMicrotasks();
bloc.add(const PomodoroEventSkipRequested());
async.flushMicrotasks();
expect(bloc.state.run!.length, const Duration(minutes: 10));
});
});
test("adds, chooses and deletes profiles in the person's settings", () {
withBloc((async, bloc, settings, store) {
const mine = PomodoroProfile(id: 'mine', name: 'Mine');
bloc
..add(const PomodoroEventProfileSaved(mine))
..add(const PomodoroEventProfileChosen('mine'));
async.flushMicrotasks();
expect(bloc.state.settings.selected, mine);
bloc.add(const PomodoroEventProfileDeleted('mine'));
async.flushMicrotasks();
expect(bloc.state.settings.selected, PomodoroProfile.classic);
expect(bloc.state.settings.profiles, hasLength(2));
});
});
}

View file

@ -0,0 +1,64 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/android_pomodoro_platform.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_profile.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_run.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_texts.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:material_ui/material_ui.dart';
void main() {
// The app has them through Material's localizations.
setUpAll(initializeDateFormatting);
final now = DateTime.utc(2026, 10, 8, 9);
final german = lookupAppLocalizations(const Locale('de'));
test("hands the service a notice in the app's language, the cycle as "
'segments and its progress', () {
const quietProfile = PomodoroProfile(id: 'classic', doNotDisturb: true);
final run = PomodoroRun.ready(quietProfile).started(now);
final json = noticeJson(
german.pomodoroNotice(run, now.add(const Duration(minutes: 5))),
);
expect(json['title'], 'Fokus 1 von 4');
expect(json['text'], 'Klassisch');
expect(
json['endsAt'],
now.add(const Duration(minutes: 25)).millisecondsSinceEpoch,
);
expect(json['elapsedMs'], const Duration(minutes: 5).inMilliseconds);
expect(json['index'], 0);
expect((json['cycle']! as List).first, {
'phase': 'focus',
'lengthMs': const Duration(minutes: 25).inMilliseconds,
});
expect((json['cycle']! as List).last, {
'phase': 'longBreak',
'lengthMs': const Duration(minutes: 15).inMilliseconds,
});
expect(json['actions'], {
'pause': 'Pausieren',
'resume': 'Fortsetzen',
'skip': 'Überspringen',
'stop': 'Beenden',
});
expect(json['alertTitle'], 'Zeit für eine Pause');
expect(json['alertText'], 'Kurze Pause wartet auf dich.');
expect(json['quiet'], isTrue);
expect(json['timerName'], 'Pomodoro-Timer');
});
test('a paused timer has no end, and keeps quiet no longer', () {
const quietProfile = PomodoroProfile(id: 'classic', doNotDisturb: true);
final run = PomodoroRun.ready(quietProfile);
final json = noticeJson(german.pomodoroNotice(run, now));
expect(json['endsAt'], isNull);
expect(json['leftMs'], const Duration(minutes: 25).inMilliseconds);
expect(json['text'], 'Pausiert · Klassisch');
expect(json['quiet'], isFalse);
});
}

View file

@ -0,0 +1,271 @@
import 'dart:io';
import 'package:dbus/dbus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.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/status_notifier_item.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_profile.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_run.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_texts.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:material_ui/material_ui.dart';
/// A notification server, as GNOME's, noting what it was asked.
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();
}
return DBusMethodErrorResponse.unknownMethod();
}
}
/// The top bar's watcher, as GNOME's AppIndicator extension runs it.
class _Watcher() extends DBusObject {
this : super(DBusObjectPath('/StatusNotifierWatcher'));
final registered = <String>[];
@override
Future<DBusMethodResponse> handleMethodCall(DBusMethodCall call) async {
if (call.name == 'RegisterStatusNotifierItem') {
registered.add((call.values.single as DBusString).value);
return DBusMethodSuccessResponse();
}
return DBusMethodErrorResponse.unknownMethod();
}
}
void main() {
// The app has them through Material's localizations.
setUpAll(initializeDateFormatting);
final english = lookupAppLocalizations(const Locale('en', 'GB'));
late DBusServer server;
late DBusAddress address;
late DBusClient serverSide;
late DBusClient app;
late _Notifications notifications;
late Directory folder;
late List<List<String>> commands;
late String banners;
late LinuxPomodoroPlatform platform;
setUp(() async {
server = DBusServer();
address = await server.listenAddress(
DBusAddress.unix(dir: Directory.systemTemp),
);
serverSide = DBusClient(address);
notifications = _Notifications();
await serverSide.requestName('org.freedesktop.Notifications');
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', '');
},
);
});
tearDown(() async {
await app.close();
await serverSide.close();
await server.close();
await folder.delete(recursive: true);
});
PomodoroNotice notice(PomodoroRun run) =>
english.pomodoroNotice(run, DateTime.now().toUtc());
test('shows the timer with its buttons, and updates the same '
'notification', () 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'),
);
});
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(
profile: short,
phase: PomodoroPhase.focus,
endsAt: DateTime.now().toUtc().add(const Duration(milliseconds: 200)),
);
await platform.show(notice(run));
await Future<void>.delayed(const Duration(milliseconds: 600));
final alert = notifications.notified.last;
expect((alert[3] as DBusString).value, 'Time for a break');
expect(
(alert[6] as DBusDict).children[const DBusString('sound-name')],
const DBusVariant(DBusString('complete')),
);
});
group('where the top bar shows icons', () {
late _Watcher watcher;
late DBusClient shell;
setUp(() async {
watcher = _Watcher();
shell = DBusClient(address);
await shell.requestName('org.kde.StatusNotifierWatcher');
await shell.registerObject(watcher);
});
tearDown(() => shell.close());
test('the timer counts down there, its buttons in its menu, and no '
'notification shows', () async {
final run = PomodoroRun.ready(
PomodoroProfile.classic,
).started(DateTime.now().toUtc());
final pressed = platform.actions.first;
await platform.show(notice(run));
final [name] = watcher.registered;
expect(name, startsWith('org.freedesktop.StatusNotifierItem-'));
expect(notifications.notified, isEmpty);
final item = DBusRemoteObject(
shell,
name: name,
path: DBusObjectPath('/StatusNotifierItem'),
);
expect(
await item.getProperty('org.kde.StatusNotifierItem', 'XAyatanaLabel'),
isA<DBusString>().having((label) => label.value, 'value', '25:00'),
);
final menu = DBusRemoteObject(
shell,
name: name,
path: DBusObjectPath('/MenuBar'),
);
final layout = await menu.callMethod(
'com.canonical.dbusmenu',
'GetLayout',
[const DBusInt32(0), const DBusInt32(-1), DBusArray.string(const [])],
replySignature: DBusSignature('u(ia{sv}av)'),
);
final children =
((layout.values[1] as DBusStruct).children[2] as DBusArray).children;
String labelOf(DBusValue child) =>
(((child as DBusVariant).value as DBusStruct).children[1] as DBusDict)
.children[const DBusString('label')]!
.asVariant()
.asString();
expect(children.map(labelOf), [
'Focus 1 of 4 · Classic',
'Pause',
'Skip',
'Stop',
]);
await menu.callMethod('com.canonical.dbusmenu', 'Event', [
const DBusInt32(3),
const DBusString('clicked'),
const DBusVariant(DBusString('')),
const DBusUint32(0),
], replySignature: DBusSignature(''));
expect(
await pressed.timeout(const Duration(seconds: 5)),
PomodoroAction.skip,
);
await platform.clear();
expect(
await item.getProperty('org.kde.StatusNotifierItem', 'Status'),
const DBusString('Passive'),
);
});
});
test('an icon of the timer is drawn for the top bar', () {
final icon = timerIcon(24);
expect(icon, hasLength(24 * 24 * 4));
// The ring's top and the centre: ink and none.
expect(icon[(6 * 24 + 12) * 4], greaterThan(200));
expect(icon[(16 * 24 + 9) * 4], 0);
});
}

View file

@ -0,0 +1,36 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_run_store.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_profile.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_run.dart';
void main() {
late Directory folder;
late File file;
setUp(() async {
folder = await Directory.systemTemp.createTemp('pomodoro_run_test');
file = File('${folder.path}/pomodoro.json');
});
tearDown(() => folder.delete(recursive: true));
test('keeps a timer until it is forgotten', () async {
final run = PomodoroRun.ready(
PomodoroProfile.classic,
).started(DateTime.utc(2026, 10, 8, 9));
await FilePomodoroRunStore(file).write(run);
expect(await FilePomodoroRunStore(file).read(), run);
await FilePomodoroRunStore(file).write(null);
expect(await FilePomodoroRunStore(file).read(), isNull);
await FilePomodoroRunStore(file).write(null);
});
test('a damaged file is no timer', () async {
await file.writeAsString('{"phase": 3}');
expect(await FilePomodoroRunStore(file).read(), isNull);
});
}

View file

@ -0,0 +1,187 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_profile.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_run.dart';
void main() {
final now = DateTime.utc(2026, 10, 8, 9);
const classic = PomodoroProfile.classic;
group('the built-in profiles', () {
test('Classic: 25 minutes of focus, 5 of break, 15 after four', () {
expect(classic.focusMinutes, 25);
expect(classic.shortBreakMinutes, 5);
expect(classic.longBreakMinutes, 15);
expect(classic.longBreakAfter, 4);
expect(classic.autoStartFocus, isFalse);
expect(classic.autoStartBreaks, isFalse);
expect(classic.doNotDisturb, isFalse);
});
test('52/17 has no long break', () {
expect(PomodoroProfile.fiftyTwoSeventeen.focusMinutes, 52);
expect(PomodoroProfile.fiftyTwoSeventeen.shortBreakMinutes, 17);
expect(PomodoroProfile.fiftyTwoSeventeen.hasLongBreak, isFalse);
});
test('are what a person starts with, Classic chosen', () {
const settings = PomodoroSettings();
expect(settings.profiles, [
PomodoroProfile.classic,
PomodoroProfile.fiftyTwoSeventeen,
]);
expect(settings.selected, PomodoroProfile.classic);
});
test('a chosen profile that is gone falls back to the first', () {
const settings = PomodoroSettings(selectedId: 'gone');
expect(settings.selected, PomodoroProfile.classic);
});
});
group('a run', () {
test('starts ready, paused at the full focus', () {
final run = PomodoroRun.ready(classic);
expect(run.phase, PomodoroPhase.focus);
expect(run.isRunning, isFalse);
expect(run.leftAt(now), const Duration(minutes: 25));
});
test('counts down while running, and keeps what is left while paused', () {
final running = PomodoroRun.ready(classic).started(now);
final later = now.add(const Duration(minutes: 10));
expect(running.isRunning, isTrue);
expect(running.endsAt, now.add(const Duration(minutes: 25)));
expect(running.leftAt(later), const Duration(minutes: 15));
final paused = running.paused(later);
expect(paused.isRunning, isFalse);
expect(
paused.leftAt(later.add(const Duration(hours: 1))),
const Duration(minutes: 15),
);
expect(
paused.started(later).endsAt,
later.add(const Duration(minutes: 15)),
);
});
test('after a focus comes a short break, and after four a long one', () {
var run = PomodoroRun.ready(classic);
final phases = <PomodoroPhase>[];
for (var i = 0; i < 9; i++) {
run = run.next(now);
phases.add(run.phase);
}
expect(phases, [
PomodoroPhase.shortBreak,
PomodoroPhase.focus,
PomodoroPhase.shortBreak,
PomodoroPhase.focus,
PomodoroPhase.shortBreak,
PomodoroPhase.focus,
PomodoroPhase.longBreak,
PomodoroPhase.focus,
PomodoroPhase.shortBreak,
]);
});
test('a profile without a long break only ever takes short ones', () {
var run = PomodoroRun.ready(PomodoroProfile.fiftyTwoSeventeen);
for (var i = 0; i < 10; i++) {
run = run.next(now);
expect(run.phase, isNot(PomodoroPhase.longBreak));
}
});
test('the next phase waits, paused at its full length, unless the '
'profile starts it by itself', () {
final waiting = PomodoroRun.ready(classic).started(now).next(now);
expect(waiting.isRunning, isFalse);
expect(waiting.leftAt(now), const Duration(minutes: 5));
const breaksGoOn = PomodoroProfile(
id: 'auto',
autoStartBreaks: true,
);
final going = PomodoroRun.ready(breaksGoOn).started(now).next(now);
expect(going.isRunning, isTrue);
expect(going.endsAt, now.add(const Duration(minutes: 5)));
expect(going.next(now).isRunning, isFalse, reason: 'focus waits');
});
test('catching up ends the phases that ran out meanwhile, each from '
'when the last one ended', () {
const allGoOn = PomodoroProfile(
id: 'auto',
autoStartFocus: true,
autoStartBreaks: true,
);
final run = PomodoroRun.ready(allGoOn).started(now);
// 25 + 5 + 25 minutes later, and 2 more into the second break.
final (caughtUp, ended) = run.caughtUpTo(
now.add(const Duration(minutes: 57)),
);
expect(ended, PomodoroPhase.focus);
expect(caughtUp.phase, PomodoroPhase.shortBreak);
expect(
caughtUp.leftAt(now.add(const Duration(minutes: 57))),
const Duration(minutes: 3),
);
});
test('catching up stops at a phase that waits', () {
final run = PomodoroRun.ready(classic).started(now);
final (caughtUp, ended) = run.caughtUpTo(
now.add(const Duration(hours: 3)),
);
expect(ended, PomodoroPhase.focus);
expect(caughtUp.phase, PomodoroPhase.shortBreak);
expect(caughtUp.isRunning, isFalse);
});
test('catching up before the end changes nothing', () {
final run = PomodoroRun.ready(classic).started(now);
expect(run.caughtUpTo(now.add(const Duration(minutes: 3))), (
run,
null,
));
});
test("shows where it is in the profile's cycle", () {
final run = PomodoroRun.ready(classic).next(now).next(now);
expect(run.cycle, [
PomodoroPhase.focus,
PomodoroPhase.shortBreak,
PomodoroPhase.focus,
PomodoroPhase.shortBreak,
PomodoroPhase.focus,
PomodoroPhase.shortBreak,
PomodoroPhase.focus,
PomodoroPhase.longBreak,
]);
expect(run.indexInCycle, 2);
expect(
run.elapsedInCycleAt(now),
const Duration(minutes: 30),
reason: 'a focus and a break done',
);
});
test('survives being kept and read back', () {
final run = PomodoroRun.ready(classic).next(now).started(now);
expect(PomodoroRun.fromJson(run.toJson()), run);
});
});
}

View file

@ -0,0 +1,188 @@
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_platform.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_profile.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_run.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_chip.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_pomodoro_platform.dart';
import '../../../support/fake_settings_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/memory_pomodoro_run_store.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
void main() {
late FakePomodoroPlatform platform;
setUp(() => platform = FakePomodoroPlatform());
Future<void> showHome(WidgetTester tester) async {
tester.view.physicalSize = const Size(1000, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
pomodoro: platform,
settingsGateway: FakeSettingsGateway(),
),
);
}
Future<void> tap(WidgetTester tester, Finder finder) async {
await tester.tap(finder);
await tester.pump();
await tester.pump();
}
testWidgets('Home starts the chosen profile, and the system shows the '
'timer', (tester) async {
await showHome(tester);
expect(find.text('Pomodoro'), findsOneWidget);
expect(find.text('Classic'), findsOneWidget);
expect(find.text('25:00'), findsOneWidget);
await tap(tester, find.text('Start'));
expect(find.text('Pause'), findsOneWidget);
expect(find.text('Skip'), findsOneWidget);
expect(find.text('Stop'), findsOneWidget);
expect(platform.showing?.title, 'Focus 1 of 4');
expect(platform.showing?.text, 'Classic');
expect(platform.showing?.isRunning, isTrue);
expect(platform.showing?.alertTitle, 'Time for a break');
expect(
platform.showing?.alertText,
'Short break is ready when you are.',
);
await tester.pump(const Duration(seconds: 3));
expect(find.text('24:57'), findsOneWidget, reason: 'counting down');
});
testWidgets("the system's buttons act on the timer", (tester) async {
await showHome(tester);
await tap(tester, find.text('Start'));
platform.press(PomodoroAction.pause);
await tester.pump();
await tester.pump();
expect(find.text('Resume'), findsOneWidget);
expect(platform.showing?.isRunning, isFalse);
expect(platform.showing?.text, 'Paused · Classic');
platform.press(PomodoroAction.skip);
await tester.pump();
await tester.pump();
expect(platform.showing?.phase, PomodoroPhase.shortBreak);
platform.press(PomodoroAction.stop);
await tester.pump();
await tester.pump();
expect(find.text('Start'), findsOneWidget);
expect(platform.showing, isNull);
});
testWidgets('another profile is chosen on Home', (tester) async {
await showHome(tester);
await tester.tap(find.text('Classic'));
await tester.pumpAndSettle();
await tester.tap(find.text('52/17').last);
await tester.pumpAndSettle();
expect(find.text('52:00'), findsOneWidget);
expect(find.text('52 min focus, 17 min break'), findsOneWidget);
});
testWidgets('a profile is added, kept and chosen', (tester) async {
await showHome(tester);
await tester.tap(find.byTooltip('Pomodoro profiles'));
await tester.pumpAndSettle();
await tester.tap(find.text('Add a profile'));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Exams');
await tester.tap(find.byTooltip('More').first);
await tester.pump();
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('Exams'), findsOneWidget);
expect(
find.text('30 min focus, 5 min break, 15 min after 4'),
findsOneWidget,
);
await tester.tap(find.text('Exams'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
expect(find.text('30:00'), findsOneWidget);
});
testWidgets('keeping quiet while focusing asks for the permission first', (
tester,
) async {
await showHome(tester);
await tester.tap(find.byTooltip('Pomodoro profiles'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Edit profile').first);
await tester.pumpAndSettle();
final quiet = find.widgetWithText(
SwitchListTile,
'Do Not Disturb while focusing',
);
await tester.ensureVisible(quiet);
await tester.pumpAndSettle();
await tester.tap(quiet);
await tester.pumpAndSettle();
expect(platform.askedForQuiet, 1);
expect(tester.widget<SwitchListTile>(quiet).value, isFalse);
expect(
find.textContaining('may not keep this device quiet'),
findsOneWidget,
);
platform.allowsQuiet = true;
await tester.tap(quiet);
await tester.pumpAndSettle();
expect(tester.widget<SwitchListTile>(quiet).value, isTrue);
});
testWidgets('the timer shows while studying, and a tap pauses it', (
tester,
) async {
final now = clock.now().toUtc();
await pumpApp(
tester,
testDependencies(
pomodoro: platform,
studysetGateway: FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
),
pomodoroRuns: MemoryPomodoroRunStore(
PomodoroRun.ready(PomodoroProfile.classic).started(now),
),
),
location: RoutePaths.study('japanese'),
);
final chip = find.byType(PomodoroChip);
expect(
find.descendant(of: chip, matching: find.textContaining(':')),
findsOneWidget,
);
await tester.tap(chip);
await tester.pump();
await tester.pump();
expect(platform.showing?.isRunning, isFalse);
});
}