diff --git a/pomodoro_app/android/app/src/main/AndroidManifest.xml b/pomodoro_app/android/app/src/main/AndroidManifest.xml
index 05764e4..51d5919 100644
--- a/pomodoro_app/android/app/src/main/AndroidManifest.xml
+++ b/pomodoro_app/android/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
+
createState() => PomodoroScreenState();
+}
+
+/// State for [PomodoroScreen], exposed for testing.
+@visibleForTesting
+class PomodoroScreenState extends State {
+ PomodoroTimer? _timer;
+ SyncService? _syncService;
+ bool _ownsTimer = false;
+ bool _ownsSyncService = false;
+ bool _initialized = false;
+
+ @override
+ void initState() {
+ super.initState();
+
+ if (widget.timer != null) {
+ // Test path: synchronous init, no sync service needed.
+ _timer = widget.timer!;
+ _syncService = widget.syncService;
+ _timer!.addListener(_onTimerChanged);
+ _initialized = true;
+ } else {
+ // Production path: async init with sync service.
+ _initAsync();
+ }
+ }
+
+ Future _initAsync() async {
+ _syncService = SyncService(
+ onStateReceived: _onRemoteState,
+ );
+ _ownsSyncService = true;
+ await _syncService!.start();
+
+ _timer = PomodoroTimer(
+ syncService: _syncService,
+ soundService: SoundService(),
+ notificationService: NotificationService(),
+ );
+ _ownsTimer = true;
+
+ _timer!.addListener(_onTimerChanged);
+ _initialized = true;
+ if (mounted) setState(() {});
+ }
+
+ void _onRemoteState(PomodoroState state, String action) {
+ _timer?.applyRemoteState(state, action);
+ }
+
+ void _onTimerChanged() {
+ if (mounted) setState(() {});
+ }
+
+ @override
+ void dispose() {
+ _timer?.removeListener(_onTimerChanged);
+ if (_ownsTimer) _timer?.dispose();
+ if (_ownsSyncService) _syncService?.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ if (!_initialized || _timer == null) {
+ return const Scaffold(
+ body: Center(child: CircularProgressIndicator()),
+ );
+ }
+
+ final timer = _timer!;
+ final state = timer.state;
+
+ return Scaffold(
+ body: SafeArea(
+ child: Center(
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 500),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 24),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Spacer(flex: 2),
+ // Timer style picker.
+ SegmentedButton(
+ segments: const [
+ ButtonSegment(
+ value: TimerStyle.pomodoro,
+ label: Text('Pomodoro'),
+ icon: Icon(Icons.timer),
+ ),
+ ButtonSegment(
+ value: TimerStyle.ultraradian,
+ label: Text('Ultraradian'),
+ icon: Icon(Icons.self_improvement),
+ ),
+ ],
+ selected: {timer.timerStyle},
+ onSelectionChanged: (selected) {
+ timer.switchStyle(selected.first);
+ },
+ ),
+ const SizedBox(height: 16),
+ // Timer display.
+ Expanded(
+ flex: 5,
+ child: TimerDisplay(state: state),
+ ),
+ const SizedBox(height: 32),
+ // Controls.
+ TimerControls(
+ state: state,
+ onStart: timer.start,
+ onPause: timer.pause,
+ onReset: timer.reset,
+ onSkip: timer.skip,
+ ),
+ const SizedBox(height: 32),
+ // Session indicators.
+ PomodoroIndicators(state: state),
+ const SizedBox(height: 16),
+ // Completed count.
+ Text(
+ '${state.completedPomodoros} '
+ '${timer.timerStyle.label.toLowerCase()}'
+ '${state.completedPomodoros == 1 ? '' : 's'}'
+ ' completed',
+ style: Theme.of(context).textTheme.bodyLarge,
+ ),
+ const Spacer(flex: 2),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/pomodoro_app/lib/services/notification_service.dart b/pomodoro_app/lib/services/notification_service.dart
new file mode 100644
index 0000000..2476c8a
--- /dev/null
+++ b/pomodoro_app/lib/services/notification_service.dart
@@ -0,0 +1,155 @@
+import 'dart:io';
+
+import 'package:flutter/foundation.dart';
+
+import '../models/pomodoro_state.dart';
+
+/// Sends desktop notifications showing Pomodoro timer status.
+///
+/// Uses the freedesktop D-Bus Notifications interface via `gdbus` to show,
+/// update, and dismiss notifications. The notification includes the current
+/// mode, remaining time, and a progress bar. Action buttons (Pause / Skip /
+/// Start) are displayed for quick interaction.
+class NotificationService {
+ /// Creates a [NotificationService].
+ ///
+ /// Pass a custom [runProcess] for testing.
+ NotificationService({
+ @visibleForTesting
+ Future Function(String, List)? runProcess,
+ }) : _runProcess = runProcess ?? Process.run;
+
+ final Future Function(String, List) _runProcess;
+ int _currentId = 0;
+ bool _disposed = false;
+
+ static const _dbusDest = 'org.freedesktop.Notifications';
+ static const _dbusPath = '/org/freedesktop/Notifications';
+
+ /// The notification ID currently shown (0 means none).
+ @visibleForTesting
+ int get currentId => _currentId;
+
+ /// Shows or updates the timer notification with the current [state].
+ ///
+ /// The notification replaces any previous one so only a single
+ /// notification is visible at a time.
+ Future showTimer({required PomodoroState state}) async {
+ if (_disposed) return;
+
+ final title = '${state.mode.label} \u2013 ${state.formattedTime}';
+ final body = _progressBar(state.progress);
+
+ await _notify(
+ title: title,
+ body: body,
+ actions: state.isRunning
+ ? ['pause', 'Pause', 'skip', 'Skip']
+ : ['start', 'Start'],
+ );
+ }
+
+ /// Shows a notification that the session has completed.
+ Future showSessionComplete({
+ required PomodoroMode completedMode,
+ required PomodoroMode nextMode,
+ }) async {
+ if (_disposed) return;
+
+ final title = '${completedMode.label} complete!';
+ final body = 'Up next: ${nextMode.label}';
+
+ await _notify(title: title, body: body, actions: ['start', 'Start']);
+ }
+
+ /// Cancels the currently shown notification.
+ Future cancel() async {
+ if (_disposed || _currentId == 0) return;
+
+ try {
+ await _runProcess('gdbus', [
+ 'call',
+ '--session',
+ '--dest',
+ _dbusDest,
+ '--object-path',
+ _dbusPath,
+ '--method',
+ 'org.freedesktop.Notifications.CloseNotification',
+ '$_currentId',
+ ]);
+ } on Object catch (e) {
+ debugPrint('NotificationService: Close error: $e');
+ }
+ _currentId = 0;
+ }
+
+ /// Releases resources. Does not await the underlying cancel.
+ void dispose() {
+ if (_disposed) return;
+ if (_currentId != 0) {
+ // Fire-and-forget; the notification daemon cleans up on exit.
+ unawaited(cancel());
+ }
+ _disposed = true;
+ }
+
+ // ------------------------------------------------------------------
+ // Private helpers
+ // ------------------------------------------------------------------
+
+ Future _notify({
+ required String title,
+ required String body,
+ List actions = const [],
+ }) async {
+ final actionsStr = actions.isEmpty
+ ? '[]'
+ : '[${actions.map((a) => "'$a'").join(', ')}]';
+
+ try {
+ final result = await _runProcess('gdbus', [
+ 'call',
+ '--session',
+ '--dest',
+ _dbusDest,
+ '--object-path',
+ _dbusPath,
+ '--method',
+ 'org.freedesktop.Notifications.Notify',
+ 'Pomodoro',
+ '$_currentId',
+ 'appointment-soon',
+ title,
+ body,
+ actionsStr,
+ '{}',
+ '0',
+ ]);
+
+ final match =
+ RegExp(r'\(uint32 (\d+),?\)').firstMatch(result.stdout as String);
+ if (match != null) {
+ _currentId = int.parse(match.group(1)!);
+ }
+ } on Object catch (e) {
+ debugPrint('NotificationService: Notify error: $e');
+ }
+ }
+
+ /// Builds a text-based progress bar for the notification body.
+ @visibleForTesting
+ static String progressBar(double progress) => _progressBar(progress);
+
+ static String _progressBar(double progress) {
+ const total = 20;
+ final filled = (progress * total).round();
+ final empty = total - filled;
+ return '${'█' * filled}${'░' * empty}';
+ }
+}
+
+/// Completes a future without requiring `await`.
+///
+/// Prevents the `unawaited_futures` lint in fire-and-forget calls.
+void unawaited(Future future) {}
diff --git a/pomodoro_app/lib/services/pomodoro_timer.dart b/pomodoro_app/lib/services/pomodoro_timer.dart
new file mode 100644
index 0000000..3008976
--- /dev/null
+++ b/pomodoro_app/lib/services/pomodoro_timer.dart
@@ -0,0 +1,269 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+
+import '../models/pomodoro_state.dart';
+import 'notification_service.dart';
+import 'sound_service.dart';
+import 'sync_service.dart';
+
+/// Manages the Pomodoro timer logic, independent of UI framework.
+///
+/// Optionally synchronizes state across devices via [SyncService].
+class PomodoroTimer extends ChangeNotifier {
+ /// Creates a [PomodoroTimer] with configurable durations.
+ PomodoroTimer({
+ int? workMinutes,
+ int? shortBreakMinutes,
+ int? longBreakMinutes,
+ int? pomodorosPerCycle,
+ TimerStyle timerStyle = TimerStyle.pomodoro,
+ this.syncService,
+ SoundService? soundService,
+ NotificationService? notificationService,
+ @visibleForTesting Timer Function(Duration, void Function(Timer))? timerFactory,
+ }) : _timerStyle = timerStyle,
+ _soundService = soundService,
+ _notificationService = notificationService,
+ _timerFactory = timerFactory ?? Timer.periodic {
+ _workMinutes = workMinutes ?? timerStyle.defaultWorkMinutes;
+ _shortBreakMinutes = shortBreakMinutes ?? timerStyle.defaultShortBreakMinutes;
+ _longBreakMinutes = longBreakMinutes ?? timerStyle.defaultLongBreakMinutes;
+ _pomodorosPerCycle = pomodorosPerCycle ?? timerStyle.defaultPomodorosPerCycle;
+ _state = PomodoroState.initial(
+ workMinutes: _workMinutes,
+ shortBreakMinutes: _shortBreakMinutes,
+ longBreakMinutes: _longBreakMinutes,
+ pomodorosPerCycle: _pomodorosPerCycle,
+ );
+ }
+
+ /// Duration of a work session in minutes.
+ late int _workMinutes;
+
+ /// Duration of a short break in minutes.
+ late int _shortBreakMinutes;
+
+ /// Duration of a long break in minutes.
+ late int _longBreakMinutes;
+
+ /// Number of work sessions before a long break.
+ late int _pomodorosPerCycle;
+
+ /// The current timer style.
+ TimerStyle _timerStyle;
+
+ /// Optional sync service for LAN synchronization.
+ final SyncService? syncService;
+
+ final SoundService? _soundService;
+ final NotificationService? _notificationService;
+ final Timer Function(Duration, void Function(Timer)) _timerFactory;
+
+ late PomodoroState _state;
+ Timer? _timer;
+
+ /// Whether we are currently applying a remote state (prevents echo).
+ bool _applyingRemote = false;
+
+ /// The current state of the timer.
+ PomodoroState get state => _state;
+
+ /// The active timer style.
+ TimerStyle get timerStyle => _timerStyle;
+
+ /// Switches to a different timer style, resetting all progress.
+ void switchStyle(TimerStyle style) {
+ if (style == _timerStyle) return;
+ _timer?.cancel();
+ _timer = null;
+ _timerStyle = style;
+ _workMinutes = style.defaultWorkMinutes;
+ _shortBreakMinutes = style.defaultShortBreakMinutes;
+ _longBreakMinutes = style.defaultLongBreakMinutes;
+ _pomodorosPerCycle = style.defaultPomodorosPerCycle;
+ _state = PomodoroState.initial(
+ workMinutes: _workMinutes,
+ shortBreakMinutes: _shortBreakMinutes,
+ longBreakMinutes: _longBreakMinutes,
+ pomodorosPerCycle: _pomodorosPerCycle,
+ );
+ _notificationService?.cancel();
+ notifyListeners();
+ syncService?.stopHeartbeat();
+ }
+
+ /// Starts or resumes the timer.
+ void start() {
+ if (_state.isRunning) return;
+ _state = _state.copyWith(isRunning: true);
+ notifyListeners();
+ _startTicking();
+ _notificationService?.showTimer(state: _state);
+ _broadcastIfLocal('start');
+ syncService?.startHeartbeat(() => _state);
+ }
+
+ /// Pauses the timer.
+ void pause() {
+ if (!_state.isRunning) return;
+ _timer?.cancel();
+ _timer = null;
+ _state = _state.copyWith(isRunning: false);
+ _notificationService?.cancel();
+ notifyListeners();
+ _broadcastIfLocal('pause');
+ syncService?.stopHeartbeat();
+ }
+
+ /// Resets the current session timer without changing the mode.
+ void reset() {
+ _timer?.cancel();
+ _timer = null;
+ _state = _state.copyWith(
+ remainingSeconds: _state.totalSeconds,
+ isRunning: false,
+ );
+ _notificationService?.cancel();
+ notifyListeners();
+ _broadcastIfLocal('reset');
+ syncService?.stopHeartbeat();
+ }
+
+ /// Skips to the next session, treating the current one as completed.
+ void skip() {
+ _timer?.cancel();
+ _timer = null;
+ _onSessionComplete();
+ _broadcastIfLocal('skip');
+ syncService?.stopHeartbeat();
+ }
+
+ /// Applies state received from a remote device via [SyncService].
+ void applyRemoteState(PomodoroState remoteState, String action) {
+ _applyingRemote = true;
+
+ _timer?.cancel();
+ _timer = null;
+
+ _state = remoteState;
+
+ if (_state.isRunning) {
+ _startTicking();
+ }
+
+ notifyListeners();
+ _applyingRemote = false;
+ }
+
+ void _tick(Timer timer) {
+ if (_state.remainingSeconds <= 1) {
+ timer.cancel();
+ _timer = null;
+ _onSessionComplete();
+ } else {
+ _state = _state.copyWith(
+ remainingSeconds: _state.remainingSeconds - 1,
+ );
+ _updateNotification();
+ notifyListeners();
+ }
+ }
+
+ void _onSessionComplete() {
+ if (_state.mode == PomodoroMode.work) {
+ final newCompleted = _state.completedPomodoros + 1;
+ _state = _state.copyWith(
+ completedPomodoros: newCompleted,
+ remainingSeconds: 0,
+ isRunning: false,
+ );
+ } else {
+ _state = _state.copyWith(
+ remainingSeconds: 0,
+ isRunning: false,
+ );
+ }
+ notifyListeners();
+ final completedMode = _state.mode;
+ _advanceToNextMode();
+ _soundService?.playTransitionSound(
+ completedMode: completedMode,
+ nextMode: _state.mode,
+ );
+ _notificationService?.showSessionComplete(
+ completedMode: completedMode,
+ nextMode: _state.mode,
+ );
+ notifyListeners();
+ }
+
+ void _advanceToNextMode() {
+ switch (_state.mode) {
+ case PomodoroMode.work:
+ if (_state.completedPomodoros > 0 &&
+ _state.completedPomodoros % _pomodorosPerCycle == 0) {
+ _setMode(PomodoroMode.longBreak);
+ } else {
+ _setMode(PomodoroMode.shortBreak);
+ }
+ case PomodoroMode.shortBreak:
+ case PomodoroMode.longBreak:
+ _setMode(PomodoroMode.work);
+ }
+ }
+
+ void _setMode(PomodoroMode mode) {
+ final totalSeconds = _durationForMode(mode) * 60;
+ _state = _state.copyWith(
+ mode: mode,
+ remainingSeconds: totalSeconds,
+ totalSeconds: totalSeconds,
+ isRunning: false,
+ );
+ }
+
+ int _durationForMode(PomodoroMode mode) {
+ switch (mode) {
+ case PomodoroMode.work:
+ return _workMinutes;
+ case PomodoroMode.shortBreak:
+ return _shortBreakMinutes;
+ case PomodoroMode.longBreak:
+ return _longBreakMinutes;
+ }
+ }
+
+ void _startTicking() {
+ _timer = _timerFactory(
+ const Duration(seconds: 1),
+ _tick,
+ );
+ }
+
+ /// Broadcasts state to peers only if this is a local user action.
+ void _broadcastIfLocal(String action) {
+ if (!_applyingRemote) {
+ syncService?.broadcast(_state, action);
+ }
+ }
+
+ /// Interval in seconds between notification updates while running.
+ static const _notifyIntervalSeconds = 30;
+
+ void _updateNotification() {
+ if (_notificationService == null) return;
+ if (_state.remainingSeconds % _notifyIntervalSeconds == 0) {
+ _notificationService.showTimer(state: _state);
+ }
+ }
+
+ @override
+ void dispose() {
+ _timer?.cancel();
+ syncService?.stopHeartbeat();
+ _soundService?.dispose();
+ _notificationService?.dispose();
+ super.dispose();
+ }
+}
diff --git a/pomodoro_app/lib/services/sound_service.dart b/pomodoro_app/lib/services/sound_service.dart
new file mode 100644
index 0000000..bf48048
--- /dev/null
+++ b/pomodoro_app/lib/services/sound_service.dart
@@ -0,0 +1,78 @@
+import 'package:audioplayers/audioplayers.dart';
+import 'package:flutter/foundation.dart';
+
+import '../models/pomodoro_state.dart';
+
+/// Plays notification sounds for Pomodoro timer transitions.
+///
+/// Each transition type has a distinct sound:
+/// - Work done → ascending chime
+/// - Short break done → gentle double ping
+/// - Long break starting → descending celebration
+/// - Long break done → rapid wake-up beeps
+class SoundService {
+ /// Creates a [SoundService].
+ ///
+ /// Pass a custom [playCallback] for testing.
+ SoundService({
+ @visibleForTesting Future Function(String assetPath)? playCallback,
+ }) : _playCallback = playCallback;
+
+ final Future Function(String assetPath)? _playCallback;
+ AudioPlayer? _player;
+ bool _disposed = false;
+
+ static const _assetPrefix = 'sounds';
+
+ /// Plays the appropriate sound for a mode transition.
+ ///
+ /// [completedMode] is the mode that just finished.
+ /// [nextMode] is the mode that is starting.
+ Future playTransitionSound({
+ required PomodoroMode completedMode,
+ required PomodoroMode nextMode,
+ }) async {
+ if (_disposed) return;
+
+ final assetPath = _assetForTransition(completedMode, nextMode);
+ if (assetPath == null) return;
+
+ try {
+ if (_playCallback != null) {
+ await _playCallback(assetPath);
+ } else {
+ _player?.dispose();
+ _player = AudioPlayer();
+ await _player!.play(AssetSource('$_assetPrefix/$assetPath'));
+ }
+ debugPrint('SoundService: Playing $assetPath');
+ } on Object catch (e) {
+ debugPrint('SoundService: Playback error: $e');
+ }
+ }
+
+ /// Returns the WAV filename for a given transition, or null if none.
+ static String? _assetForTransition(
+ PomodoroMode completedMode,
+ PomodoroMode nextMode,
+ ) {
+ switch (completedMode) {
+ case PomodoroMode.work:
+ if (nextMode == PomodoroMode.longBreak) {
+ return 'long_break_start.wav';
+ }
+ return 'work_done.wav';
+ case PomodoroMode.shortBreak:
+ return 'short_break_done.wav';
+ case PomodoroMode.longBreak:
+ return 'long_break_done.wav';
+ }
+ }
+
+ /// Releases audio resources.
+ void dispose() {
+ _disposed = true;
+ _player?.dispose();
+ _player = null;
+ }
+}
diff --git a/pomodoro_app/lib/services/sync_service.dart b/pomodoro_app/lib/services/sync_service.dart
new file mode 100644
index 0000000..7c19861
--- /dev/null
+++ b/pomodoro_app/lib/services/sync_service.dart
@@ -0,0 +1,252 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+import 'dart:math';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+
+import '../models/pomodoro_state.dart';
+
+/// Callback type for receiving a synced [PomodoroState] and action name.
+typedef SyncCallback = void Function(PomodoroState state, String action);
+
+/// Provides LAN synchronization between Pomodoro app instances using
+/// UDP broadcast.
+///
+/// Uses subnet broadcast (255.255.255.255) instead of multicast for
+/// maximum compatibility across platforms. A unique [deviceId] prevents
+/// echo (processing own messages).
+class SyncService {
+ /// Creates a [SyncService].
+ ///
+ /// [onStateReceived] is called when a remote device broadcasts a state
+ /// change. [port] can be overridden for testing.
+ SyncService({
+ required this.onStateReceived,
+ this.port = 41234,
+ @visibleForTesting String? deviceId,
+ @visibleForTesting
+ Future Function(dynamic host, int port)?
+ socketFactory,
+ }) : deviceId = deviceId ?? _generateDeviceId(),
+ _socketFactory = socketFactory;
+
+ /// Unique identifier for this device instance.
+ final String deviceId;
+
+ /// UDP port for sync messages.
+ final int port;
+
+ /// UDP port for wake signals (separate from sync to allow a daemon to
+ /// listen without conflicting with the app's sync socket).
+ static const int wakePort = 41235;
+
+ /// Called when a state update is received from another device.
+ final SyncCallback onStateReceived;
+
+ final Future Function(dynamic host, int port)?
+ _socketFactory;
+
+ RawDatagramSocket? _socket;
+ Timer? _heartbeat;
+ bool _disposed = false;
+
+ static const _methodChannel = MethodChannel('pomodoro_multicast_lock');
+
+ /// Whether the service is currently listening.
+ bool get isActive => _socket != null && !_disposed;
+
+ /// Starts listening for broadcast messages and enables sending.
+ Future start() async {
+ if (_disposed) return;
+
+ // Acquire Android multicast/broadcast lock.
+ await _acquireMulticastLock();
+
+ try {
+ if (_socketFactory != null) {
+ _socket = await _socketFactory(InternetAddress.anyIPv4, port);
+ } else {
+ _socket = await RawDatagramSocket.bind(
+ InternetAddress.anyIPv4,
+ port,
+ reuseAddress: true,
+ );
+ }
+
+ _socket?.broadcastEnabled = true;
+
+ _socket?.listen(
+ _onSocketEvent,
+ onError: _onError,
+ cancelOnError: false,
+ );
+
+ debugPrint('SyncService: Listening on port $port (device=$deviceId)');
+
+ // Notify other devices that this instance just opened.
+ _sendWake();
+ } on Object catch (e) {
+ debugPrint('SyncService: Failed to start: $e');
+ }
+ }
+
+ /// Broadcasts the given [state] with an [action] label to all peers.
+ void broadcast(PomodoroState state, String action) {
+ if (_socket == null || _disposed) return;
+
+ final message = _encodeMessage(state, action);
+ try {
+ final sent = _socket!.send(
+ message,
+ InternetAddress('255.255.255.255'),
+ port,
+ );
+ debugPrint(
+ 'SyncService: Sent $action ($sent bytes) '
+ 'to 255.255.255.255:$port',
+ );
+ } on Object catch (e) {
+ debugPrint('SyncService: Send failed: $e');
+ }
+ }
+
+ /// Starts periodic heartbeat that broadcasts current state.
+ ///
+ /// This keeps devices in sync even if an individual message is lost.
+ void startHeartbeat(PomodoroState Function() stateProvider) {
+ _heartbeat?.cancel();
+ _heartbeat = Timer.periodic(
+ const Duration(seconds: 5),
+ (_) => broadcast(stateProvider(), 'heartbeat'),
+ );
+ }
+
+ /// Stops the periodic heartbeat.
+ void stopHeartbeat() {
+ _heartbeat?.cancel();
+ _heartbeat = null;
+ }
+
+ /// Shuts down the sync service.
+ Future dispose() async {
+ _disposed = true;
+ _heartbeat?.cancel();
+ _heartbeat = null;
+
+ _socket?.close();
+ _socket = null;
+
+ await _releaseMulticastLock();
+ }
+
+ // -- Private helpers --
+
+ /// Sends a wake signal to the dedicated wake port so that a desktop
+ /// daemon can auto-launch the app on other devices.
+ void _sendWake() {
+ if (_socket == null || _disposed) return;
+ final message = utf8.encode(jsonEncode({
+ 'deviceId': deviceId,
+ 'action': 'wake',
+ 'timestamp': DateTime.now().millisecondsSinceEpoch,
+ }));
+ try {
+ _socket!.send(message, InternetAddress('255.255.255.255'), wakePort);
+ debugPrint('SyncService: Sent wake to port $wakePort');
+ } on Object catch (e) {
+ debugPrint('SyncService: Wake send failed: $e');
+ }
+ }
+
+ void _onSocketEvent(RawSocketEvent event) {
+ if (event != RawSocketEvent.read) return;
+
+ final datagram = _socket?.receive();
+ if (datagram == null) return;
+
+ try {
+ final json = utf8.decode(datagram.data);
+ final map = jsonDecode(json) as Map;
+
+ // Ignore own messages.
+ if (map['deviceId'] == deviceId) return;
+
+ final state = _decodeState(map['state'] as Map);
+ final action = map['action'] as String;
+ debugPrint(
+ 'SyncService: Received $action from ${map['deviceId']}',
+ );
+ onStateReceived(state, action);
+ } on Object catch (e) {
+ debugPrint('SyncService: Parse error: $e');
+ }
+ }
+
+ void _onError(Object error) {
+ debugPrint('SyncService: Socket error: $error');
+ }
+
+ List _encodeMessage(PomodoroState state, String action) {
+ final map = {
+ 'deviceId': deviceId,
+ 'timestamp': DateTime.now().millisecondsSinceEpoch,
+ 'action': action,
+ 'state': _encodeState(state),
+ };
+ return utf8.encode(jsonEncode(map));
+ }
+
+ static Map _encodeState(PomodoroState state) {
+ return {
+ 'mode': state.mode.name,
+ 'remainingSeconds': state.remainingSeconds,
+ 'totalSeconds': state.totalSeconds,
+ 'isRunning': state.isRunning,
+ 'completedPomodoros': state.completedPomodoros,
+ 'pomodorosPerCycle': state.pomodorosPerCycle,
+ };
+ }
+
+ static PomodoroState _decodeState(Map map) {
+ return PomodoroState(
+ mode: PomodoroMode.values.byName(map['mode'] as String),
+ remainingSeconds: map['remainingSeconds'] as int,
+ totalSeconds: map['totalSeconds'] as int,
+ isRunning: map['isRunning'] as bool,
+ completedPomodoros: map['completedPomodoros'] as int,
+ pomodorosPerCycle: map['pomodorosPerCycle'] as int,
+ );
+ }
+
+ static Future _acquireMulticastLock() async {
+ if (!Platform.isAndroid) return;
+ try {
+ await _methodChannel.invokeMethod('acquire');
+ } on MissingPluginException {
+ // Platform channel not available (e.g., in tests).
+ } on Object catch (e) {
+ debugPrint('SyncService: Failed to acquire multicast lock: $e');
+ }
+ }
+
+ static Future _releaseMulticastLock() async {
+ if (!Platform.isAndroid) return;
+ try {
+ await _methodChannel.invokeMethod('release');
+ } on MissingPluginException {
+ // Platform channel not available.
+ } on Object catch (e) {
+ debugPrint('SyncService: Failed to release multicast lock: $e');
+ }
+ }
+
+ static String _generateDeviceId() {
+ final random = Random();
+ return List.generate(
+ 8,
+ (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
+ ).join();
+ }
+}
diff --git a/pomodoro_app/lib/theme/pomodoro_theme.dart b/pomodoro_app/lib/theme/pomodoro_theme.dart
new file mode 100644
index 0000000..499a138
--- /dev/null
+++ b/pomodoro_app/lib/theme/pomodoro_theme.dart
@@ -0,0 +1,73 @@
+import 'package:flutter/material.dart';
+
+import '../models/pomodoro_state.dart';
+
+/// Provides consistent theming for the Pomodoro app across platforms.
+class PomodoroTheme {
+ PomodoroTheme._();
+
+ // Brand colors per mode.
+ static const Color workColor = Color(0xFFE74C3C);
+ static const Color shortBreakColor = Color(0xFF2ECC71);
+ static const Color longBreakColor = Color(0xFF3498DB);
+
+ static const Color _darkSurface = Color(0xFF1A1A2E);
+ static const Color _darkBackground = Color(0xFF16213E);
+ static const Color _textLight = Color(0xFFF5F5F5);
+ static const Color _textMuted = Color(0xFFB0B0B0);
+
+ /// Returns the accent color for the given [mode].
+ static Color colorForMode(PomodoroMode mode) {
+ switch (mode) {
+ case PomodoroMode.work:
+ return workColor;
+ case PomodoroMode.shortBreak:
+ return shortBreakColor;
+ case PomodoroMode.longBreak:
+ return longBreakColor;
+ }
+ }
+
+ /// The app's dark theme.
+ static ThemeData get darkTheme {
+ return ThemeData(
+ useMaterial3: true,
+ brightness: Brightness.dark,
+ scaffoldBackgroundColor: _darkBackground,
+ colorScheme: const ColorScheme.dark(
+ primary: workColor,
+ surface: _darkSurface,
+ onSurface: _textLight,
+ ),
+ textTheme: const TextTheme(
+ displayLarge: TextStyle(
+ fontSize: 72,
+ fontWeight: FontWeight.w300,
+ color: _textLight,
+ letterSpacing: 4,
+ ),
+ headlineMedium: TextStyle(
+ fontSize: 24,
+ fontWeight: FontWeight.w500,
+ color: _textLight,
+ ),
+ bodyLarge: TextStyle(
+ fontSize: 16,
+ color: _textMuted,
+ ),
+ ),
+ elevatedButtonTheme: ElevatedButtonThemeData(
+ style: ElevatedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(30),
+ ),
+ textStyle: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/pomodoro_app/lib/widgets/pomodoro_indicators.dart b/pomodoro_app/lib/widgets/pomodoro_indicators.dart
new file mode 100644
index 0000000..d938cda
--- /dev/null
+++ b/pomodoro_app/lib/widgets/pomodoro_indicators.dart
@@ -0,0 +1,47 @@
+import 'package:flutter/material.dart';
+
+import '../models/pomodoro_state.dart';
+import '../theme/pomodoro_theme.dart';
+
+/// Shows completed pomodoro indicators as filled/unfilled dots.
+class PomodoroIndicators extends StatelessWidget {
+ /// Creates [PomodoroIndicators].
+ const PomodoroIndicators({
+ required this.state,
+ super.key,
+ });
+
+ /// The current Pomodoro state.
+ final PomodoroState state;
+
+ @override
+ Widget build(BuildContext context) {
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: List.generate(
+ state.pomodorosPerCycle,
+ (index) {
+ final isCompleted = index < state.completedPomodoros % state.pomodorosPerCycle;
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 6),
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 300),
+ width: 14,
+ height: 14,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: isCompleted
+ ? PomodoroTheme.workColor
+ : Colors.white24,
+ border: Border.all(
+ color: PomodoroTheme.workColor.withValues(alpha: 0.5),
+ width: 2,
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
diff --git a/pomodoro_app/lib/widgets/timer_controls.dart b/pomodoro_app/lib/widgets/timer_controls.dart
new file mode 100644
index 0000000..ba369cb
--- /dev/null
+++ b/pomodoro_app/lib/widgets/timer_controls.dart
@@ -0,0 +1,79 @@
+import 'package:flutter/material.dart';
+
+import '../models/pomodoro_state.dart';
+import '../theme/pomodoro_theme.dart';
+
+/// Row of control buttons for the Pomodoro timer.
+class TimerControls extends StatelessWidget {
+ /// Creates [TimerControls].
+ const TimerControls({
+ required this.state,
+ required this.onStart,
+ required this.onPause,
+ required this.onReset,
+ required this.onSkip,
+ super.key,
+ });
+
+ /// The current Pomodoro state.
+ final PomodoroState state;
+
+ /// Callback when user taps start.
+ final VoidCallback onStart;
+
+ /// Callback when user taps pause.
+ final VoidCallback onPause;
+
+ /// Callback when user taps reset.
+ final VoidCallback onReset;
+
+ /// Callback when user taps skip.
+ final VoidCallback onSkip;
+
+ @override
+ Widget build(BuildContext context) {
+ final color = PomodoroTheme.colorForMode(state.mode);
+
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ // Reset button.
+ IconButton(
+ onPressed: onReset,
+ icon: const Icon(Icons.refresh),
+ iconSize: 32,
+ tooltip: 'Reset',
+ color: Colors.white70,
+ ),
+ const SizedBox(width: 16),
+ // Play / Pause button.
+ SizedBox(
+ width: 72,
+ height: 72,
+ child: ElevatedButton(
+ onPressed: state.isRunning ? onPause : onStart,
+ style: ElevatedButton.styleFrom(
+ backgroundColor: color,
+ foregroundColor: Colors.white,
+ shape: const CircleBorder(),
+ padding: EdgeInsets.zero,
+ ),
+ child: Icon(
+ state.isRunning ? Icons.pause : Icons.play_arrow,
+ size: 36,
+ ),
+ ),
+ ),
+ const SizedBox(width: 16),
+ // Skip button.
+ IconButton(
+ onPressed: onSkip,
+ icon: const Icon(Icons.skip_next),
+ iconSize: 32,
+ tooltip: 'Skip',
+ color: Colors.white70,
+ ),
+ ],
+ );
+ }
+}
diff --git a/pomodoro_app/lib/widgets/timer_display.dart b/pomodoro_app/lib/widgets/timer_display.dart
new file mode 100644
index 0000000..7b1b86b
--- /dev/null
+++ b/pomodoro_app/lib/widgets/timer_display.dart
@@ -0,0 +1,79 @@
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+
+import '../models/pomodoro_state.dart';
+import '../theme/pomodoro_theme.dart';
+
+/// A circular progress indicator that displays the remaining time.
+class TimerDisplay extends StatelessWidget {
+ /// Creates a [TimerDisplay].
+ const TimerDisplay({
+ required this.state,
+ super.key,
+ });
+
+ /// The current Pomodoro state.
+ final PomodoroState state;
+
+ @override
+ Widget build(BuildContext context) {
+ final color = PomodoroTheme.colorForMode(state.mode);
+
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final size = min(constraints.maxWidth, constraints.maxHeight) * 0.7;
+ return SizedBox(
+ width: size,
+ height: size,
+ child: Stack(
+ alignment: Alignment.center,
+ children: [
+ // Background circle.
+ SizedBox.expand(
+ child: CircularProgressIndicator(
+ value: 1.0,
+ strokeWidth: 8,
+ color: color.withValues(alpha: 0.2),
+ ),
+ ),
+ // Progress arc.
+ SizedBox.expand(
+ child: CircularProgressIndicator(
+ value: state.progress,
+ strokeWidth: 8,
+ color: color,
+ strokeCap: StrokeCap.round,
+ ),
+ ),
+ // Time text and mode label.
+ FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ state.modeDisplayLabel,
+ style: Theme.of(context).textTheme.bodyLarge?.copyWith(
+ color: color,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ state.formattedTime,
+ style:
+ Theme.of(context).textTheme.displayLarge?.copyWith(
+ color: Colors.white,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/pomodoro_app/packaging/arch/PKGBUILD b/pomodoro_app/packaging/arch/PKGBUILD
index 80384af..a807ebd 100644
--- a/pomodoro_app/packaging/arch/PKGBUILD
+++ b/pomodoro_app/packaging/arch/PKGBUILD
@@ -47,10 +47,10 @@ package() {
"$pkgdir/usr/lib/$pkgname/pomodoro_app"
# Install bundled shared libraries.
- install -Dm644 "$_bundle/lib/libapp.so" \
- "$pkgdir/usr/lib/$pkgname/lib/libapp.so"
- install -Dm644 "$_bundle/lib/libflutter_linux_gtk.so" \
- "$pkgdir/usr/lib/$pkgname/lib/libflutter_linux_gtk.so"
+ for lib in "$_bundle"/lib/*.so; do
+ install -Dm644 "$lib" \
+ "$pkgdir/usr/lib/$pkgname/lib/$(basename "$lib")"
+ done
# Install data directory.
install -Dm644 "$_bundle/data/icudtl.dat" \
diff --git a/pomodoro_app/test/models/pomodoro_state_test.dart b/pomodoro_app/test/models/pomodoro_state_test.dart
index a9ebd92..46b1750 100644
--- a/pomodoro_app/test/models/pomodoro_state_test.dart
+++ b/pomodoro_app/test/models/pomodoro_state_test.dart
@@ -2,6 +2,27 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:pomodoro_app/models/pomodoro_state.dart';
void main() {
+ group('TimerStyle', () {
+ test('label returns correct strings', () {
+ expect(TimerStyle.pomodoro.label, 'Pomodoro');
+ expect(TimerStyle.ultraradian.label, 'Ultraradian');
+ });
+
+ test('pomodoro has correct defaults', () {
+ expect(TimerStyle.pomodoro.defaultWorkMinutes, 25);
+ expect(TimerStyle.pomodoro.defaultShortBreakMinutes, 5);
+ expect(TimerStyle.pomodoro.defaultLongBreakMinutes, 15);
+ expect(TimerStyle.pomodoro.defaultPomodorosPerCycle, 4);
+ });
+
+ test('ultraradian has correct defaults', () {
+ expect(TimerStyle.ultraradian.defaultWorkMinutes, 90);
+ expect(TimerStyle.ultraradian.defaultShortBreakMinutes, 30);
+ expect(TimerStyle.ultraradian.defaultLongBreakMinutes, 30);
+ expect(TimerStyle.ultraradian.defaultPomodorosPerCycle, 1);
+ });
+ });
+
group('PomodoroMode', () {
test('label returns correct strings', () {
expect(PomodoroMode.work.label, 'Work');
@@ -98,6 +119,30 @@ void main() {
});
});
+ group('PomodoroState.modeDisplayLabel', () {
+ test('returns mode label when pomodorosPerCycle > 1', () {
+ final state = PomodoroState.initial();
+ expect(state.modeDisplayLabel, 'Work');
+
+ final breakState = state.copyWith(mode: PomodoroMode.shortBreak);
+ expect(breakState.modeDisplayLabel, 'Short Break');
+
+ final longBreakState = state.copyWith(mode: PomodoroMode.longBreak);
+ expect(longBreakState.modeDisplayLabel, 'Long Break');
+ });
+
+ test('returns Break when pomodorosPerCycle is 1 and not work', () {
+ final state = PomodoroState.initial(pomodorosPerCycle: 1);
+ expect(state.modeDisplayLabel, 'Work');
+
+ final breakState = state.copyWith(mode: PomodoroMode.shortBreak);
+ expect(breakState.modeDisplayLabel, 'Break');
+
+ final longBreakState = state.copyWith(mode: PomodoroMode.longBreak);
+ expect(longBreakState.modeDisplayLabel, 'Break');
+ });
+ });
+
group('PomodoroState equality', () {
test('equal states are ==', () {
final a = PomodoroState.initial();
diff --git a/pomodoro_app/test/screens/pomodoro_screen_test.dart b/pomodoro_app/test/screens/pomodoro_screen_test.dart
index d533b51..c460db4 100644
--- a/pomodoro_app/test/screens/pomodoro_screen_test.dart
+++ b/pomodoro_app/test/screens/pomodoro_screen_test.dart
@@ -174,5 +174,34 @@ void main() {
// We can check that the PomodoroIndicators widget is present.
expect(find.text('0 pomodoros completed'), findsOneWidget);
});
+
+ testWidgets('shows style picker with Pomodoro selected', (tester) async {
+ await tester.pumpWidget(createApp());
+ expect(find.text('Pomodoro'), findsOneWidget);
+ expect(find.text('Ultraradian'), findsOneWidget);
+ });
+
+ testWidgets('switching to ultraradian updates timer', (tester) async {
+ await tester.pumpWidget(createApp());
+
+ await tester.tap(find.text('Ultraradian'));
+ await tester.pump();
+
+ // Ultraradian work session is 90 minutes.
+ expect(find.text('90:00'), findsOneWidget);
+ expect(timer.timerStyle, TimerStyle.ultraradian);
+ });
+
+ testWidgets('switching back to pomodoro resets timer', (tester) async {
+ await tester.pumpWidget(createApp());
+
+ await tester.tap(find.text('Ultraradian'));
+ await tester.pump();
+ expect(find.text('90:00'), findsOneWidget);
+
+ await tester.tap(find.text('Pomodoro'));
+ await tester.pump();
+ expect(find.text('25:00'), findsOneWidget);
+ });
});
}
diff --git a/pomodoro_app/test/services/notification_service_test.dart b/pomodoro_app/test/services/notification_service_test.dart
new file mode 100644
index 0000000..908c525
--- /dev/null
+++ b/pomodoro_app/test/services/notification_service_test.dart
@@ -0,0 +1,211 @@
+import 'dart:io';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:pomodoro_app/models/pomodoro_state.dart';
+import 'package:pomodoro_app/services/notification_service.dart';
+
+/// Captured call to the mock process runner.
+class _Call {
+ _Call(this.executable, this.args);
+ final String executable;
+ final List args;
+}
+
+void main() {
+ group('NotificationService', () {
+ late List<_Call> calls;
+ late NotificationService service;
+
+ Future mockRun(String exec, List args) async {
+ calls.add(_Call(exec, args));
+ return ProcessResult(0, 0, '(uint32 42,)', '');
+ }
+
+ setUp(() {
+ calls = [];
+ service = NotificationService(runProcess: mockRun);
+ });
+
+ tearDown(() {
+ service.dispose();
+ });
+
+ test('showTimer sends Notify via gdbus', () async {
+ final state = PomodoroState(
+ mode: PomodoroMode.work,
+ remainingSeconds: 1500,
+ totalSeconds: 1500,
+ isRunning: true,
+ completedPomodoros: 0,
+ pomodorosPerCycle: 4,
+ );
+
+ await service.showTimer(state: state);
+
+ expect(calls, hasLength(1));
+ expect(calls[0].executable, 'gdbus');
+ expect(
+ calls[0].args,
+ contains('org.freedesktop.Notifications.Notify'),
+ );
+ expect(calls[0].args, contains('Work \u2013 25:00'));
+ expect(calls[0].args, contains("['pause', 'Pause', 'skip', 'Skip']"));
+ });
+
+ test('showTimer shows Start action when paused', () async {
+ final state = PomodoroState(
+ mode: PomodoroMode.shortBreak,
+ remainingSeconds: 120,
+ totalSeconds: 300,
+ isRunning: false,
+ completedPomodoros: 1,
+ pomodorosPerCycle: 4,
+ );
+
+ await service.showTimer(state: state);
+
+ expect(calls[0].args, contains("['start', 'Start']"));
+ });
+
+ test('showTimer replaces previous notification', () async {
+ final state = PomodoroState(
+ mode: PomodoroMode.work,
+ remainingSeconds: 1500,
+ totalSeconds: 1500,
+ isRunning: true,
+ completedPomodoros: 0,
+ pomodorosPerCycle: 4,
+ );
+
+ await service.showTimer(state: state);
+
+ // First call should use replaces_id 0.
+ expect(calls[0].args, contains('0'));
+
+ // Second call should use the parsed ID 42.
+ await service.showTimer(state: state);
+ expect(calls[1].args, contains('42'));
+ });
+
+ test('parses notification ID from gdbus output', () async {
+ final state = PomodoroState.initial();
+
+ await service.showTimer(state: state);
+ expect(service.currentId, 42);
+ });
+
+ test('handles unparsable gdbus output gracefully', () async {
+ final stubService = NotificationService(
+ runProcess: (exec, args) async {
+ return ProcessResult(0, 0, 'unexpected output', '');
+ },
+ );
+
+ final state = PomodoroState.initial();
+ await stubService.showTimer(state: state);
+ expect(stubService.currentId, 0);
+
+ stubService.dispose();
+ });
+
+ test('showSessionComplete sends correct content', () async {
+ await service.showSessionComplete(
+ completedMode: PomodoroMode.work,
+ nextMode: PomodoroMode.shortBreak,
+ );
+
+ expect(calls, hasLength(1));
+ expect(calls[0].args, contains('Work complete!'));
+ expect(calls[0].args, contains('Up next: Short Break'));
+ });
+
+ test('cancel sends CloseNotification', () async {
+ // First show a notification to get an ID.
+ final state = PomodoroState.initial();
+ await service.showTimer(state: state);
+ calls.clear();
+
+ await service.cancel();
+
+ expect(calls, hasLength(1));
+ expect(
+ calls[0].args,
+ contains('org.freedesktop.Notifications.CloseNotification'),
+ );
+ expect(calls[0].args, contains('42'));
+ });
+
+ test('cancel does nothing when no notification shown', () async {
+ await service.cancel();
+ expect(calls, isEmpty);
+ });
+
+ test('cancel resets currentId to 0', () async {
+ final state = PomodoroState.initial();
+ await service.showTimer(state: state);
+ expect(service.currentId, 42);
+
+ await service.cancel();
+ expect(service.currentId, 0);
+ });
+
+ test('does nothing after dispose', () async {
+ service.dispose();
+
+ final state = PomodoroState.initial();
+ await service.showTimer(state: state);
+ await service.showSessionComplete(
+ completedMode: PomodoroMode.work,
+ nextMode: PomodoroMode.shortBreak,
+ );
+ await service.cancel();
+
+ expect(calls, isEmpty);
+ });
+
+ test('dispose cancels active notification', () async {
+ final state = PomodoroState.initial();
+ await service.showTimer(state: state);
+ calls.clear();
+
+ service.dispose();
+
+ // Cancel was fired (fire-and-forget).
+ expect(calls, hasLength(1));
+ expect(
+ calls[0].args,
+ contains('org.freedesktop.Notifications.CloseNotification'),
+ );
+ });
+
+ test('handles process error gracefully', () async {
+ final errorService = NotificationService(
+ runProcess: (exec, args) async {
+ throw const OSError('gdbus not found');
+ },
+ );
+
+ final state = PomodoroState.initial();
+ // Should not throw.
+ await errorService.showTimer(state: state);
+ await errorService.cancel();
+
+ errorService.dispose();
+ });
+ });
+
+ group('progressBar', () {
+ test('returns empty bar at 0%', () {
+ expect(NotificationService.progressBar(0.0), '░' * 20);
+ });
+
+ test('returns full bar at 100%', () {
+ expect(NotificationService.progressBar(1.0), '█' * 20);
+ });
+
+ test('returns half bar at 50%', () {
+ final bar = NotificationService.progressBar(0.5);
+ expect(bar, '${'█' * 10}${'░' * 10}');
+ });
+ });
+}
diff --git a/pomodoro_app/test/services/pomodoro_timer_test.dart b/pomodoro_app/test/services/pomodoro_timer_test.dart
index e071bca..b26ef26 100644
--- a/pomodoro_app/test/services/pomodoro_timer_test.dart
+++ b/pomodoro_app/test/services/pomodoro_timer_test.dart
@@ -247,6 +247,70 @@ void main() {
});
});
+ group('switchStyle()', () {
+ test('switches to ultraradian with correct durations', () {
+ timer.switchStyle(TimerStyle.ultraradian);
+ expect(timer.timerStyle, TimerStyle.ultraradian);
+ expect(timer.state.remainingSeconds, 90 * 60);
+ expect(timer.state.totalSeconds, 90 * 60);
+ expect(timer.state.pomodorosPerCycle, 1);
+ expect(timer.state.mode, PomodoroMode.work);
+ expect(timer.state.isRunning, false);
+ });
+
+ test('switches back to pomodoro', () {
+ timer.switchStyle(TimerStyle.ultraradian);
+ timer.switchStyle(TimerStyle.pomodoro);
+ expect(timer.timerStyle, TimerStyle.pomodoro);
+ expect(timer.state.remainingSeconds, 25 * 60);
+ expect(timer.state.totalSeconds, 25 * 60);
+ expect(timer.state.pomodorosPerCycle, 4);
+ });
+
+ test('resets running timer when switching', () {
+ timer.start();
+ fakeController.tick();
+ expect(timer.state.isRunning, true);
+
+ timer.switchStyle(TimerStyle.ultraradian);
+ expect(timer.state.isRunning, false);
+ expect(timer.state.remainingSeconds, 90 * 60);
+ });
+
+ test('does nothing when switching to same style', () {
+ timer.start();
+ fakeController.tick();
+ final stateBefore = timer.state;
+
+ timer.switchStyle(TimerStyle.pomodoro);
+ expect(timer.state, stateBefore);
+ });
+
+ test('notifies listeners', () {
+ var notified = false;
+ timer.addListener(() => notified = true);
+ timer.switchStyle(TimerStyle.ultraradian);
+ expect(notified, true);
+ });
+
+ test('resets completed pomodoros', () {
+ timer.start();
+ for (var i = 0; i < 60; i++) {
+ fakeController.tick();
+ }
+ expect(timer.state.completedPomodoros, 1);
+
+ timer.switchStyle(TimerStyle.ultraradian);
+ expect(timer.state.completedPomodoros, 0);
+ });
+ });
+
+ group('timerStyle getter', () {
+ test('defaults to pomodoro', () {
+ expect(timer.timerStyle, TimerStyle.pomodoro);
+ });
+ });
+
group('applyRemoteState()', () {
test('applies remote state and notifies listeners', () {
var notified = false;
diff --git a/pomodoro_app/test/services/sync_service_test.dart b/pomodoro_app/test/services/sync_service_test.dart
index 05ed281..69befd3 100644
--- a/pomodoro_app/test/services/sync_service_test.dart
+++ b/pomodoro_app/test/services/sync_service_test.dart
@@ -10,12 +10,12 @@ import 'package:pomodoro_app/services/sync_service.dart';
/// injecting received messages.
class FakeDatagramSocket implements RawDatagramSocket {
final _controller = StreamController.broadcast();
- final List<_SentDatagram> sentMessages = [];
+ final List sentMessages = [];
Datagram? _pendingDatagram;
@override
int send(List buffer, InternetAddress address, int port) {
- sentMessages.add(_SentDatagram(buffer, address, port));
+ sentMessages.add(SentDatagram(buffer, address, port));
return buffer.length;
}
@@ -61,8 +61,8 @@ class FakeDatagramSocket implements RawDatagramSocket {
dynamic noSuchMethod(Invocation invocation) => null;
}
-class _SentDatagram {
- _SentDatagram(this.data, this.address, this.port);
+class SentDatagram {
+ SentDatagram(this.data, this.address, this.port);
final List data;
final InternetAddress address;
final int port;
@@ -118,7 +118,6 @@ void main() {
});
test('ignores own messages', () async {
- final state = PomodoroState.initial();
final message = jsonEncode({
'deviceId': 'test-device-1', // Same as our device.
'timestamp': DateTime.now().millisecondsSinceEpoch,
@@ -212,7 +211,7 @@ void main() {
PomodoroState? received;
final sender = SyncService(
- onStateReceived: (_, __) {},
+ onStateReceived: (_, _) {},
deviceId: 'sender',
socketFactory: (h, p) async => fakeSocket,
);