chore: Halkyon Learn moves into apps/learn

Its server, client, app and curator, and the tools that write its
assets (maps, Anki fixtures, icons), as they are; paths are fixed in
the commits that follow.

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-19 20:08:06 +02:00
commit 649499be23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,223 @@
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('a timer on a profile deleted goes on with the one chosen then', () {
withBloc((async, bloc, settings, store) {
const mine = PomodoroProfile(
id: 'mine',
name: 'Mine',
shortBreakMinutes: 9,
);
bloc
..add(const PomodoroEventProfileSaved(mine))
..add(const PomodoroEventProfileChosen('mine'));
async.flushMicrotasks();
bloc.add(const PomodoroEventStartRequested());
async.flushMicrotasks();
expect(bloc.state.run!.profile, mine);
bloc.add(const PomodoroEventProfileDeleted('mine'));
async.flushMicrotasks();
expect(bloc.state.settings.selected, PomodoroProfile.classic);
expect(bloc.state.run!.profile, PomodoroProfile.classic);
expect(bloc.state.run!.isRunning, isTrue, reason: 'it goes on');
});
});
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));
});
});
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();
});
});
});
}

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,366 @@
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>>[];
@override
Future<DBusMethodResponse> handleMethodCall(DBusMethodCall call) async {
if (call.name == 'Notify') {
notified.add(call.values);
return DBusMethodSuccessResponse([DBusUint32(notified.length)]);
}
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();
}
}
/// 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);
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 String banners;
late List<Uri> opened;
late LinuxPomodoroPlatform platform;
LinuxPomodoroPlatform newPlatform() => LinuxPomodoroPlatform(
app,
folder,
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(
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');
banners = 'true';
opened = [];
platform = newPlatform();
});
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());
/// 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));
expect(notifications.notified, isEmpty);
});
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');
});
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.single;
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, shell) = await startWatcher());
tearDown(() => shell.close());
test('the timer counts down there, its buttons in its menu', () async {
final run = PomodoroRun.ready(
PomodoroProfile.classic,
).started(DateTime.now().toUtc());
final pressed = platform.actions.first;
await platform.show(notice(run));
// Registered by its path, as libayatana-appindicator does: the snap's
// rules let the item bind no name, and send the label's signal only
// from there.
final [registered] = watcher.registered;
expect(registered, '/org/ayatana/NotificationItem/halkyon_learn');
final name = app.uniqueName;
expect(notifications.notified, isEmpty);
final item = DBusRemoteObject(
shell,
name: name,
path: DBusObjectPath(registered),
);
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('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);
});
});
group('without a top bar for it', () {
late DBusClient gnomeShell;
setUp(() async {
gnomeShell = DBusClient(address);
await gnomeShell.requestName('org.gnome.Shell');
});
tearDown(() => gnomeShell.close());
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 platform.dismissTopBarSuggestion();
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(suggestions, [true, false]);
expect(await newPlatform().suggestsTopBar.first, isFalse);
});
test('suggests nothing elsewhere than on GNOME', () async {
await gnomeShell.close();
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(await newPlatform().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', () {
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,250 @@
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('deleting the chosen profile chooses the first one left', () {
const mine = PomodoroProfile(id: 'mine');
const settings = PomodoroSettings(
profiles: [PomodoroProfile.classic, mine],
selectedId: 'mine',
);
expect(settings.withoutProfile('mine').selectedId, 'classic');
expect(
settings.withSelected('classic').withoutProfile('mine').selectedId,
'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('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);
expect(PomodoroRun.fromJson(run.toJson()), run);
});
});
}

View file

@ -0,0 +1,298 @@
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.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('Increase').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("allowed in the system's settings, keeping quiet switches on "
'by itself on coming back', (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(tester.widget<SwitchListTile>(quiet).value, isFalse);
// The person allows it in the system's settings, and comes back.
platform.allowsQuiet = true;
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpAndSettle();
expect(tester.widget<SwitchListTile>(quiet).value, isTrue);
expect(find.textContaining('may not keep this device quiet'), findsNothing);
expect(platform.askedForQuiet, 1, reason: 'asked once, not again');
});
testWidgets('coming back without allowing it leaves keeping quiet off', (
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();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpAndSettle();
expect(tester.widget<SwitchListTile>(quiet).value, isFalse);
});
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, only to watch', (
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,
);
// Only to watch: a tap leaves the timer running.
await tester.tap(chip);
await tester.pump();
await tester.pump();
expect(platform.showing?.isRunning, isTrue);
});
testWidgets('the timer stays out of studying where the device says so', (
tester,
) async {
final now = clock.now().toUtc();
await pumpApp(
tester,
testDependencies(
pomodoro: platform,
deviceSettings: const DeviceSettings(pomodoroWhileStudying: false),
studysetGateway: FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
),
pomodoroRuns: MemoryPomodoroRunStore(
PomodoroRun.ready(PomodoroProfile.classic).started(now),
),
),
location: RoutePaths.study('japanese'),
);
expect(
find.descendant(
of: find.byType(PomodoroChip),
matching: find.textContaining(':'),
),
findsNothing,
);
});
}