feat(app): choose whether studying shows the Pomodoro timer

Settings has a Study card: the Pomodoro profiles, and a switch "Show
the Pomodoro timer while studying" (on unless switched off). Like the
appearance, it is the device's own setting.

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 21:10:33 +02:00
commit b72a96f810
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 216 additions and 12 deletions

View file

@ -287,6 +287,9 @@
"settingsCitationStyleChicago": "Chicago (18. Auflage)",
"settingsCitationPreview": "Vorschau",
"settingsCitationGuide": "Offizieller Leitfaden",
"settingsStudy": "Lernen",
"settingsStudyPomodoroTitle": "Pomodoro-Timer beim Lernen anzeigen",
"settingsStudyPomodoroSubtitle": "Zeige den Fokus-/Pausen-Countdown in der Lernsitzung, damit du die verbleibende Zeit siehst, ohne die Benachrichtigung zu öffnen.",
"settingsVersion": "Version {version} (Build {build})",
"syncTitle": "Synchronisierung",
"syncStateSyncing": "Wird synchronisiert…",

View file

@ -312,6 +312,9 @@
"settingsCitationStyleChicago": "Chicago (18th edition)",
"settingsCitationPreview": "Preview",
"settingsCitationGuide": "Official guide",
"settingsStudy": "Study",
"settingsStudyPomodoroTitle": "Show the Pomodoro timer while studying",
"settingsStudyPomodoroSubtitle": "Show the focus / break countdown inside the study session, so you can see the time left without opening the notification.",
"settingsVersion": "Version {version} (build {build})",
"@settingsVersion": {"placeholders": {"version": {"type": "String"}, "build": {"type": "String"}}},
"syncTitle": "Sync",

View file

@ -1762,6 +1762,24 @@ abstract class AppLocalizations {
/// **'Official guide'**
String get settingsCitationGuide;
/// No description provided for @settingsStudy.
///
/// In en, this message translates to:
/// **'Study'**
String get settingsStudy;
/// No description provided for @settingsStudyPomodoroTitle.
///
/// In en, this message translates to:
/// **'Show the Pomodoro timer while studying'**
String get settingsStudyPomodoroTitle;
/// No description provided for @settingsStudyPomodoroSubtitle.
///
/// In en, this message translates to:
/// **'Show the focus / break countdown inside the study session, so you can see the time left without opening the notification.'**
String get settingsStudyPomodoroSubtitle;
/// No description provided for @settingsVersion.
///
/// In en, this message translates to:

View file

@ -1009,6 +1009,17 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settingsCitationGuide => 'Offizieller Leitfaden';
@override
String get settingsStudy => 'Lernen';
@override
String get settingsStudyPomodoroTitle =>
'Pomodoro-Timer beim Lernen anzeigen';
@override
String get settingsStudyPomodoroSubtitle =>
'Zeige den Fokus-/Pausen-Countdown in der Lernsitzung, damit du die verbleibende Zeit siehst, ohne die Benachrichtigung zu öffnen.';
@override
String settingsVersion(String version, String build) {
return 'Version $version (Build $build)';

View file

@ -1002,6 +1002,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsCitationGuide => 'Official guide';
@override
String get settingsStudy => 'Study';
@override
String get settingsStudyPomodoroTitle =>
'Show the Pomodoro timer while studying';
@override
String get settingsStudyPomodoroSubtitle =>
'Show the focus / break countdown inside the study session, so you can see the time left without opening the notification.';
@override
String settingsVersion(String version, String build) {
return 'Version $version (build $build)';

View file

@ -23,6 +23,10 @@ class DeviceSettingsBloc(
on<DeviceSettingsEventCitationStyleChosen>(
(event, emit) => _apply(emit, state.withCitationStyle(event.style)),
);
on<DeviceSettingsEventPomodoroWhileStudyingChosen>(
(event, emit) =>
_apply(emit, state.withPomodoroWhileStudying(event.shown)),
);
}
Future<void> _apply(
@ -65,3 +69,11 @@ final class const DeviceSettingsEventCitationStyleChosen(
@override
List<Object?> get props => [style];
}
/// The person chose whether studying shows the Pomodoro timer.
final class const DeviceSettingsEventPomodoroWhileStudyingChosen(
final bool shown,
) extends DeviceSettingsEvent {
@override
List<Object?> get props => [shown];
}

View file

@ -46,6 +46,7 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
citationStyle:
CitationStyle.values.asNameMap()[json['citationStyle']] ??
CitationStyle.apa,
pomodoroWhileStudying: json['pomodoroWhileStudying'] != false,
);
} on FormatException catch (error) {
log('The device settings are unreadable', name: 'Settings', error: error);
@ -62,6 +63,7 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
'appearance': settings.appearance.name,
'language': ?settings.language,
'citationStyle': settings.citationStyle.name,
'pomodoroWhileStudying': settings.pomodoroWhileStudying,
}),
flush: true,
);

View file

@ -9,7 +9,8 @@ enum Appearance() {
}
/// The settings of one device, which stay on it (decided 2026-10-07): how the
/// app looks, the language it speaks and the style it cites sources in.
/// app looks, the language it speaks, the style it cites sources in, and
/// whether studying shows the Pomodoro timer.
/// Unlike the person's settings, they don't follow anyone to other devices,
/// and they apply before anyone is signed in.
final class const DeviceSettings({
@ -21,12 +22,16 @@ final class const DeviceSettings({
/// How sources and bibliographies are cited.
final CitationStyle citationStyle = CitationStyle.apa,
/// Whether a study session shows the Pomodoro timer's countdown.
final bool pomodoroWhileStudying = true,
}) with Equatable {
/// These settings with [appearance] instead.
DeviceSettings withAppearance(Appearance appearance) => DeviceSettings(
appearance: appearance,
language: language,
citationStyle: citationStyle,
pomodoroWhileStudying: pomodoroWhileStudying,
);
/// These settings with [language] instead; null for the device's.
@ -34,6 +39,7 @@ final class const DeviceSettings({
appearance: appearance,
language: language,
citationStyle: citationStyle,
pomodoroWhileStudying: pomodoroWhileStudying,
);
/// These settings with [citationStyle] instead.
@ -42,8 +48,22 @@ final class const DeviceSettings({
appearance: appearance,
language: language,
citationStyle: citationStyle,
pomodoroWhileStudying: pomodoroWhileStudying,
);
/// These settings with [shown] for the Pomodoro timer while studying.
DeviceSettings withPomodoroWhileStudying(bool shown) => DeviceSettings(
appearance: appearance,
language: language,
citationStyle: citationStyle,
pomodoroWhileStudying: shown,
);
@override
List<Object?> get props => [appearance, language, citationStyle];
List<Object?> get props => [
appearance,
language,
citationStyle,
pomodoroWhileStudying,
];
}

View file

@ -1,4 +1,5 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/bloc/pomodoro_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_card.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -7,12 +8,16 @@ import 'package:material_ui/material_ui.dart';
/// The Pomodoro timer, small, while studying: the time left of its phase
/// beside a timer (or a pause while paused), in the primary colour while
/// focusing and teal on a break. Only to watch: the timer is worked from its
/// card and its notification. Nothing while no timer is set.
/// card and its notification. Nothing while no timer is set, or where the
/// device's settings leave it out of studying.
class const PomodoroChip({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final run = context.watch<PomodoroBloc>().state.run;
if (run == null) {
final shown = context.select<DeviceSettingsBloc, bool>(
(bloc) => bloc.state.pomodoroWhileStudying,
);
if (run == null || !shown) {
return const SizedBox.shrink();
}
final theme = Theme.of(context);

View file

@ -1,6 +1,7 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_status_card.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
@ -39,14 +40,7 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
const SizedBox(height: 12),
const CitationSettingsCard(),
const SizedBox(height: 12),
SectionCard(
padding: const EdgeInsets.symmetric(vertical: 8),
child: ListTile(
leading: const Icon(LucideIcons.timer),
title: Text(l10n.pomodoroProfiles),
onTap: () => showPomodoroProfilesSheet(context),
),
),
const _StudyCard(),
const SizedBox(height: 12),
SectionCard(
padding: const EdgeInsets.symmetric(vertical: 8),
@ -154,3 +148,44 @@ Future<void> _delete(BuildContext context) async {
session.add(const SessionEventDeleteRequested());
}
}
/// How studying goes on this device: the Pomodoro profiles, and whether a
/// session shows the timer.
class const _StudyCard() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return SectionCard(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.settingsStudy,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.timer),
title: Text(l10n.pomodoroProfiles),
onTap: () => showPomodoroProfilesSheet(context),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.settingsStudyPomodoroTitle),
subtitle: Text(l10n.settingsStudyPomodoroSubtitle),
value: context.select<DeviceSettingsBloc, bool>(
(bloc) => bloc.state.pomodoroWhileStudying,
),
onChanged: (shown) => context.read<DeviceSettingsBloc>().add(
DeviceSettingsEventPomodoroWhileStudyingChosen(shown),
),
),
],
),
);
}
}

View file

@ -76,4 +76,16 @@ void main() {
await pumpEventQueue();
expect(store.writes, [after]);
});
test('the Pomodoro timer can be hidden while studying', () async {
final store = MemoryDeviceSettingsStore();
final bloc = DeviceSettingsBloc(store, const DeviceSettings())
..add(const DeviceSettingsEventPomodoroWhileStudyingChosen(false));
addTearDown(bloc.close);
const after = DeviceSettings(pomodoroWhileStudying: false);
await next(bloc, after);
await pumpEventQueue();
expect(store.writes, [after]);
});
}

View file

@ -44,6 +44,17 @@ void main() {
);
});
test('shows the Pomodoro timer while studying until told not to', () async {
expect((await store.read()).pomodoroWhileStudying, isTrue);
await store.write(const DeviceSettings(pomodoroWhileStudying: false));
expect(
(await FileDeviceSettingsStore(file).read()).pomodoroWhileStudying,
isFalse,
);
});
test('keeps a return to the device language', () async {
await store.write(const DeviceSettings(language: 'en-US'));
await store.write(const DeviceSettings(appearance: Appearance.light));

View file

@ -1,5 +1,6 @@
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';
@ -264,4 +265,34 @@ void main() {
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,
);
});
}

View file

@ -108,6 +108,36 @@ void main() {
);
});
testWidgets('the Study card hides the Pomodoro timer while studying, and '
'opens the Pomodoro profiles', (tester) async {
final store = MemoryDeviceSettingsStore();
await pumpApp(
tester,
testDependencies(deviceSettingsStore: store),
location: RoutePaths.settings,
);
final timer = find.text('Show the Pomodoro timer while studying');
await tester.scrollUntilVisible(
timer,
200,
scrollable: find.byType(Scrollable).first,
);
expect(find.text('Pomodoro profiles'), findsOneWidget);
final toggle = find.widgetWithText(
SwitchListTile,
'Show the Pomodoro timer while studying',
);
await tester.ensureVisible(toggle);
await tester.pumpAndSettle();
await tester.tap(
find.descendant(of: toggle, matching: find.byType(Switch)),
);
await tester.pumpAndSettle();
expect(store.writes.last.pomodoroWhileStudying, isFalse);
});
testWidgets('starts with the appearance the device kept', (tester) async {
tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark;
addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue);