From 86bc5927917777b4e81dc74c97601b8ee852a6d5 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 21 Feb 2026 20:40:33 +0100 Subject: [PATCH] feat: moviepy showcase full --- .../android/app/src/main/AndroidManifest.xml | 1 + pomodoro_app/lib/main.dart | 24 ++ pomodoro_app/lib/models/pomodoro_state.dart | 205 +++++++++++++ pomodoro_app/lib/screens/pomodoro_screen.dart | 167 +++++++++++ .../lib/services/notification_service.dart | 155 ++++++++++ pomodoro_app/lib/services/pomodoro_timer.dart | 269 ++++++++++++++++++ pomodoro_app/lib/services/sound_service.dart | 78 +++++ pomodoro_app/lib/services/sync_service.dart | 252 ++++++++++++++++ pomodoro_app/lib/theme/pomodoro_theme.dart | 73 +++++ .../lib/widgets/pomodoro_indicators.dart | 47 +++ pomodoro_app/lib/widgets/timer_controls.dart | 79 +++++ pomodoro_app/lib/widgets/timer_display.dart | 79 +++++ pomodoro_app/packaging/arch/PKGBUILD | 8 +- .../test/models/pomodoro_state_test.dart | 45 +++ .../test/screens/pomodoro_screen_test.dart | 29 ++ .../services/notification_service_test.dart | 211 ++++++++++++++ .../test/services/pomodoro_timer_test.dart | 64 +++++ .../test/services/sync_service_test.dart | 11 +- 18 files changed, 1787 insertions(+), 10 deletions(-) create mode 100644 pomodoro_app/lib/main.dart create mode 100644 pomodoro_app/lib/models/pomodoro_state.dart create mode 100644 pomodoro_app/lib/screens/pomodoro_screen.dart create mode 100644 pomodoro_app/lib/services/notification_service.dart create mode 100644 pomodoro_app/lib/services/pomodoro_timer.dart create mode 100644 pomodoro_app/lib/services/sound_service.dart create mode 100644 pomodoro_app/lib/services/sync_service.dart create mode 100644 pomodoro_app/lib/theme/pomodoro_theme.dart create mode 100644 pomodoro_app/lib/widgets/pomodoro_indicators.dart create mode 100644 pomodoro_app/lib/widgets/timer_controls.dart create mode 100644 pomodoro_app/lib/widgets/timer_display.dart create mode 100644 pomodoro_app/test/services/notification_service_test.dart 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, );