mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-07-04 13:43:02 +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.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}"
|
||||
|
||||
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"
|
||||
|
||||
# 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" \
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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()', () {
|
||||
test('applies remote state and notifies listeners', () {
|
||||
var notified = false;
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user