feat: moviepy showcase full

This commit is contained in:
Krzysztof kuhy Rudnicki 2026-02-21 20:40:33 +01:00
parent 6ec85106b7
commit 8fb2e96363
23 changed files with 3582 additions and 20 deletions

2
.gitignore vendored
View File

@ -41,7 +41,9 @@ testem.log
.DS_Store
Thumbs.db
*.mkv
*.mp4
imageviewer
title_test.png
# Byte-compiled / optimized / DLL files
__pycache__/

View File

@ -15,6 +15,8 @@ com.glovo.courier
com.wolt.android
# Bolt Food / Bolt
com.bolt.deliveryclient
ee.mtakso.client
ee.mtakso.food
ee.mtakso

View File

@ -0,0 +1,200 @@
#!/usr/bin/env bash
set -euo pipefail
# docx_to_pdf.sh
#
# Convert one or more DOCX files (or directories containing them) to PDF
# using LibreOffice.
# Source common library
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
# shellcheck source=../lib/common.sh
source "$SCRIPT_DIR/../lib/common.sh"
OUTPUT_DIR=""
RECURSIVE=false
MERGE=false
MERGE_NAME="merged.pdf"
PRINT=false
DOCX_FILES=()
usage() {
cat << EOF
Usage:
$(basename "$0") [OPTIONS] PATH [PATH...]
Convert DOCX files to PDF using LibreOffice.
PATH can be a DOCX file or a directory containing DOCX files.
Options:
-o DIR Output directory (default: same as input)
-m NAME Merge all PDFs into one file (default: merged.pdf)
-p Print the merged PDF (requires -m)
-r Search directories recursively for DOCX files
-h Show this help
Examples:
$(basename "$0") file.docx
$(basename "$0") -o out file1.docx file2.docx
$(basename "$0") /path/to/docs/
$(basename "$0") -m merged.pdf /path/to/docs/
$(basename "$0") -m combined.pdf -p /path/to/docs/
$(basename "$0") -r -o out /path/to/docs/
EOF
}
ensure_libreoffice() {
if ! command -v libreoffice > /dev/null 2>&1; then
echo "Error: 'libreoffice' is not installed or not in PATH." >&2
echo "Install it with: sudo pacman -S libreoffice-fresh (Arch)" >&2
echo " sudo apt install libreoffice (Debian/Ubuntu)" >&2
exit 1
fi
}
ensure_pdfunite() {
if ! command -v pdfunite > /dev/null 2>&1; then
echo "Error: 'pdfunite' is not installed or not in PATH." >&2
echo "Install it with: sudo pacman -S poppler (Arch)" >&2
echo " sudo apt install poppler-utils (Debian/Ubuntu)" >&2
exit 1
fi
}
parse_args() {
local opt
OUTPUT_DIR=""
DOCX_FILES=()
while getopts ":o:m:prh" opt; do
case "$opt" in
o)
OUTPUT_DIR="$OPTARG"
;;
m)
MERGE=true
MERGE_NAME="$OPTARG"
;;
p)
PRINT=true
;;
r)
RECURSIVE=true
;;
h)
usage
exit 0
;;
*)
usage
exit 1
;;
esac
done
shift $((OPTIND - 1))
if [[ $# -lt 1 ]]; then
echo "Error: at least one DOCX file or directory must be specified." >&2
usage
exit 1
fi
local arg
local input_dir=""
for arg in "$@"; do
if [[ -d $arg ]]; then
collect_from_dir "$arg"
input_dir="$arg"
else
DOCX_FILES+=("$arg")
fi
done
if [[ ${#DOCX_FILES[@]} -eq 0 ]]; then
echo "Error: no DOCX files found." >&2
exit 1
fi
if [[ -z ${OUTPUT_DIR:-} ]]; then
if [[ -n $input_dir ]]; then
OUTPUT_DIR="$input_dir"
else
OUTPUT_DIR="$(dirname "${DOCX_FILES[0]}")"
fi
fi
if [[ ! -d $OUTPUT_DIR ]]; then
mkdir -p "$OUTPUT_DIR"
fi
}
collect_from_dir() {
local dir="$1"
local found
if [[ $RECURSIVE == true ]]; then
while IFS= read -r -d '' found; do
DOCX_FILES+=("$found")
done < <(find "$dir" -type f -iname '*.docx' -print0 | sort -z)
else
for found in "$dir"/*.docx "$dir"/*.DOCX; do
if [[ -f $found ]]; then
DOCX_FILES+=("$found")
fi
done
fi
}
convert_docx() {
local docx_file="$1"
log "Converting '$docx_file' to PDF -> ${OUTPUT_DIR}/"
libreoffice --headless --convert-to pdf --outdir "$OUTPUT_DIR" "$docx_file"
}
main() {
ensure_libreoffice
parse_args "$@"
if [[ $MERGE == true ]]; then
ensure_pdfunite
fi
if [[ $PRINT == true && $MERGE != true ]]; then
echo "Error: -p (print) requires -m (merge)." >&2
exit 1
fi
local docx
local pdf_files=()
for docx in "${DOCX_FILES[@]}"; do
if [[ ! -f $docx ]]; then
echo "Warning: '$docx' is not a regular file, skipping." >&2
continue
fi
convert_docx "$docx"
local base
base="$(basename "${docx%.*}").pdf"
pdf_files+=("${OUTPUT_DIR%/}/$base")
done
if [[ $MERGE == true && ${#pdf_files[@]} -gt 0 ]]; then
local merged_path="${OUTPUT_DIR%/}/${MERGE_NAME}"
log "Merging ${#pdf_files[@]} PDFs into '$merged_path'"
pdfunite "${pdf_files[@]}" "$merged_path"
log "Merged PDF created: $merged_path"
if [[ $PRINT == true ]]; then
log "Sending '$merged_path' to printer"
lp "$merged_path"
fi
fi
log "Done converting DOCX files to PDF. Output directory: $OUTPUT_DIR"
}
main "$@"

1197
moviepy_showcase.py Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import '../models/pomodoro_state.dart';
import '../services/pomodoro_timer.dart';
import '../services/notification_service.dart';
import '../services/sound_service.dart';
import '../services/sync_service.dart';
import '../widgets/pomodoro_indicators.dart';
import '../widgets/timer_controls.dart';
import '../widgets/timer_display.dart';
/// The main screen of the Pomodoro app.
///
/// Displays the timer, controls, and session indicators in a responsive
/// layout that works on both mobile and desktop.
class PomodoroScreen extends StatefulWidget {
/// Creates a [PomodoroScreen].
const PomodoroScreen({this.timer, this.syncService, super.key});
/// Optional timer instance for testing. If null, creates a default one.
final PomodoroTimer? timer;
/// Optional sync service for testing. If null, creates a default one.
final SyncService? syncService;
@override
State<PomodoroScreen> createState() => PomodoroScreenState();
}
/// State for [PomodoroScreen], exposed for testing.
@visibleForTesting
class PomodoroScreenState extends State<PomodoroScreen> {
PomodoroTimer? _timer;
SyncService? _syncService;
bool _ownsTimer = false;
bool _ownsSyncService = false;
bool _initialized = false;
@override
void initState() {
super.initState();
if (widget.timer != null) {
// Test path: synchronous init, no sync service needed.
_timer = widget.timer!;
_syncService = widget.syncService;
_timer!.addListener(_onTimerChanged);
_initialized = true;
} else {
// Production path: async init with sync service.
_initAsync();
}
}
Future<void> _initAsync() async {
_syncService = SyncService(
onStateReceived: _onRemoteState,
);
_ownsSyncService = true;
await _syncService!.start();
_timer = PomodoroTimer(
syncService: _syncService,
soundService: SoundService(),
notificationService: NotificationService(),
);
_ownsTimer = true;
_timer!.addListener(_onTimerChanged);
_initialized = true;
if (mounted) setState(() {});
}
void _onRemoteState(PomodoroState state, String action) {
_timer?.applyRemoteState(state, action);
}
void _onTimerChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
_timer?.removeListener(_onTimerChanged);
if (_ownsTimer) _timer?.dispose();
if (_ownsSyncService) _syncService?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_initialized || _timer == null) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final timer = _timer!;
final state = timer.state;
return Scaffold(
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Spacer(flex: 2),
// Timer style picker.
SegmentedButton<TimerStyle>(
segments: const [
ButtonSegment(
value: TimerStyle.pomodoro,
label: Text('Pomodoro'),
icon: Icon(Icons.timer),
),
ButtonSegment(
value: TimerStyle.ultraradian,
label: Text('Ultraradian'),
icon: Icon(Icons.self_improvement),
),
],
selected: {timer.timerStyle},
onSelectionChanged: (selected) {
timer.switchStyle(selected.first);
},
),
const SizedBox(height: 16),
// Timer display.
Expanded(
flex: 5,
child: TimerDisplay(state: state),
),
const SizedBox(height: 32),
// Controls.
TimerControls(
state: state,
onStart: timer.start,
onPause: timer.pause,
onReset: timer.reset,
onSkip: timer.skip,
),
const SizedBox(height: 32),
// Session indicators.
PomodoroIndicators(state: state),
const SizedBox(height: 16),
// Completed count.
Text(
'${state.completedPomodoros} '
'${timer.timerStyle.label.toLowerCase()}'
'${state.completedPomodoros == 1 ? '' : 's'}'
' completed',
style: Theme.of(context).textTheme.bodyLarge,
),
const Spacer(flex: 2),
],
),
),
),
),
),
);
}
}

View File

@ -0,0 +1,155 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../models/pomodoro_state.dart';
/// Sends desktop notifications showing Pomodoro timer status.
///
/// Uses the freedesktop D-Bus Notifications interface via `gdbus` to show,
/// update, and dismiss notifications. The notification includes the current
/// mode, remaining time, and a progress bar. Action buttons (Pause / Skip /
/// Start) are displayed for quick interaction.
class NotificationService {
/// Creates a [NotificationService].
///
/// Pass a custom [runProcess] for testing.
NotificationService({
@visibleForTesting
Future<ProcessResult> Function(String, List<String>)? runProcess,
}) : _runProcess = runProcess ?? Process.run;
final Future<ProcessResult> Function(String, List<String>) _runProcess;
int _currentId = 0;
bool _disposed = false;
static const _dbusDest = 'org.freedesktop.Notifications';
static const _dbusPath = '/org/freedesktop/Notifications';
/// The notification ID currently shown (0 means none).
@visibleForTesting
int get currentId => _currentId;
/// Shows or updates the timer notification with the current [state].
///
/// The notification replaces any previous one so only a single
/// notification is visible at a time.
Future<void> showTimer({required PomodoroState state}) async {
if (_disposed) return;
final title = '${state.mode.label} \u2013 ${state.formattedTime}';
final body = _progressBar(state.progress);
await _notify(
title: title,
body: body,
actions: state.isRunning
? ['pause', 'Pause', 'skip', 'Skip']
: ['start', 'Start'],
);
}
/// Shows a notification that the session has completed.
Future<void> showSessionComplete({
required PomodoroMode completedMode,
required PomodoroMode nextMode,
}) async {
if (_disposed) return;
final title = '${completedMode.label} complete!';
final body = 'Up next: ${nextMode.label}';
await _notify(title: title, body: body, actions: ['start', 'Start']);
}
/// Cancels the currently shown notification.
Future<void> cancel() async {
if (_disposed || _currentId == 0) return;
try {
await _runProcess('gdbus', [
'call',
'--session',
'--dest',
_dbusDest,
'--object-path',
_dbusPath,
'--method',
'org.freedesktop.Notifications.CloseNotification',
'$_currentId',
]);
} on Object catch (e) {
debugPrint('NotificationService: Close error: $e');
}
_currentId = 0;
}
/// Releases resources. Does not await the underlying cancel.
void dispose() {
if (_disposed) return;
if (_currentId != 0) {
// Fire-and-forget; the notification daemon cleans up on exit.
unawaited(cancel());
}
_disposed = true;
}
// ------------------------------------------------------------------
// Private helpers
// ------------------------------------------------------------------
Future<void> _notify({
required String title,
required String body,
List<String> actions = const [],
}) async {
final actionsStr = actions.isEmpty
? '[]'
: '[${actions.map((a) => "'$a'").join(', ')}]';
try {
final result = await _runProcess('gdbus', [
'call',
'--session',
'--dest',
_dbusDest,
'--object-path',
_dbusPath,
'--method',
'org.freedesktop.Notifications.Notify',
'Pomodoro',
'$_currentId',
'appointment-soon',
title,
body,
actionsStr,
'{}',
'0',
]);
final match =
RegExp(r'\(uint32 (\d+),?\)').firstMatch(result.stdout as String);
if (match != null) {
_currentId = int.parse(match.group(1)!);
}
} on Object catch (e) {
debugPrint('NotificationService: Notify error: $e');
}
}
/// Builds a text-based progress bar for the notification body.
@visibleForTesting
static String progressBar(double progress) => _progressBar(progress);
static String _progressBar(double progress) {
const total = 20;
final filled = (progress * total).round();
final empty = total - filled;
return '${'' * filled}${'' * empty}';
}
}
/// Completes a future without requiring `await`.
///
/// Prevents the `unawaited_futures` lint in fire-and-forget calls.
void unawaited(Future<void> future) {}

View File

@ -0,0 +1,269 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/pomodoro_state.dart';
import 'notification_service.dart';
import 'sound_service.dart';
import 'sync_service.dart';
/// Manages the Pomodoro timer logic, independent of UI framework.
///
/// Optionally synchronizes state across devices via [SyncService].
class PomodoroTimer extends ChangeNotifier {
/// Creates a [PomodoroTimer] with configurable durations.
PomodoroTimer({
int? workMinutes,
int? shortBreakMinutes,
int? longBreakMinutes,
int? pomodorosPerCycle,
TimerStyle timerStyle = TimerStyle.pomodoro,
this.syncService,
SoundService? soundService,
NotificationService? notificationService,
@visibleForTesting Timer Function(Duration, void Function(Timer))? timerFactory,
}) : _timerStyle = timerStyle,
_soundService = soundService,
_notificationService = notificationService,
_timerFactory = timerFactory ?? Timer.periodic {
_workMinutes = workMinutes ?? timerStyle.defaultWorkMinutes;
_shortBreakMinutes = shortBreakMinutes ?? timerStyle.defaultShortBreakMinutes;
_longBreakMinutes = longBreakMinutes ?? timerStyle.defaultLongBreakMinutes;
_pomodorosPerCycle = pomodorosPerCycle ?? timerStyle.defaultPomodorosPerCycle;
_state = PomodoroState.initial(
workMinutes: _workMinutes,
shortBreakMinutes: _shortBreakMinutes,
longBreakMinutes: _longBreakMinutes,
pomodorosPerCycle: _pomodorosPerCycle,
);
}
/// Duration of a work session in minutes.
late int _workMinutes;
/// Duration of a short break in minutes.
late int _shortBreakMinutes;
/// Duration of a long break in minutes.
late int _longBreakMinutes;
/// Number of work sessions before a long break.
late int _pomodorosPerCycle;
/// The current timer style.
TimerStyle _timerStyle;
/// Optional sync service for LAN synchronization.
final SyncService? syncService;
final SoundService? _soundService;
final NotificationService? _notificationService;
final Timer Function(Duration, void Function(Timer)) _timerFactory;
late PomodoroState _state;
Timer? _timer;
/// Whether we are currently applying a remote state (prevents echo).
bool _applyingRemote = false;
/// The current state of the timer.
PomodoroState get state => _state;
/// The active timer style.
TimerStyle get timerStyle => _timerStyle;
/// Switches to a different timer style, resetting all progress.
void switchStyle(TimerStyle style) {
if (style == _timerStyle) return;
_timer?.cancel();
_timer = null;
_timerStyle = style;
_workMinutes = style.defaultWorkMinutes;
_shortBreakMinutes = style.defaultShortBreakMinutes;
_longBreakMinutes = style.defaultLongBreakMinutes;
_pomodorosPerCycle = style.defaultPomodorosPerCycle;
_state = PomodoroState.initial(
workMinutes: _workMinutes,
shortBreakMinutes: _shortBreakMinutes,
longBreakMinutes: _longBreakMinutes,
pomodorosPerCycle: _pomodorosPerCycle,
);
_notificationService?.cancel();
notifyListeners();
syncService?.stopHeartbeat();
}
/// Starts or resumes the timer.
void start() {
if (_state.isRunning) return;
_state = _state.copyWith(isRunning: true);
notifyListeners();
_startTicking();
_notificationService?.showTimer(state: _state);
_broadcastIfLocal('start');
syncService?.startHeartbeat(() => _state);
}
/// Pauses the timer.
void pause() {
if (!_state.isRunning) return;
_timer?.cancel();
_timer = null;
_state = _state.copyWith(isRunning: false);
_notificationService?.cancel();
notifyListeners();
_broadcastIfLocal('pause');
syncService?.stopHeartbeat();
}
/// Resets the current session timer without changing the mode.
void reset() {
_timer?.cancel();
_timer = null;
_state = _state.copyWith(
remainingSeconds: _state.totalSeconds,
isRunning: false,
);
_notificationService?.cancel();
notifyListeners();
_broadcastIfLocal('reset');
syncService?.stopHeartbeat();
}
/// Skips to the next session, treating the current one as completed.
void skip() {
_timer?.cancel();
_timer = null;
_onSessionComplete();
_broadcastIfLocal('skip');
syncService?.stopHeartbeat();
}
/// Applies state received from a remote device via [SyncService].
void applyRemoteState(PomodoroState remoteState, String action) {
_applyingRemote = true;
_timer?.cancel();
_timer = null;
_state = remoteState;
if (_state.isRunning) {
_startTicking();
}
notifyListeners();
_applyingRemote = false;
}
void _tick(Timer timer) {
if (_state.remainingSeconds <= 1) {
timer.cancel();
_timer = null;
_onSessionComplete();
} else {
_state = _state.copyWith(
remainingSeconds: _state.remainingSeconds - 1,
);
_updateNotification();
notifyListeners();
}
}
void _onSessionComplete() {
if (_state.mode == PomodoroMode.work) {
final newCompleted = _state.completedPomodoros + 1;
_state = _state.copyWith(
completedPomodoros: newCompleted,
remainingSeconds: 0,
isRunning: false,
);
} else {
_state = _state.copyWith(
remainingSeconds: 0,
isRunning: false,
);
}
notifyListeners();
final completedMode = _state.mode;
_advanceToNextMode();
_soundService?.playTransitionSound(
completedMode: completedMode,
nextMode: _state.mode,
);
_notificationService?.showSessionComplete(
completedMode: completedMode,
nextMode: _state.mode,
);
notifyListeners();
}
void _advanceToNextMode() {
switch (_state.mode) {
case PomodoroMode.work:
if (_state.completedPomodoros > 0 &&
_state.completedPomodoros % _pomodorosPerCycle == 0) {
_setMode(PomodoroMode.longBreak);
} else {
_setMode(PomodoroMode.shortBreak);
}
case PomodoroMode.shortBreak:
case PomodoroMode.longBreak:
_setMode(PomodoroMode.work);
}
}
void _setMode(PomodoroMode mode) {
final totalSeconds = _durationForMode(mode) * 60;
_state = _state.copyWith(
mode: mode,
remainingSeconds: totalSeconds,
totalSeconds: totalSeconds,
isRunning: false,
);
}
int _durationForMode(PomodoroMode mode) {
switch (mode) {
case PomodoroMode.work:
return _workMinutes;
case PomodoroMode.shortBreak:
return _shortBreakMinutes;
case PomodoroMode.longBreak:
return _longBreakMinutes;
}
}
void _startTicking() {
_timer = _timerFactory(
const Duration(seconds: 1),
_tick,
);
}
/// Broadcasts state to peers only if this is a local user action.
void _broadcastIfLocal(String action) {
if (!_applyingRemote) {
syncService?.broadcast(_state, action);
}
}
/// Interval in seconds between notification updates while running.
static const _notifyIntervalSeconds = 30;
void _updateNotification() {
if (_notificationService == null) return;
if (_state.remainingSeconds % _notifyIntervalSeconds == 0) {
_notificationService.showTimer(state: _state);
}
}
@override
void dispose() {
_timer?.cancel();
syncService?.stopHeartbeat();
_soundService?.dispose();
_notificationService?.dispose();
super.dispose();
}
}

View File

@ -0,0 +1,78 @@
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
import '../models/pomodoro_state.dart';
/// Plays notification sounds for Pomodoro timer transitions.
///
/// Each transition type has a distinct sound:
/// - Work done ascending chime
/// - Short break done gentle double ping
/// - Long break starting descending celebration
/// - Long break done rapid wake-up beeps
class SoundService {
/// Creates a [SoundService].
///
/// Pass a custom [playCallback] for testing.
SoundService({
@visibleForTesting Future<void> Function(String assetPath)? playCallback,
}) : _playCallback = playCallback;
final Future<void> Function(String assetPath)? _playCallback;
AudioPlayer? _player;
bool _disposed = false;
static const _assetPrefix = 'sounds';
/// Plays the appropriate sound for a mode transition.
///
/// [completedMode] is the mode that just finished.
/// [nextMode] is the mode that is starting.
Future<void> playTransitionSound({
required PomodoroMode completedMode,
required PomodoroMode nextMode,
}) async {
if (_disposed) return;
final assetPath = _assetForTransition(completedMode, nextMode);
if (assetPath == null) return;
try {
if (_playCallback != null) {
await _playCallback(assetPath);
} else {
_player?.dispose();
_player = AudioPlayer();
await _player!.play(AssetSource('$_assetPrefix/$assetPath'));
}
debugPrint('SoundService: Playing $assetPath');
} on Object catch (e) {
debugPrint('SoundService: Playback error: $e');
}
}
/// Returns the WAV filename for a given transition, or null if none.
static String? _assetForTransition(
PomodoroMode completedMode,
PomodoroMode nextMode,
) {
switch (completedMode) {
case PomodoroMode.work:
if (nextMode == PomodoroMode.longBreak) {
return 'long_break_start.wav';
}
return 'work_done.wav';
case PomodoroMode.shortBreak:
return 'short_break_done.wav';
case PomodoroMode.longBreak:
return 'long_break_done.wav';
}
}
/// Releases audio resources.
void dispose() {
_disposed = true;
_player?.dispose();
_player = null;
}
}

View File

@ -0,0 +1,252 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../models/pomodoro_state.dart';
/// Callback type for receiving a synced [PomodoroState] and action name.
typedef SyncCallback = void Function(PomodoroState state, String action);
/// Provides LAN synchronization between Pomodoro app instances using
/// UDP broadcast.
///
/// Uses subnet broadcast (255.255.255.255) instead of multicast for
/// maximum compatibility across platforms. A unique [deviceId] prevents
/// echo (processing own messages).
class SyncService {
/// Creates a [SyncService].
///
/// [onStateReceived] is called when a remote device broadcasts a state
/// change. [port] can be overridden for testing.
SyncService({
required this.onStateReceived,
this.port = 41234,
@visibleForTesting String? deviceId,
@visibleForTesting
Future<RawDatagramSocket> Function(dynamic host, int port)?
socketFactory,
}) : deviceId = deviceId ?? _generateDeviceId(),
_socketFactory = socketFactory;
/// Unique identifier for this device instance.
final String deviceId;
/// UDP port for sync messages.
final int port;
/// UDP port for wake signals (separate from sync to allow a daemon to
/// listen without conflicting with the app's sync socket).
static const int wakePort = 41235;
/// Called when a state update is received from another device.
final SyncCallback onStateReceived;
final Future<RawDatagramSocket> Function(dynamic host, int port)?
_socketFactory;
RawDatagramSocket? _socket;
Timer? _heartbeat;
bool _disposed = false;
static const _methodChannel = MethodChannel('pomodoro_multicast_lock');
/// Whether the service is currently listening.
bool get isActive => _socket != null && !_disposed;
/// Starts listening for broadcast messages and enables sending.
Future<void> start() async {
if (_disposed) return;
// Acquire Android multicast/broadcast lock.
await _acquireMulticastLock();
try {
if (_socketFactory != null) {
_socket = await _socketFactory(InternetAddress.anyIPv4, port);
} else {
_socket = await RawDatagramSocket.bind(
InternetAddress.anyIPv4,
port,
reuseAddress: true,
);
}
_socket?.broadcastEnabled = true;
_socket?.listen(
_onSocketEvent,
onError: _onError,
cancelOnError: false,
);
debugPrint('SyncService: Listening on port $port (device=$deviceId)');
// Notify other devices that this instance just opened.
_sendWake();
} on Object catch (e) {
debugPrint('SyncService: Failed to start: $e');
}
}
/// Broadcasts the given [state] with an [action] label to all peers.
void broadcast(PomodoroState state, String action) {
if (_socket == null || _disposed) return;
final message = _encodeMessage(state, action);
try {
final sent = _socket!.send(
message,
InternetAddress('255.255.255.255'),
port,
);
debugPrint(
'SyncService: Sent $action ($sent bytes) '
'to 255.255.255.255:$port',
);
} on Object catch (e) {
debugPrint('SyncService: Send failed: $e');
}
}
/// Starts periodic heartbeat that broadcasts current state.
///
/// This keeps devices in sync even if an individual message is lost.
void startHeartbeat(PomodoroState Function() stateProvider) {
_heartbeat?.cancel();
_heartbeat = Timer.periodic(
const Duration(seconds: 5),
(_) => broadcast(stateProvider(), 'heartbeat'),
);
}
/// Stops the periodic heartbeat.
void stopHeartbeat() {
_heartbeat?.cancel();
_heartbeat = null;
}
/// Shuts down the sync service.
Future<void> dispose() async {
_disposed = true;
_heartbeat?.cancel();
_heartbeat = null;
_socket?.close();
_socket = null;
await _releaseMulticastLock();
}
// -- Private helpers --
/// Sends a wake signal to the dedicated wake port so that a desktop
/// daemon can auto-launch the app on other devices.
void _sendWake() {
if (_socket == null || _disposed) return;
final message = utf8.encode(jsonEncode(<String, dynamic>{
'deviceId': deviceId,
'action': 'wake',
'timestamp': DateTime.now().millisecondsSinceEpoch,
}));
try {
_socket!.send(message, InternetAddress('255.255.255.255'), wakePort);
debugPrint('SyncService: Sent wake to port $wakePort');
} on Object catch (e) {
debugPrint('SyncService: Wake send failed: $e');
}
}
void _onSocketEvent(RawSocketEvent event) {
if (event != RawSocketEvent.read) return;
final datagram = _socket?.receive();
if (datagram == null) return;
try {
final json = utf8.decode(datagram.data);
final map = jsonDecode(json) as Map<String, dynamic>;
// Ignore own messages.
if (map['deviceId'] == deviceId) return;
final state = _decodeState(map['state'] as Map<String, dynamic>);
final action = map['action'] as String;
debugPrint(
'SyncService: Received $action from ${map['deviceId']}',
);
onStateReceived(state, action);
} on Object catch (e) {
debugPrint('SyncService: Parse error: $e');
}
}
void _onError(Object error) {
debugPrint('SyncService: Socket error: $error');
}
List<int> _encodeMessage(PomodoroState state, String action) {
final map = <String, dynamic>{
'deviceId': deviceId,
'timestamp': DateTime.now().millisecondsSinceEpoch,
'action': action,
'state': _encodeState(state),
};
return utf8.encode(jsonEncode(map));
}
static Map<String, dynamic> _encodeState(PomodoroState state) {
return {
'mode': state.mode.name,
'remainingSeconds': state.remainingSeconds,
'totalSeconds': state.totalSeconds,
'isRunning': state.isRunning,
'completedPomodoros': state.completedPomodoros,
'pomodorosPerCycle': state.pomodorosPerCycle,
};
}
static PomodoroState _decodeState(Map<String, dynamic> map) {
return PomodoroState(
mode: PomodoroMode.values.byName(map['mode'] as String),
remainingSeconds: map['remainingSeconds'] as int,
totalSeconds: map['totalSeconds'] as int,
isRunning: map['isRunning'] as bool,
completedPomodoros: map['completedPomodoros'] as int,
pomodorosPerCycle: map['pomodorosPerCycle'] as int,
);
}
static Future<void> _acquireMulticastLock() async {
if (!Platform.isAndroid) return;
try {
await _methodChannel.invokeMethod<bool>('acquire');
} on MissingPluginException {
// Platform channel not available (e.g., in tests).
} on Object catch (e) {
debugPrint('SyncService: Failed to acquire multicast lock: $e');
}
}
static Future<void> _releaseMulticastLock() async {
if (!Platform.isAndroid) return;
try {
await _methodChannel.invokeMethod<bool>('release');
} on MissingPluginException {
// Platform channel not available.
} on Object catch (e) {
debugPrint('SyncService: Failed to release multicast lock: $e');
}
}
static String _generateDeviceId() {
final random = Random();
return List.generate(
8,
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
}
}

View File

@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import '../models/pomodoro_state.dart';
/// Provides consistent theming for the Pomodoro app across platforms.
class PomodoroTheme {
PomodoroTheme._();
// Brand colors per mode.
static const Color workColor = Color(0xFFE74C3C);
static const Color shortBreakColor = Color(0xFF2ECC71);
static const Color longBreakColor = Color(0xFF3498DB);
static const Color _darkSurface = Color(0xFF1A1A2E);
static const Color _darkBackground = Color(0xFF16213E);
static const Color _textLight = Color(0xFFF5F5F5);
static const Color _textMuted = Color(0xFFB0B0B0);
/// Returns the accent color for the given [mode].
static Color colorForMode(PomodoroMode mode) {
switch (mode) {
case PomodoroMode.work:
return workColor;
case PomodoroMode.shortBreak:
return shortBreakColor;
case PomodoroMode.longBreak:
return longBreakColor;
}
}
/// The app's dark theme.
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: _darkBackground,
colorScheme: const ColorScheme.dark(
primary: workColor,
surface: _darkSurface,
onSurface: _textLight,
),
textTheme: const TextTheme(
displayLarge: TextStyle(
fontSize: 72,
fontWeight: FontWeight.w300,
color: _textLight,
letterSpacing: 4,
),
headlineMedium: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w500,
color: _textLight,
),
bodyLarge: TextStyle(
fontSize: 16,
color: _textMuted,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
);
}
}

View File

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../models/pomodoro_state.dart';
import '../theme/pomodoro_theme.dart';
/// Shows completed pomodoro indicators as filled/unfilled dots.
class PomodoroIndicators extends StatelessWidget {
/// Creates [PomodoroIndicators].
const PomodoroIndicators({
required this.state,
super.key,
});
/// The current Pomodoro state.
final PomodoroState state;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
state.pomodorosPerCycle,
(index) {
final isCompleted = index < state.completedPomodoros % state.pomodorosPerCycle;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: 14,
height: 14,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isCompleted
? PomodoroTheme.workColor
: Colors.white24,
border: Border.all(
color: PomodoroTheme.workColor.withValues(alpha: 0.5),
width: 2,
),
),
),
);
},
),
);
}
}

View File

@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import '../models/pomodoro_state.dart';
import '../theme/pomodoro_theme.dart';
/// Row of control buttons for the Pomodoro timer.
class TimerControls extends StatelessWidget {
/// Creates [TimerControls].
const TimerControls({
required this.state,
required this.onStart,
required this.onPause,
required this.onReset,
required this.onSkip,
super.key,
});
/// The current Pomodoro state.
final PomodoroState state;
/// Callback when user taps start.
final VoidCallback onStart;
/// Callback when user taps pause.
final VoidCallback onPause;
/// Callback when user taps reset.
final VoidCallback onReset;
/// Callback when user taps skip.
final VoidCallback onSkip;
@override
Widget build(BuildContext context) {
final color = PomodoroTheme.colorForMode(state.mode);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Reset button.
IconButton(
onPressed: onReset,
icon: const Icon(Icons.refresh),
iconSize: 32,
tooltip: 'Reset',
color: Colors.white70,
),
const SizedBox(width: 16),
// Play / Pause button.
SizedBox(
width: 72,
height: 72,
child: ElevatedButton(
onPressed: state.isRunning ? onPause : onStart,
style: ElevatedButton.styleFrom(
backgroundColor: color,
foregroundColor: Colors.white,
shape: const CircleBorder(),
padding: EdgeInsets.zero,
),
child: Icon(
state.isRunning ? Icons.pause : Icons.play_arrow,
size: 36,
),
),
),
const SizedBox(width: 16),
// Skip button.
IconButton(
onPressed: onSkip,
icon: const Icon(Icons.skip_next),
iconSize: 32,
tooltip: 'Skip',
color: Colors.white70,
),
],
);
}
}

View File

@ -0,0 +1,79 @@
import 'dart:math';
import 'package:flutter/material.dart';
import '../models/pomodoro_state.dart';
import '../theme/pomodoro_theme.dart';
/// A circular progress indicator that displays the remaining time.
class TimerDisplay extends StatelessWidget {
/// Creates a [TimerDisplay].
const TimerDisplay({
required this.state,
super.key,
});
/// The current Pomodoro state.
final PomodoroState state;
@override
Widget build(BuildContext context) {
final color = PomodoroTheme.colorForMode(state.mode);
return LayoutBuilder(
builder: (context, constraints) {
final size = min(constraints.maxWidth, constraints.maxHeight) * 0.7;
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
// Background circle.
SizedBox.expand(
child: CircularProgressIndicator(
value: 1.0,
strokeWidth: 8,
color: color.withValues(alpha: 0.2),
),
),
// Progress arc.
SizedBox.expand(
child: CircularProgressIndicator(
value: state.progress,
strokeWidth: 8,
color: color,
strokeCap: StrokeCap.round,
),
),
// Time text and mode label.
FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
state.modeDisplayLabel,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
state.formattedTime,
style:
Theme.of(context).textTheme.displayLarge?.copyWith(
color: Colors.white,
),
),
],
),
),
],
),
);
},
);
}
}

View File

@ -47,10 +47,10 @@ package() {
"$pkgdir/usr/lib/$pkgname/pomodoro_app"
# Install bundled shared libraries.
install -Dm644 "$_bundle/lib/libapp.so" \
"$pkgdir/usr/lib/$pkgname/lib/libapp.so"
install -Dm644 "$_bundle/lib/libflutter_linux_gtk.so" \
"$pkgdir/usr/lib/$pkgname/lib/libflutter_linux_gtk.so"
for lib in "$_bundle"/lib/*.so; do
install -Dm644 "$lib" \
"$pkgdir/usr/lib/$pkgname/lib/$(basename "$lib")"
done
# Install data directory.
install -Dm644 "$_bundle/data/icudtl.dat" \

View File

@ -2,6 +2,27 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:pomodoro_app/models/pomodoro_state.dart';
void main() {
group('TimerStyle', () {
test('label returns correct strings', () {
expect(TimerStyle.pomodoro.label, 'Pomodoro');
expect(TimerStyle.ultraradian.label, 'Ultraradian');
});
test('pomodoro has correct defaults', () {
expect(TimerStyle.pomodoro.defaultWorkMinutes, 25);
expect(TimerStyle.pomodoro.defaultShortBreakMinutes, 5);
expect(TimerStyle.pomodoro.defaultLongBreakMinutes, 15);
expect(TimerStyle.pomodoro.defaultPomodorosPerCycle, 4);
});
test('ultraradian has correct defaults', () {
expect(TimerStyle.ultraradian.defaultWorkMinutes, 90);
expect(TimerStyle.ultraradian.defaultShortBreakMinutes, 30);
expect(TimerStyle.ultraradian.defaultLongBreakMinutes, 30);
expect(TimerStyle.ultraradian.defaultPomodorosPerCycle, 1);
});
});
group('PomodoroMode', () {
test('label returns correct strings', () {
expect(PomodoroMode.work.label, 'Work');
@ -98,6 +119,30 @@ void main() {
});
});
group('PomodoroState.modeDisplayLabel', () {
test('returns mode label when pomodorosPerCycle > 1', () {
final state = PomodoroState.initial();
expect(state.modeDisplayLabel, 'Work');
final breakState = state.copyWith(mode: PomodoroMode.shortBreak);
expect(breakState.modeDisplayLabel, 'Short Break');
final longBreakState = state.copyWith(mode: PomodoroMode.longBreak);
expect(longBreakState.modeDisplayLabel, 'Long Break');
});
test('returns Break when pomodorosPerCycle is 1 and not work', () {
final state = PomodoroState.initial(pomodorosPerCycle: 1);
expect(state.modeDisplayLabel, 'Work');
final breakState = state.copyWith(mode: PomodoroMode.shortBreak);
expect(breakState.modeDisplayLabel, 'Break');
final longBreakState = state.copyWith(mode: PomodoroMode.longBreak);
expect(longBreakState.modeDisplayLabel, 'Break');
});
});
group('PomodoroState equality', () {
test('equal states are ==', () {
final a = PomodoroState.initial();

View File

@ -174,5 +174,34 @@ void main() {
// We can check that the PomodoroIndicators widget is present.
expect(find.text('0 pomodoros completed'), findsOneWidget);
});
testWidgets('shows style picker with Pomodoro selected', (tester) async {
await tester.pumpWidget(createApp());
expect(find.text('Pomodoro'), findsOneWidget);
expect(find.text('Ultraradian'), findsOneWidget);
});
testWidgets('switching to ultraradian updates timer', (tester) async {
await tester.pumpWidget(createApp());
await tester.tap(find.text('Ultraradian'));
await tester.pump();
// Ultraradian work session is 90 minutes.
expect(find.text('90:00'), findsOneWidget);
expect(timer.timerStyle, TimerStyle.ultraradian);
});
testWidgets('switching back to pomodoro resets timer', (tester) async {
await tester.pumpWidget(createApp());
await tester.tap(find.text('Ultraradian'));
await tester.pump();
expect(find.text('90:00'), findsOneWidget);
await tester.tap(find.text('Pomodoro'));
await tester.pump();
expect(find.text('25:00'), findsOneWidget);
});
});
}

View File

@ -0,0 +1,211 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:pomodoro_app/models/pomodoro_state.dart';
import 'package:pomodoro_app/services/notification_service.dart';
/// Captured call to the mock process runner.
class _Call {
_Call(this.executable, this.args);
final String executable;
final List<String> args;
}
void main() {
group('NotificationService', () {
late List<_Call> calls;
late NotificationService service;
Future<ProcessResult> mockRun(String exec, List<String> args) async {
calls.add(_Call(exec, args));
return ProcessResult(0, 0, '(uint32 42,)', '');
}
setUp(() {
calls = [];
service = NotificationService(runProcess: mockRun);
});
tearDown(() {
service.dispose();
});
test('showTimer sends Notify via gdbus', () async {
final state = PomodoroState(
mode: PomodoroMode.work,
remainingSeconds: 1500,
totalSeconds: 1500,
isRunning: true,
completedPomodoros: 0,
pomodorosPerCycle: 4,
);
await service.showTimer(state: state);
expect(calls, hasLength(1));
expect(calls[0].executable, 'gdbus');
expect(
calls[0].args,
contains('org.freedesktop.Notifications.Notify'),
);
expect(calls[0].args, contains('Work \u2013 25:00'));
expect(calls[0].args, contains("['pause', 'Pause', 'skip', 'Skip']"));
});
test('showTimer shows Start action when paused', () async {
final state = PomodoroState(
mode: PomodoroMode.shortBreak,
remainingSeconds: 120,
totalSeconds: 300,
isRunning: false,
completedPomodoros: 1,
pomodorosPerCycle: 4,
);
await service.showTimer(state: state);
expect(calls[0].args, contains("['start', 'Start']"));
});
test('showTimer replaces previous notification', () async {
final state = PomodoroState(
mode: PomodoroMode.work,
remainingSeconds: 1500,
totalSeconds: 1500,
isRunning: true,
completedPomodoros: 0,
pomodorosPerCycle: 4,
);
await service.showTimer(state: state);
// First call should use replaces_id 0.
expect(calls[0].args, contains('0'));
// Second call should use the parsed ID 42.
await service.showTimer(state: state);
expect(calls[1].args, contains('42'));
});
test('parses notification ID from gdbus output', () async {
final state = PomodoroState.initial();
await service.showTimer(state: state);
expect(service.currentId, 42);
});
test('handles unparsable gdbus output gracefully', () async {
final stubService = NotificationService(
runProcess: (exec, args) async {
return ProcessResult(0, 0, 'unexpected output', '');
},
);
final state = PomodoroState.initial();
await stubService.showTimer(state: state);
expect(stubService.currentId, 0);
stubService.dispose();
});
test('showSessionComplete sends correct content', () async {
await service.showSessionComplete(
completedMode: PomodoroMode.work,
nextMode: PomodoroMode.shortBreak,
);
expect(calls, hasLength(1));
expect(calls[0].args, contains('Work complete!'));
expect(calls[0].args, contains('Up next: Short Break'));
});
test('cancel sends CloseNotification', () async {
// First show a notification to get an ID.
final state = PomodoroState.initial();
await service.showTimer(state: state);
calls.clear();
await service.cancel();
expect(calls, hasLength(1));
expect(
calls[0].args,
contains('org.freedesktop.Notifications.CloseNotification'),
);
expect(calls[0].args, contains('42'));
});
test('cancel does nothing when no notification shown', () async {
await service.cancel();
expect(calls, isEmpty);
});
test('cancel resets currentId to 0', () async {
final state = PomodoroState.initial();
await service.showTimer(state: state);
expect(service.currentId, 42);
await service.cancel();
expect(service.currentId, 0);
});
test('does nothing after dispose', () async {
service.dispose();
final state = PomodoroState.initial();
await service.showTimer(state: state);
await service.showSessionComplete(
completedMode: PomodoroMode.work,
nextMode: PomodoroMode.shortBreak,
);
await service.cancel();
expect(calls, isEmpty);
});
test('dispose cancels active notification', () async {
final state = PomodoroState.initial();
await service.showTimer(state: state);
calls.clear();
service.dispose();
// Cancel was fired (fire-and-forget).
expect(calls, hasLength(1));
expect(
calls[0].args,
contains('org.freedesktop.Notifications.CloseNotification'),
);
});
test('handles process error gracefully', () async {
final errorService = NotificationService(
runProcess: (exec, args) async {
throw const OSError('gdbus not found');
},
);
final state = PomodoroState.initial();
// Should not throw.
await errorService.showTimer(state: state);
await errorService.cancel();
errorService.dispose();
});
});
group('progressBar', () {
test('returns empty bar at 0%', () {
expect(NotificationService.progressBar(0.0), '' * 20);
});
test('returns full bar at 100%', () {
expect(NotificationService.progressBar(1.0), '' * 20);
});
test('returns half bar at 50%', () {
final bar = NotificationService.progressBar(0.5);
expect(bar, '${'' * 10}${'' * 10}');
});
});
}

View File

@ -247,6 +247,70 @@ void main() {
});
});
group('switchStyle()', () {
test('switches to ultraradian with correct durations', () {
timer.switchStyle(TimerStyle.ultraradian);
expect(timer.timerStyle, TimerStyle.ultraradian);
expect(timer.state.remainingSeconds, 90 * 60);
expect(timer.state.totalSeconds, 90 * 60);
expect(timer.state.pomodorosPerCycle, 1);
expect(timer.state.mode, PomodoroMode.work);
expect(timer.state.isRunning, false);
});
test('switches back to pomodoro', () {
timer.switchStyle(TimerStyle.ultraradian);
timer.switchStyle(TimerStyle.pomodoro);
expect(timer.timerStyle, TimerStyle.pomodoro);
expect(timer.state.remainingSeconds, 25 * 60);
expect(timer.state.totalSeconds, 25 * 60);
expect(timer.state.pomodorosPerCycle, 4);
});
test('resets running timer when switching', () {
timer.start();
fakeController.tick();
expect(timer.state.isRunning, true);
timer.switchStyle(TimerStyle.ultraradian);
expect(timer.state.isRunning, false);
expect(timer.state.remainingSeconds, 90 * 60);
});
test('does nothing when switching to same style', () {
timer.start();
fakeController.tick();
final stateBefore = timer.state;
timer.switchStyle(TimerStyle.pomodoro);
expect(timer.state, stateBefore);
});
test('notifies listeners', () {
var notified = false;
timer.addListener(() => notified = true);
timer.switchStyle(TimerStyle.ultraradian);
expect(notified, true);
});
test('resets completed pomodoros', () {
timer.start();
for (var i = 0; i < 60; i++) {
fakeController.tick();
}
expect(timer.state.completedPomodoros, 1);
timer.switchStyle(TimerStyle.ultraradian);
expect(timer.state.completedPomodoros, 0);
});
});
group('timerStyle getter', () {
test('defaults to pomodoro', () {
expect(timer.timerStyle, TimerStyle.pomodoro);
});
});
group('applyRemoteState()', () {
test('applies remote state and notifies listeners', () {
var notified = false;

View File

@ -10,12 +10,12 @@ import 'package:pomodoro_app/services/sync_service.dart';
/// injecting received messages.
class FakeDatagramSocket implements RawDatagramSocket {
final _controller = StreamController<RawSocketEvent>.broadcast();
final List<_SentDatagram> sentMessages = [];
final List<SentDatagram> sentMessages = [];
Datagram? _pendingDatagram;
@override
int send(List<int> buffer, InternetAddress address, int port) {
sentMessages.add(_SentDatagram(buffer, address, port));
sentMessages.add(SentDatagram(buffer, address, port));
return buffer.length;
}
@ -61,8 +61,8 @@ class FakeDatagramSocket implements RawDatagramSocket {
dynamic noSuchMethod(Invocation invocation) => null;
}
class _SentDatagram {
_SentDatagram(this.data, this.address, this.port);
class SentDatagram {
SentDatagram(this.data, this.address, this.port);
final List<int> data;
final InternetAddress address;
final int port;
@ -118,7 +118,6 @@ void main() {
});
test('ignores own messages', () async {
final state = PomodoroState.initial();
final message = jsonEncode({
'deviceId': 'test-device-1', // Same as our device.
'timestamp': DateTime.now().millisecondsSinceEpoch,
@ -212,7 +211,7 @@ void main() {
PomodoroState? received;
final sender = SyncService(
onStateReceived: (_, __) {},
onStateReceived: (_, _) {},
deviceId: 'sender',
socketFactory: (h, p) async => fakeSocket,
);

View File

@ -57,6 +57,13 @@ def _out(text: str = "") -> None:
sys.stdout.write(text + "\n")
def _prompt(text: str) -> str:
"""Read user input with a prompt."""
sys.stdout.write(text)
sys.stdout.flush()
return sys.stdin.readline().strip()
# ── Brother PJL status codes ────────────────────────────────────────
# Documented in Brother PJL Technical Reference.
# Format: code -> (severity, short_text, action)
@ -91,26 +98,23 @@ BROTHER_STATUS_CODES: dict[int, tuple[str, str, str]] = {
40309: (
"critical",
"Replace Toner",
"The toner cartridge needs immediate replacement"
" (TN-1050/TN-1030 compatible).",
"The toner cartridge needs immediate replacement (TN-1050/TN-1030 compatible).",
),
40310: (
"critical",
"Toner End",
"The toner cartridge is empty. Replace now" " (TN-1050/TN-1030 compatible).",
"The toner cartridge is empty. Replace now (TN-1050/TN-1030 compatible).",
),
# Drum
30201: (
"warn",
"Drum End Soon",
"The drum unit is nearing end of life."
" Order replacement (DR-1050 compatible).",
"The drum unit is nearing end of life. Order replacement (DR-1050 compatible).",
),
40201: (
"warn",
"Drum End Soon",
"The drum unit is nearing end of life."
" Order replacement (DR-1050 compatible).",
"The drum unit is nearing end of life. Order replacement (DR-1050 compatible).",
),
40019: (
"critical",
@ -147,6 +151,28 @@ BROTHER_STATUS_CODES: dict[int, tuple[str, str, str]] = {
# ── Data classes ─────────────────────────────────────────────────────
@dataclass
class CUPSJob:
"""A single CUPS print job."""
job_id: str
user: str
size: str
date: str
@dataclass
class CUPSQueueStatus:
"""Status of the CUPS print queue for a printer."""
printer_name: str = ""
enabled: bool = True
reason: str = ""
jobs: list[CUPSJob] = field(default_factory=list)
has_backend_errors: bool = False
last_backend_error: str = ""
@dataclass
class USBResult:
"""Result from a USB PJL query."""
@ -481,6 +507,362 @@ def query_network_snmp(ip: str) -> NetworkResult:
return _build_network_result(ip, community, timeout)
# ── CUPS queue inspection ────────────────────────────────────────────
def _find_cups_printer_name() -> str:
"""Find the CUPS queue name for a Brother printer."""
lpstat_path = shutil.which("lpstat")
if not lpstat_path:
return ""
try:
r = subprocess.run(
[lpstat_path, "-v"],
capture_output=True,
text=True,
timeout=5,
check=False,
)
for line in r.stdout.splitlines():
if "brother" in line.lower():
# e.g. device for Brother_HL-1110_series: usb://...
match = re.match(r"device for (\S+):", line)
if match:
return match.group(1)
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
pass
return ""
def _parse_lpstat_printer_line(line: str) -> tuple[bool, str]:
"""Parse an lpstat -p line. Returns (enabled, reason)."""
enabled = "disabled" not in line.lower()
reason = ""
# Reason follows the dash after the date
match = re.search(r"\d{4}\s+-\s*(.+)", line)
if match:
reason = match.group(1).strip()
return enabled, reason
def _parse_lpstat_jobs(output: str, printer_name: str) -> list[CUPSJob]:
"""Parse lpstat -o output into CUPSJob list."""
jobs: list[CUPSJob] = []
for line in output.splitlines():
if not line.startswith(printer_name):
continue
parts = line.split()
if len(parts) >= 4: # noqa: PLR2004
job_id = parts[0]
user = parts[1]
size = parts[2]
date = " ".join(parts[3:])
jobs.append(CUPSJob(job_id=job_id, user=user, size=size, date=date))
return jobs
def get_cups_queue_status() -> CUPSQueueStatus:
"""Check if the CUPS queue is disabled and list pending jobs."""
printer_name = _find_cups_printer_name()
if not printer_name:
return CUPSQueueStatus()
result = CUPSQueueStatus(printer_name=printer_name)
lpstat_path = shutil.which("lpstat")
if not lpstat_path:
return result
# Check printer enabled/disabled state
try:
r = subprocess.run(
[lpstat_path, "-p", printer_name],
capture_output=True,
text=True,
timeout=5,
check=False,
)
for line in r.stdout.splitlines():
if "printer" in line.lower() and printer_name in line:
result.enabled, result.reason = _parse_lpstat_printer_line(line)
break
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
pass
# List pending jobs
try:
r = subprocess.run(
[lpstat_path, "-o", printer_name],
capture_output=True,
text=True,
timeout=5,
check=False,
)
result.jobs = _parse_lpstat_jobs(r.stdout, printer_name)
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
pass
# Check for stale backend errors
has_errors, last_error = _check_cups_backend_errors(printer_name)
result.has_backend_errors = has_errors
result.last_backend_error = last_error
return result
def _cups_enable_printer(printer_name: str) -> bool:
"""Re-enable a disabled CUPS printer. Returns True on success."""
cupsenable_path = shutil.which("cupsenable")
if not cupsenable_path:
_out(f" {RED}cupsenable not found.{RESET}")
return False
try:
subprocess.run(
[cupsenable_path, printer_name],
timeout=5,
check=True,
)
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError) as e:
_out(f" {RED}Failed to enable printer: {e}{RESET}")
return False
else:
return True
def _cups_cancel_all_jobs(printer_name: str) -> bool:
"""Cancel all pending jobs. Returns True on success."""
cancel_path = shutil.which("cancel")
if not cancel_path:
_out(f" {RED}cancel command not found.{RESET}")
return False
try:
subprocess.run(
[cancel_path, "-a", printer_name],
timeout=5,
check=True,
)
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError) as e:
_out(f" {RED}Failed to cancel jobs: {e}{RESET}")
return False
else:
return True
def _cups_cancel_job(job_id: str) -> bool:
"""Cancel a specific job. Returns True on success."""
cancel_path = shutil.which("cancel")
if not cancel_path:
return False
try:
subprocess.run(
[cancel_path, job_id],
timeout=5,
check=True,
)
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
return False
else:
return True
def _cups_restart_service() -> bool:
"""Restart the CUPS service. Returns True on success."""
systemctl_path = shutil.which("systemctl")
if not systemctl_path:
_out(f" {RED}systemctl not found.{RESET}")
return False
try:
subprocess.run(
[systemctl_path, "restart", "cups"],
timeout=15,
check=True,
)
time.sleep(2) # wait for CUPS to come back up
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError) as e:
_out(f" {RED}Failed to restart CUPS: {e}{RESET}")
return False
else:
return True
def _check_cups_backend_errors(
printer_name: str, # noqa: ARG001
) -> tuple[bool, str]:
"""Check CUPS error log for backend errors. Returns (has_errors, last_error)."""
log_path = Path("/var/log/cups/error_log")
if not log_path.exists():
return False, ""
try:
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return False, ""
# Look for backend errors related to this printer (scan from end)
backend_error = ""
error_timestamp = ""
last_success_timestamp = ""
for line in reversed(lines):
if (
"backend errors" in line or "stopped with status" in line
) and not backend_error:
backend_error = line.strip()
ts_match = re.search(r"\[([^\]]+)\]", line)
if ts_match:
error_timestamp = ts_match.group(1)
# Check if a job completed successfully after the error
if ("Completed" in line or "total" in line) and error_timestamp:
ts_match = re.search(r"\[([^\]]+)\]", line)
if ts_match:
last_success_timestamp = ts_match.group(1)
break
if not backend_error:
return False, ""
# If there's been a successful print after the error, backend is fine
if last_success_timestamp and last_success_timestamp > error_timestamp:
return False, ""
return True, backend_error
def _display_cups_queue_status(queue: CUPSQueueStatus) -> None:
"""Display CUPS queue status and offer interactive fixes."""
if not queue.printer_name:
return
if queue.enabled and not queue.jobs and not queue.has_backend_errors:
return
_out()
_out(f"{BOLD}── Print Queue ──{RESET}")
_out()
if queue.has_backend_errors and queue.enabled and not queue.jobs:
_out(f" {YELLOW}{BOLD}⚡ CUPS backend has stale errors{RESET}")
_out(
f" {DIM}New print jobs may silently fail."
f" A CUPS restart usually fixes this.{RESET}"
)
_out()
if not queue.enabled:
_out(f" {RED}{BOLD}⚠ Printer queue is DISABLED{RESET}")
if queue.reason:
_out(f" {DIM}Reason: {queue.reason}{RESET}")
_out()
if queue.jobs:
_out(f" {BOLD}Pending jobs ({len(queue.jobs)}):{RESET}")
for job in queue.jobs:
_out(f" {job.job_id} {DIM}{job.user} {job.size}B {job.date}{RESET}")
_out()
_offer_queue_fix(queue)
def _offer_queue_fix(queue: CUPSQueueStatus) -> None:
"""Prompt the user to fix a disabled queue / pending jobs."""
_out(f" {BOLD}Available actions:{RESET}")
options: list[str] = []
if not queue.enabled and queue.jobs:
_out(f" {CYAN}1){RESET} Re-enable printer and retry all jobs")
_out(f" {CYAN}2){RESET} Re-enable printer and cancel all jobs")
_out(f" {CYAN}3){RESET} Cancel all jobs (keep printer disabled)")
_out(f" {CYAN}4){RESET} Restart CUPS service (fixes stale backend)")
_out(f" {CYAN}5){RESET} Restart CUPS + re-enable + retry all jobs")
_out(f" {CYAN}6){RESET} Do nothing")
options = ["1", "2", "3", "4", "5", "6"]
elif not queue.enabled:
_out(f" {CYAN}1){RESET} Re-enable printer")
_out(f" {CYAN}2){RESET} Restart CUPS service (fixes stale backend)")
_out(f" {CYAN}3){RESET} Do nothing")
options = ["1", "2", "3"]
elif queue.jobs:
_out(f" {CYAN}1){RESET} Cancel all pending jobs")
_out(f" {CYAN}2){RESET} Restart CUPS service (fixes stale backend)")
_out(f" {CYAN}3){RESET} Do nothing")
options = ["1", "2", "3"]
else:
# Backend errors only, printer enabled, no jobs
_out(f" {CYAN}1){RESET} Restart CUPS service (fixes stale backend)")
_out(f" {CYAN}2){RESET} Do nothing")
options = ["1", "2"]
_out()
choice = _prompt(f" Choose [{'/'.join(options)}]: ")
_out()
if not queue.enabled and queue.jobs:
_handle_disabled_with_jobs(queue, choice)
elif not queue.enabled:
_handle_disabled_no_jobs(queue, choice)
elif queue.jobs:
_handle_enabled_with_jobs(queue, choice)
else:
_handle_backend_errors_only(choice)
def _handle_disabled_with_jobs(queue: CUPSQueueStatus, choice: str) -> None: # noqa: C901
"""Handle fix for disabled printer with pending jobs."""
if choice == "1":
if _cups_enable_printer(queue.printer_name):
_out(f" {GREEN}✓ Printer re-enabled. Jobs will be retried.{RESET}")
elif choice == "2":
_cups_cancel_all_jobs(queue.printer_name)
if _cups_enable_printer(queue.printer_name):
_out(f" {GREEN}✓ All jobs cancelled and printer re-enabled.{RESET}")
elif choice == "3":
if _cups_cancel_all_jobs(queue.printer_name):
_out(f" {GREEN}✓ All jobs cancelled.{RESET}")
elif choice == "4":
if _cups_restart_service():
_out(f" {GREEN}✓ CUPS restarted.{RESET}")
elif choice == "5":
if _cups_restart_service():
_cups_enable_printer(queue.printer_name)
_out(
f" {GREEN}✓ CUPS restarted, printer re-enabled."
f" Jobs will be retried.{RESET}"
)
else:
_out(f" {DIM}No changes made.{RESET}")
def _handle_disabled_no_jobs(queue: CUPSQueueStatus, choice: str) -> None:
"""Handle fix for disabled printer with no pending jobs."""
if choice == "1":
if _cups_enable_printer(queue.printer_name):
_out(f" {GREEN}✓ Printer re-enabled.{RESET}")
elif choice == "2":
if _cups_restart_service():
_cups_enable_printer(queue.printer_name)
_out(f" {GREEN}✓ CUPS restarted and printer re-enabled.{RESET}")
else:
_out(f" {DIM}No changes made.{RESET}")
def _handle_enabled_with_jobs(queue: CUPSQueueStatus, choice: str) -> None:
"""Handle fix for enabled printer with stuck jobs."""
if choice == "1":
if _cups_cancel_all_jobs(queue.printer_name):
_out(f" {GREEN}✓ All jobs cancelled.{RESET}")
elif choice == "2":
if _cups_restart_service():
_out(f" {GREEN}✓ CUPS restarted.{RESET}")
else:
_out(f" {DIM}No changes made.{RESET}")
def _handle_backend_errors_only(choice: str) -> None:
"""Handle fix when only stale backend errors are detected."""
if choice == "1":
if _cups_restart_service():
_out(f" {GREEN}✓ CUPS restarted. Stale backend errors cleared.{RESET}")
else:
_out(f" {DIM}No changes made.{RESET}")
# ── Status code lookup ──────────────────────────────────────────────
@ -564,8 +946,7 @@ _SEVERITY_SUMMARIES: dict[str, str] = {
"warn": f"{YELLOW}{BOLD}⚡ WARNING: Maintenance will be needed"
f" soon.{RESET}\n{YELLOW} Order replacement parts"
f" now to avoid interruption.{RESET}",
"critical": f"{RED}{BOLD}⚠ ACTION REQUIRED: Replacement or fix"
f" needed now!{RESET}",
"critical": f"{RED}{BOLD}⚠ ACTION REQUIRED: Replacement or fix needed now!{RESET}",
}
@ -619,6 +1000,9 @@ def display_usb_results(result: USBResult) -> None:
_out()
_display_consumables_reference()
queue = get_cups_queue_status()
_display_cups_queue_status(queue)
# ── Display: Network helpers ────────────────────────────────────────
@ -810,7 +1194,7 @@ def _run_usb_mode(usb_line: str) -> None:
"""Handle USB printer mode."""
_out(f"{CYAN}Found Brother printer on USB: {usb_line}{RESET}")
if os.geteuid() != 0:
_out(f"{RED}Root access required for USB printer." f" Re-run with sudo.{RESET}")
_out(f"{RED}Root access required for USB printer. Re-run with sudo.{RESET}")
sys.exit(1)
display_usb_results(query_usb_pjl())