mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 15:43:06 +02:00
feat: moviepy showcase full
This commit is contained in:
parent
6ec85106b7
commit
8fb2e96363
2
.gitignore
vendored
2
.gitignore
vendored
@ -41,7 +41,9 @@ testem.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.mkv
|
||||
*.mp4
|
||||
imageviewer
|
||||
title_test.png
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
@ -15,6 +15,8 @@ com.glovo.courier
|
||||
com.wolt.android
|
||||
|
||||
# Bolt Food / Bolt
|
||||
com.bolt.deliveryclient
|
||||
ee.mtakso.client
|
||||
ee.mtakso.food
|
||||
ee.mtakso
|
||||
|
||||
|
||||
200
linux_configuration/scripts/utils/docx_to_pdf.sh
Executable file
200
linux_configuration/scripts/utils/docx_to_pdf.sh
Executable 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
1197
moviepy_showcase.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||
);
|
||||
|
||||
@ -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())
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user