feat: moviepy showcase full

This commit is contained in:
Krzysztof kuhy Rudnicki 2026-02-21 20:40:33 +01:00
parent 7a7db7d25d
commit 86bc592791
18 changed files with 1787 additions and 10 deletions

View File

@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="pomodoro_app"
android:name="${applicationName}"

View File

@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'screens/pomodoro_screen.dart';
import 'theme/pomodoro_theme.dart';
void main() {
runApp(const PomodoroApp());
}
/// The root widget of the Pomodoro application.
class PomodoroApp extends StatelessWidget {
/// Creates a [PomodoroApp].
const PomodoroApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Pomodoro',
debugShowCheckedModeBanner: false,
theme: PomodoroTheme.darkTheme,
home: const PomodoroScreen(),
);
}
}

View File

@ -0,0 +1,205 @@
/// Defines the timer style (technique) the user can choose.
enum TimerStyle {
/// Classic Pomodoro: 25 min work, 5 min short break, 15 min long break.
pomodoro,
/// Ultraradian rhythm: 90 min work, 30 min break.
ultraradian,
}
/// Extension on [TimerStyle] to provide display labels and default durations.
extension TimerStyleConfig on TimerStyle {
/// Human-readable label for the timer style.
String get label {
switch (this) {
case TimerStyle.pomodoro:
return 'Pomodoro';
case TimerStyle.ultraradian:
return 'Ultraradian';
}
}
/// Default work duration in minutes.
int get defaultWorkMinutes {
switch (this) {
case TimerStyle.pomodoro:
return 25;
case TimerStyle.ultraradian:
return 90;
}
}
/// Default short break duration in minutes.
int get defaultShortBreakMinutes {
switch (this) {
case TimerStyle.pomodoro:
return 5;
case TimerStyle.ultraradian:
return 30;
}
}
/// Default long break duration in minutes.
int get defaultLongBreakMinutes {
switch (this) {
case TimerStyle.pomodoro:
return 15;
case TimerStyle.ultraradian:
return 30;
}
}
/// Default number of work sessions before a long break.
int get defaultPomodorosPerCycle {
switch (this) {
case TimerStyle.pomodoro:
return 4;
case TimerStyle.ultraradian:
return 1;
}
}
}
/// Defines the different modes of a Pomodoro session.
enum PomodoroMode {
/// A work session (default 25 minutes).
work,
/// A short break between work sessions (default 5 minutes).
shortBreak,
/// A long break after completing a cycle (default 15 minutes).
longBreak,
}
/// Extension on [PomodoroMode] to provide display labels.
extension PomodoroModeLabel on PomodoroMode {
/// Human-readable label for the mode.
String get label {
switch (this) {
case PomodoroMode.work:
return 'Work';
case PomodoroMode.shortBreak:
return 'Short Break';
case PomodoroMode.longBreak:
return 'Long Break';
}
}
}
/// Immutable snapshot of the Pomodoro timer state.
class PomodoroState {
/// Creates a [PomodoroState].
const PomodoroState({
required this.mode,
required this.remainingSeconds,
required this.totalSeconds,
required this.isRunning,
required this.completedPomodoros,
required this.pomodorosPerCycle,
});
/// Creates the default initial state.
factory PomodoroState.initial({
int workMinutes = 25,
int shortBreakMinutes = 5,
int longBreakMinutes = 15,
int pomodorosPerCycle = 4,
}) {
final totalSeconds = workMinutes * 60;
return PomodoroState(
mode: PomodoroMode.work,
remainingSeconds: totalSeconds,
totalSeconds: totalSeconds,
isRunning: false,
completedPomodoros: 0,
pomodorosPerCycle: pomodorosPerCycle,
);
}
/// The current timer mode.
final PomodoroMode mode;
/// Seconds left on the current timer.
final int remainingSeconds;
/// Total seconds for the current mode.
final int totalSeconds;
/// Whether the timer is currently running.
final bool isRunning;
/// Number of completed work sessions in the current cycle.
final int completedPomodoros;
/// Number of pomodoros before a long break.
final int pomodorosPerCycle;
/// Progress as a value between 0.0 and 1.0.
double get progress {
if (totalSeconds == 0) return 1.0;
return 1.0 - (remainingSeconds / totalSeconds);
}
/// Display label for the current mode, context-aware.
///
/// When [pomodorosPerCycle] is 1 (e.g. ultraradian), breaks are simply
/// labelled "Break" instead of "Short Break" or "Long Break".
String get modeDisplayLabel {
if (pomodorosPerCycle <= 1 && mode != PomodoroMode.work) {
return 'Break';
}
return mode.label;
}
/// Formatted time string (MM:SS).
String get formattedTime {
final minutes = remainingSeconds ~/ 60;
final seconds = remainingSeconds % 60;
return '${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
}
/// Creates a copy with the given fields replaced.
PomodoroState copyWith({
PomodoroMode? mode,
int? remainingSeconds,
int? totalSeconds,
bool? isRunning,
int? completedPomodoros,
int? pomodorosPerCycle,
}) {
return PomodoroState(
mode: mode ?? this.mode,
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
totalSeconds: totalSeconds ?? this.totalSeconds,
isRunning: isRunning ?? this.isRunning,
completedPomodoros: completedPomodoros ?? this.completedPomodoros,
pomodorosPerCycle: pomodorosPerCycle ?? this.pomodorosPerCycle,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PomodoroState &&
other.mode == mode &&
other.remainingSeconds == remainingSeconds &&
other.totalSeconds == totalSeconds &&
other.isRunning == isRunning &&
other.completedPomodoros == completedPomodoros &&
other.pomodorosPerCycle == pomodorosPerCycle;
}
@override
int get hashCode {
return Object.hash(
mode,
remainingSeconds,
totalSeconds,
isRunning,
completedPomodoros,
pomodorosPerCycle,
);
}
}

View File

@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import '../models/pomodoro_state.dart';
import '../services/pomodoro_timer.dart';
import '../services/notification_service.dart';
import '../services/sound_service.dart';
import '../services/sync_service.dart';
import '../widgets/pomodoro_indicators.dart';
import '../widgets/timer_controls.dart';
import '../widgets/timer_display.dart';
/// The main screen of the Pomodoro app.
///
/// Displays the timer, controls, and session indicators in a responsive
/// layout that works on both mobile and desktop.
class PomodoroScreen extends StatefulWidget {
/// Creates a [PomodoroScreen].
const PomodoroScreen({this.timer, this.syncService, super.key});
/// Optional timer instance for testing. If null, creates a default one.
final PomodoroTimer? timer;
/// Optional sync service for testing. If null, creates a default one.
final SyncService? syncService;
@override
State<PomodoroScreen> createState() => PomodoroScreenState();
}
/// State for [PomodoroScreen], exposed for testing.
@visibleForTesting
class PomodoroScreenState extends State<PomodoroScreen> {
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<void> _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<TimerStyle>(
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),
],
),
),
),
),
),
);
}
}

View File

@ -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<ProcessResult> Function(String, List<String>)? runProcess,
}) : _runProcess = runProcess ?? Process.run;
final Future<ProcessResult> Function(String, List<String>) _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<void> 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<void> 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<void> 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<void> _notify({
required String title,
required String body,
List<String> 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<void> future) {}

View File

@ -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();
}
}

View File

@ -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<void> Function(String assetPath)? playCallback,
}) : _playCallback = playCallback;
final Future<void> 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<void> 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;
}
}

View File

@ -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<RawDatagramSocket> 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<RawDatagramSocket> 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<void> 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<void> 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(<String, dynamic>{
'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<String, dynamic>;
// Ignore own messages.
if (map['deviceId'] == deviceId) return;
final state = _decodeState(map['state'] as Map<String, dynamic>);
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<int> _encodeMessage(PomodoroState state, String action) {
final map = <String, dynamic>{
'deviceId': deviceId,
'timestamp': DateTime.now().millisecondsSinceEpoch,
'action': action,
'state': _encodeState(state),
};
return utf8.encode(jsonEncode(map));
}
static Map<String, dynamic> _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<String, dynamic> 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<void> _acquireMulticastLock() async {
if (!Platform.isAndroid) return;
try {
await _methodChannel.invokeMethod<bool>('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<void> _releaseMulticastLock() async {
if (!Platform.isAndroid) return;
try {
await _methodChannel.invokeMethod<bool>('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();
}
}

View File

@ -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,
),
),
),
);
}
}

View File

@ -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,
),
),
),
);
},
),
);
}
}

View File

@ -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,
),
],
);
}
}

View File

@ -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,
),
),
],
),
),
],
),
);
},
);
}
}

View File

@ -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" \

View File

@ -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();

View File

@ -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);
});
});
}

View File

@ -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<String> args;
}
void main() {
group('NotificationService', () {
late List<_Call> calls;
late NotificationService service;
Future<ProcessResult> mockRun(String exec, List<String> 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}');
});
});
}

View File

@ -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;

View File

@ -10,12 +10,12 @@ import 'package:pomodoro_app/services/sync_service.dart';
/// injecting received messages.
class FakeDatagramSocket implements RawDatagramSocket {
final _controller = StreamController<RawSocketEvent>.broadcast();
final List<_SentDatagram> sentMessages = [];
final List<SentDatagram> sentMessages = [];
Datagram? _pendingDatagram;
@override
int send(List<int> 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<int> 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,
);