mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-07-04 15:43:11 +02:00
feat: moviepy showcase full
This commit is contained in:
parent
7a7db7d25d
commit
86bc592791
@ -3,6 +3,7 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
<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.CHANGE_WIFI_MULTICAST_STATE"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<application
|
<application
|
||||||
android:label="pomodoro_app"
|
android:label="pomodoro_app"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
24
pomodoro_app/lib/main.dart
Normal file
24
pomodoro_app/lib/main.dart
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
205
pomodoro_app/lib/models/pomodoro_state.dart
Normal file
205
pomodoro_app/lib/models/pomodoro_state.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
pomodoro_app/lib/screens/pomodoro_screen.dart
Normal file
167
pomodoro_app/lib/screens/pomodoro_screen.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
155
pomodoro_app/lib/services/notification_service.dart
Normal file
155
pomodoro_app/lib/services/notification_service.dart
Normal 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) {}
|
||||||
269
pomodoro_app/lib/services/pomodoro_timer.dart
Normal file
269
pomodoro_app/lib/services/pomodoro_timer.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
78
pomodoro_app/lib/services/sound_service.dart
Normal file
78
pomodoro_app/lib/services/sound_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
252
pomodoro_app/lib/services/sync_service.dart
Normal file
252
pomodoro_app/lib/services/sync_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
73
pomodoro_app/lib/theme/pomodoro_theme.dart
Normal file
73
pomodoro_app/lib/theme/pomodoro_theme.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
pomodoro_app/lib/widgets/pomodoro_indicators.dart
Normal file
47
pomodoro_app/lib/widgets/pomodoro_indicators.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
pomodoro_app/lib/widgets/timer_controls.dart
Normal file
79
pomodoro_app/lib/widgets/timer_controls.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
pomodoro_app/lib/widgets/timer_display.dart
Normal file
79
pomodoro_app/lib/widgets/timer_display.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -47,10 +47,10 @@ package() {
|
|||||||
"$pkgdir/usr/lib/$pkgname/pomodoro_app"
|
"$pkgdir/usr/lib/$pkgname/pomodoro_app"
|
||||||
|
|
||||||
# Install bundled shared libraries.
|
# Install bundled shared libraries.
|
||||||
install -Dm644 "$_bundle/lib/libapp.so" \
|
for lib in "$_bundle"/lib/*.so; do
|
||||||
"$pkgdir/usr/lib/$pkgname/lib/libapp.so"
|
install -Dm644 "$lib" \
|
||||||
install -Dm644 "$_bundle/lib/libflutter_linux_gtk.so" \
|
"$pkgdir/usr/lib/$pkgname/lib/$(basename "$lib")"
|
||||||
"$pkgdir/usr/lib/$pkgname/lib/libflutter_linux_gtk.so"
|
done
|
||||||
|
|
||||||
# Install data directory.
|
# Install data directory.
|
||||||
install -Dm644 "$_bundle/data/icudtl.dat" \
|
install -Dm644 "$_bundle/data/icudtl.dat" \
|
||||||
|
|||||||
@ -2,6 +2,27 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:pomodoro_app/models/pomodoro_state.dart';
|
import 'package:pomodoro_app/models/pomodoro_state.dart';
|
||||||
|
|
||||||
void main() {
|
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', () {
|
group('PomodoroMode', () {
|
||||||
test('label returns correct strings', () {
|
test('label returns correct strings', () {
|
||||||
expect(PomodoroMode.work.label, 'Work');
|
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', () {
|
group('PomodoroState equality', () {
|
||||||
test('equal states are ==', () {
|
test('equal states are ==', () {
|
||||||
final a = PomodoroState.initial();
|
final a = PomodoroState.initial();
|
||||||
|
|||||||
@ -174,5 +174,34 @@ void main() {
|
|||||||
// We can check that the PomodoroIndicators widget is present.
|
// We can check that the PomodoroIndicators widget is present.
|
||||||
expect(find.text('0 pomodoros completed'), findsOneWidget);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
211
pomodoro_app/test/services/notification_service_test.dart
Normal file
211
pomodoro_app/test/services/notification_service_test.dart
Normal 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}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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()', () {
|
group('applyRemoteState()', () {
|
||||||
test('applies remote state and notifies listeners', () {
|
test('applies remote state and notifies listeners', () {
|
||||||
var notified = false;
|
var notified = false;
|
||||||
|
|||||||
@ -10,12 +10,12 @@ import 'package:pomodoro_app/services/sync_service.dart';
|
|||||||
/// injecting received messages.
|
/// injecting received messages.
|
||||||
class FakeDatagramSocket implements RawDatagramSocket {
|
class FakeDatagramSocket implements RawDatagramSocket {
|
||||||
final _controller = StreamController<RawSocketEvent>.broadcast();
|
final _controller = StreamController<RawSocketEvent>.broadcast();
|
||||||
final List<_SentDatagram> sentMessages = [];
|
final List<SentDatagram> sentMessages = [];
|
||||||
Datagram? _pendingDatagram;
|
Datagram? _pendingDatagram;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int send(List<int> buffer, InternetAddress address, int port) {
|
int send(List<int> buffer, InternetAddress address, int port) {
|
||||||
sentMessages.add(_SentDatagram(buffer, address, port));
|
sentMessages.add(SentDatagram(buffer, address, port));
|
||||||
return buffer.length;
|
return buffer.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,8 +61,8 @@ class FakeDatagramSocket implements RawDatagramSocket {
|
|||||||
dynamic noSuchMethod(Invocation invocation) => null;
|
dynamic noSuchMethod(Invocation invocation) => null;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SentDatagram {
|
class SentDatagram {
|
||||||
_SentDatagram(this.data, this.address, this.port);
|
SentDatagram(this.data, this.address, this.port);
|
||||||
final List<int> data;
|
final List<int> data;
|
||||||
final InternetAddress address;
|
final InternetAddress address;
|
||||||
final int port;
|
final int port;
|
||||||
@ -118,7 +118,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('ignores own messages', () async {
|
test('ignores own messages', () async {
|
||||||
final state = PomodoroState.initial();
|
|
||||||
final message = jsonEncode({
|
final message = jsonEncode({
|
||||||
'deviceId': 'test-device-1', // Same as our device.
|
'deviceId': 'test-device-1', // Same as our device.
|
||||||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||||||
@ -212,7 +211,7 @@ void main() {
|
|||||||
PomodoroState? received;
|
PomodoroState? received;
|
||||||
|
|
||||||
final sender = SyncService(
|
final sender = SyncService(
|
||||||
onStateReceived: (_, __) {},
|
onStateReceived: (_, _) {},
|
||||||
deviceId: 'sender',
|
deviceId: 'sender',
|
||||||
socketFactory: (h, p) async => fakeSocket,
|
socketFactory: (h, p) async => fakeSocket,
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user