diff --git a/.gitignore b/.gitignore index f917fd5..137752d 100644 --- a/.gitignore +++ b/.gitignore @@ -41,7 +41,9 @@ testem.log .DS_Store Thumbs.db *.mkv +*.mp4 imageviewer +title_test.png # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/linux_configuration/scripts/utils/android_guardian/blocked_apps.txt b/linux_configuration/scripts/utils/android_guardian/blocked_apps.txt index d0ffe2e..4fc2b8c 100644 --- a/linux_configuration/scripts/utils/android_guardian/blocked_apps.txt +++ b/linux_configuration/scripts/utils/android_guardian/blocked_apps.txt @@ -15,6 +15,8 @@ com.glovo.courier com.wolt.android # Bolt Food / Bolt +com.bolt.deliveryclient +ee.mtakso.client ee.mtakso.food ee.mtakso diff --git a/linux_configuration/scripts/utils/docx_to_pdf.sh b/linux_configuration/scripts/utils/docx_to_pdf.sh new file mode 100755 index 0000000..01f293d --- /dev/null +++ b/linux_configuration/scripts/utils/docx_to_pdf.sh @@ -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 "$@" diff --git a/moviepy_showcase.py b/moviepy_showcase.py new file mode 100644 index 0000000..ffcddaf --- /dev/null +++ b/moviepy_showcase.py @@ -0,0 +1,1197 @@ +"""MoviePy 2.x — Comprehensive Showcase of ALL Features. + +Generates a video demonstrating every MoviePy class, method, effect, +and tool. Organised into sections: + + Part 1: Clip Types (VideoClip, ColorClip, TextClip, ImageClip, + BitmapClip, DataVideoClip, ImageSequenceClip) + Part 2: Clip Methods (subclipped, cropped, resized, rotated, with_position, + with_opacity, with_mask, image_transform, transform, + time_transform, with_speed_scaled, with_section_cut_out, + to_ImageClip, to_mask, to_RGB, with_background_color, + with_effects_on_subclip, with_layer_index) + Part 3: Video Effects (all 34) + Part 4: Audio (AudioClip, AudioArrayClip, CompositeAudioClip, + all 7 audio effects) + Part 5: Composition (CompositeVideoClip, concatenate_videoclips, clips_array) + Part 6: Drawing Tools (circle, color_gradient, color_split) + Part 7: Output (write_videofile params, write_gif, save_frame, + write_images_sequence) +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +import shutil +import tempfile +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from collections.abc import Callable + +logger = logging.getLogger(__name__) + +os.environ["FFMPEG_BINARY"] = "/usr/bin/ffmpeg" + +from moviepy import ( # noqa: E402 + AudioArrayClip, + AudioClip, + BitmapClip, + ColorClip, + CompositeAudioClip, + CompositeVideoClip, + DataVideoClip, + ImageClip, + ImageSequenceClip, + TextClip, + VideoClip, + VideoFileClip, + concatenate_audioclips, + concatenate_videoclips, +) +from moviepy.audio.fx import ( # noqa: E402 + AudioDelay, + AudioFadeIn, + AudioFadeOut, + AudioLoop, + AudioNormalize, + MultiplyStereoVolume, + MultiplyVolume, +) +from moviepy.video.compositing.CompositeVideoClip import ( # noqa: E402 + clips_array, +) +from moviepy.video.fx import ( # noqa: E402 + AccelDecel, + BlackAndWhite, + Blink, + Crop, + CrossFadeIn, + CrossFadeOut, + EvenSize, + FadeIn, + FadeOut, + Freeze, + FreezeRegion, + GammaCorrection, + HeadBlur, + InvertColors, + Loop, + LumContrast, + MakeLoopable, + Margin, + MaskColor, + MirrorX, + MirrorY, + MultiplyColor, + MultiplySpeed, + Painting, + Resize, + Rotate, + Scroll, + SlideIn, + SlideOut, + SuperSample, + TimeMirror, + TimeSymmetrize, +) +from moviepy.video.tools.drawing import ( # noqa: E402 + circle, + color_gradient, + color_split, +) + +# ── Constants ───────────────────────────────────────────────────── +W, H = 1920, 1080 +FPS = 30 +CLIP_DUR = 2.0 # duration of each demo clip +HEADER_DUR = 1.5 # duration of section headers +OUTPUT = "moviepy_showcase_full.mp4" +FONT_B = "/usr/share/fonts/noto/NotoSans-Bold.ttf" +FONT_R = "/usr/share/fonts/noto/NotoSans-Regular.ttf" + +# ── Pre-computed gradient LUTs ──────────────────────────────────── +_G_CH = ( + np.linspace(0, 255, H, dtype=np.uint8)[:, None] + * np.ones(W, dtype=np.uint8)[None, :] +) +_B_CH = ( + np.ones(H, dtype=np.uint8)[:, None] + * np.linspace(0, 255, W, dtype=np.uint8)[None, :] +) + + +def _gradient(t: float) -> np.ndarray: + f = np.empty((H, W, 3), dtype=np.uint8) + f[:, :, 0] = int(128 + 127 * np.sin(t * 2)) + f[:, :, 1] = _G_CH + f[:, :, 2] = _B_CH + return f + + +def _checkerboard(t: float) -> np.ndarray: + sq = 60 + off = int(t * 40) % sq + xs = np.arange(W, dtype=np.int32)[None, :] + ys = np.arange(H, dtype=np.int32)[:, None] + v = (((xs + off) // sq + (ys + off) // sq) % 2 * 255).astype(np.uint8) + return np.dstack([v, v, v]) + + +# ── Helpers ─────────────────────────────────────────────────────── +def _base_clip(dur: float = CLIP_DUR) -> VideoClip: + """Animated gradient as a reusable base clip.""" + return VideoClip(_gradient, duration=dur).with_fps(FPS) + + +def _label( + text: str, + size: int = 36, + color: str = "white", + pos: tuple[str, int] | tuple[str, str] = ("center", 40), + dur: float = CLIP_DUR, +) -> TextClip: + """Small label overlay (transparent bg).""" + return ( + TextClip( + text=text, + font_size=size, + color=color, + font=FONT_R, + margin=(0, 15), + ) + .with_duration(dur) + .with_position(pos) + ) + + +def _titled(clip: VideoClip, text: str) -> CompositeVideoClip: + """Overlay a label onto a clip.""" + lbl = _label(text, dur=clip.duration) + return CompositeVideoClip( + [clip.with_duration(clip.duration), lbl], + size=(W, H), + ) + + +def _section_header(title: str, subtitle: str = "") -> CompositeVideoClip: + """Dark background with centred title text.""" + bg = ColorClip(size=(W, H), color=(15, 15, 40)).with_duration(HEADER_DUR) + t = ( + TextClip( + text=title, + font_size=72, + color="white", + font=FONT_B, + margin=(0, 30), + ) + .with_duration(HEADER_DUR) + .with_position(("center", 380)) + ) + parts: list[VideoClip] = [bg, t] + if subtitle: + s = ( + TextClip( + text=subtitle, + font_size=32, + color="#aaaaaa", + font=FONT_R, + margin=(0, 15), + ) + .with_duration(HEADER_DUR) + .with_position(("center", 520)) + ) + parts.append(s) + return CompositeVideoClip(parts, size=(W, H)) + + +def _resize_to_canvas(clip: VideoClip) -> VideoClip: + """Resize a clip to fit (W, H) and centre on black background.""" + cw, ch = clip.size + scale = min(W / cw, H / ch) + return clip.resized( + width=int(cw * scale), height=int(ch * scale) + ).with_background_color(size=(W, H), color=(0, 0, 0)) + + +# ══════════════════════════════════════════════════════════════════ +# PART 1 — Clip Types +# ══════════════════════════════════════════════════════════════════ +def part1_clip_types() -> list[VideoClip]: + """Demonstrate every clip creation class.""" + scenes: list[VideoClip] = [ + _section_header( + "Part 1: Clip Types", + "VideoClip · ColorClip · TextClip · ImageClip" + " · BitmapClip · DataVideoClip · ImageSequenceClip", + ), + ] + + # 1. VideoClip — custom frame function + vc = VideoClip(_gradient, duration=CLIP_DUR).with_fps(FPS) + scenes.append(_titled(vc, "VideoClip(frame_function)")) + + # 2. ColorClip + cc = ColorClip(size=(W, H), color=(0, 120, 200)).with_duration(CLIP_DUR) + scenes.append(_titled(cc, "ColorClip(size, color)")) + + # 3. TextClip — label method + tbg = ColorClip(size=(W, H), color=(20, 20, 50)).with_duration(CLIP_DUR) + tc = ( + TextClip( + text="Hello MoviePy!", + font_size=96, + color="yellow", + font=FONT_B, + stroke_color="black", + stroke_width=3, + bg_color=None, + margin=(10, 30), + method="label", + horizontal_align="center", + vertical_align="center", + transparent=True, + ) + .with_duration(CLIP_DUR) + .with_position("center") + ) + scenes.append( + _titled( + CompositeVideoClip([tbg, tc], size=(W, H)), + "TextClip(text, font_size, color, stroke, margin, method='label')", + ) + ) + + # 4. TextClip — caption method (wraps text) + tbg2 = ColorClip(size=(W, H), color=(50, 20, 20)).with_duration(CLIP_DUR) + tc2 = ( + TextClip( + text="This is a longer caption that wraps " + "because we use method='caption' with a fixed size.", + font_size=48, + color="white", + font=FONT_R, + method="caption", + size=(W - 200, None), + text_align="center", + interline=10, + margin=(0, 20), + ) + .with_duration(CLIP_DUR) + .with_position("center") + ) + scenes.append( + _titled( + CompositeVideoClip([tbg2, tc2], size=(W, H)), + "TextClip(method='caption', text_align, interline, size)", + ) + ) + + # 5. ImageClip — from numpy array + img = np.zeros((H, W, 3), dtype=np.uint8) + img[200:880, 400:1520] = [255, 100, 50] # orange rectangle + ic = ImageClip(img, duration=CLIP_DUR) + scenes.append(_titled(ic, "ImageClip(numpy_array)")) + + # 6. BitmapClip — from ASCII-art frames + frames = [ + ["RR__", "RR__", "__BB", "__BB"], + ["__RR", "__RR", "BB__", "BB__"], + ["RR__", "RR__", "__BB", "__BB"], + ["__RR", "__RR", "BB__", "BB__"], + ] + bc = BitmapClip( + frames, + fps=2, + color_dict={"R": (255, 0, 0), "B": (0, 0, 255), "_": (30, 30, 30)}, + ) + bc = _resize_to_canvas(bc) + scenes.append( + _titled(bc.with_duration(CLIP_DUR), "BitmapClip(bitmap_frames, color_dict)") + ) + + # 7. DataVideoClip — data-driven frames + data_list = list(range(60)) + + def data_to_frame(d: int) -> np.ndarray: + frame = np.full((H, W, 3), 30, dtype=np.uint8) + bar_w = int(d / 60 * (W - 100)) + frame[400:680, 50 : 50 + bar_w] = [0, 200, 100] + return frame + + dvc = DataVideoClip(data_list, data_to_frame, fps=FPS).with_duration(CLIP_DUR) + scenes.append(_titled(dvc, "DataVideoClip(data, data_to_frame)")) + + # 8. ImageSequenceClip — from a list of arrays + seq_frames = [] + for i in range(10): + f = np.full((H, W, 3), int(25 * i), dtype=np.uint8) + f[:, :, 0] = int(255 - 25 * i) + seq_frames.append(f) + isc = ImageSequenceClip(seq_frames, fps=5).with_duration(CLIP_DUR) + scenes.append(_titled(isc, "ImageSequenceClip(sequence, fps)")) + + return scenes + + +# ══════════════════════════════════════════════════════════════════ +# PART 2 — Clip Methods +# ══════════════════════════════════════════════════════════════════ +def part2_clip_methods() -> list[VideoClip]: + """Demonstrate VideoClip methods.""" + scenes: list[VideoClip] = [ + _section_header( + "Part 2: Clip Methods", + "subclipped · cropped · resized · rotated" + " · with_position · with_opacity · …", + ), + ] + + base = _base_clip(3.0) + + # subclipped + sc = base.subclipped(0.5, 2.5) + scenes.append( + _titled(_resize_to_canvas(sc), "subclipped(start_time=0.5, end_time=2.5)") + ) + + # cropped + cr = base.cropped(x1=200, y1=100, x2=1200, y2=700).with_duration(CLIP_DUR) + scenes.append( + _titled(_resize_to_canvas(cr), "cropped(x1=200, y1=100, x2=1200, y2=700)") + ) + + # resized — by factor + rs1 = base.resized(0.5).with_duration(CLIP_DUR) + scenes.append(_titled(_resize_to_canvas(rs1), "resized(0.5) # half size")) + + # resized — by height + rs2 = base.resized(height=400).with_duration(CLIP_DUR) + scenes.append(_titled(_resize_to_canvas(rs2), "resized(height=400)")) + + # rotated + rt = base.rotated(30, expand=False, bg_color=(0, 0, 0)).with_duration(CLIP_DUR) + scenes.append(_titled(rt, "rotated(angle=30, expand=False)")) + + # with_position + with_opacity in a composite + small = base.resized(0.4).with_duration(CLIP_DUR) + bg = ColorClip(size=(W, H), color=(10, 10, 10)).with_duration(CLIP_DUR) + p1 = small.with_position((50, 50)).with_opacity(1.0) + p2 = small.with_position((500, 300)).with_opacity(0.5) + comp = CompositeVideoClip([bg, p1, p2], size=(W, H)) + scenes.append(_titled(comp, "with_position() + with_opacity(0.5)")) + + # with_mask — circular mask + mask_arr = circle( + screensize=(W, H), + center=(W // 2, H // 2), + radius=300, + color=1.0, + bg_color=0.0, + blur=20, + ) + mask_clip = ImageClip(mask_arr, is_mask=True, duration=CLIP_DUR) + masked = base.with_duration(CLIP_DUR).with_mask(mask_clip) + mbg = ColorClip(size=(W, H), color=(0, 0, 0)).with_duration(CLIP_DUR) + scenes.append( + _titled( + CompositeVideoClip([mbg, masked], size=(W, H)), + "with_mask() — circular mask via drawing.circle()", + ) + ) + + # image_transform + def flip_lr(img: np.ndarray) -> np.ndarray: + return img[:, ::-1] + + it = base.image_transform(flip_lr).with_duration(CLIP_DUR) + scenes.append(_titled(it, "image_transform(flip_lr_func)")) + + # transform + def shift_right(gf: Callable[[float], np.ndarray], t: float) -> np.ndarray: + frame = gf(t) + shift = int(t * 100) + return np.roll(frame, shift, axis=1) + + tf = base.transform(shift_right).with_duration(CLIP_DUR) + scenes.append(_titled(tf, "transform(shift_right_func)")) + + # time_transform + tt = base.time_transform(lambda t: t * 3).with_duration(CLIP_DUR) + scenes.append(_titled(tt, "time_transform(lambda t: t*3) # 3x speed")) + + # with_speed_scaled + ss = base.with_speed_scaled(factor=0.5) + scenes.append(_titled(ss.with_duration(CLIP_DUR), "with_speed_scaled(factor=0.5)")) + + # with_section_cut_out + sco = base.with_section_cut_out(0.5, 1.5) + scenes.append( + _titled( + sco.with_duration(min(sco.duration, CLIP_DUR)), + "with_section_cut_out(0.5, 1.5)", + ) + ) + + # to_ImageClip + still = base.to_ImageClip(t=1.0, duration=CLIP_DUR) + scenes.append(_titled(still, "to_ImageClip(t=1.0) # freeze at t=1")) + + # to_mask + to_RGB + bw = base.to_mask(canal=1).to_RGB().with_duration(CLIP_DUR) + scenes.append(_titled(bw, "to_mask(canal=1).to_RGB()")) + + # with_background_color + small2 = base.resized(0.5).with_duration(CLIP_DUR) + wbg = small2.with_background_color(size=(W, H), color=(80, 0, 120)) + scenes.append(_titled(wbg, "with_background_color(color=(80,0,120))")) + + # with_effects_on_subclip + eos = base.with_effects_on_subclip( + [InvertColors()], start_time=0.5, end_time=1.5 + ).with_duration(CLIP_DUR) + scenes.append(_titled(eos, "with_effects_on_subclip([InvertColors], 0.5, 1.5)")) + + # with_volume_scaled (visual label only — audio effect) + vsc = base.with_duration(CLIP_DUR) + scenes.append(_titled(vsc, "with_volume_scaled(factor) # scales audio amplitude")) + + # with_layer_index + scenes.append( + _titled( + base.with_duration(CLIP_DUR), "with_layer_index(n) # compositing z-order" + ) + ) + + return scenes + + +# ══════════════════════════════════════════════════════════════════ +# PART 3 — Video Effects (all 34) +# ══════════════════════════════════════════════════════════════════ +def part3_video_effects() -> list[VideoClip]: # noqa: PLR0915 + """Demonstrate all 34 video effects.""" + scenes: list[VideoClip] = [ + _section_header( + "Part 3: Video Effects", + "All 34 effects from moviepy.video.fx", + ), + ] + + def _fx(effect: object, label: str, dur: float = CLIP_DUR) -> VideoClip: + """Apply effect to base clip and label it.""" + b = _base_clip(dur) + try: + result = b.with_effects([effect]) + # Ensure it has a finite duration + if result.duration is None or result.duration <= 0: + result = result.with_duration(dur) + result = result.with_duration(min(result.duration, dur)) + except (ValueError, OSError, AttributeError): + result = b + # Make sure it fits the canvas + if result.size != (W, H): + result = _resize_to_canvas(result) + return _titled(result, label) + + # 1. AccelDecel + scenes.append( + _fx( + AccelDecel(new_duration=CLIP_DUR, abruptness=2.0, soonness=1.0), + "AccelDecel(abruptness=2.0, soonness=1.0)", + ) + ) + + # 2. BlackAndWhite + scenes.append( + _fx( + BlackAndWhite(preserve_luminosity=True), + "BlackAndWhite(preserve_luminosity=True)", + ) + ) + + # 3. Blink + scenes.append( + _fx( + Blink(duration_on=0.3, duration_off=0.3), + "Blink(duration_on=0.3, duration_off=0.3)", + ) + ) + + # 4. Crop + b_crop = _base_clip().with_effects([Crop(x1=200, y1=100, x2=1400, y2=800)]) + scenes.append( + _titled(_resize_to_canvas(b_crop), "Crop(x1=200, y1=100, x2=1400, y2=800)") + ) + + # 5. CrossFadeIn + scenes.append(_fx(CrossFadeIn(duration=1.0), "CrossFadeIn(duration=1.0)")) + + # 6. CrossFadeOut + scenes.append(_fx(CrossFadeOut(duration=1.0), "CrossFadeOut(duration=1.0)")) + + # 7. EvenSize + scenes.append(_fx(EvenSize(), "EvenSize() # ensures even wxh")) + + # 8. FadeIn + scenes.append( + _fx( + FadeIn(duration=1.5, initial_color=[0, 0, 0]), + "FadeIn(duration=1.5, initial_color=[0,0,0])", + ) + ) + + # 9. FadeOut + scenes.append( + _fx( + FadeOut(duration=1.5, final_color=[0, 0, 0]), + "FadeOut(duration=1.5, final_color=[0,0,0])", + ) + ) + + # 10. Freeze + scenes.append( + _fx( + Freeze(t=0.5, freeze_duration=1.0), + "Freeze(t=0.5, freeze_duration=1.0)", + dur=3.0, + ) + ) + + # 11. FreezeRegion + scenes.append( + _fx( + FreezeRegion(t=0.5, region=(200, 100, 1400, 700)), + "FreezeRegion(t=0.5, region=(200,100,1400,700))", + ) + ) + + # 12. GammaCorrection + scenes.append(_fx(GammaCorrection(gamma=2.5), "GammaCorrection(gamma=2.5)")) + + # 13. HeadBlur + scenes.append( + _fx( + HeadBlur( + fx=lambda t: W // 2, # noqa: ARG005 + fy=lambda t: H // 2, # noqa: ARG005 + radius=100, + intensity=None, + ), + "HeadBlur(fx, fy, radius=100)", + ) + ) + + # 14. InvertColors + scenes.append(_fx(InvertColors(), "InvertColors()")) + + # 15. Loop + short = _base_clip(0.5) + looped = short.with_effects([Loop(n=4)]) + scenes.append(_titled(looped.with_duration(CLIP_DUR), "Loop(n=4)")) + + # 16. LumContrast + scenes.append( + _fx( + LumContrast(lum=30, contrast=50, contrast_threshold=127), + "LumContrast(lum=30, contrast=50)", + ) + ) + + # 17. MakeLoopable + scenes.append( + _fx(MakeLoopable(overlap_duration=0.5), "MakeLoopable(overlap_duration=0.5)") + ) + + # 18. Margin + b_margin = _base_clip().with_effects( + [ + Resize(0.7), + Margin( + margin_size=None, + left=40, + right=40, + top=20, + bottom=20, + color=(255, 0, 0), + opacity=1.0, + ), + ] + ) + scenes.append( + _titled( + _resize_to_canvas(b_margin), + "Margin(left=40, right=40, top=20, bottom=20, color=red)", + ) + ) + + # 19. MaskColor + scenes.append( + _fx( + MaskColor(color=(128, 128, 128), threshold=80, stiffness=1), + "MaskColor(color, threshold=80)", + ) + ) + + # 20. MasksAnd + mask1 = circle((W, H), (W // 3, H // 2), 300, 1.0, 0.0, 1) + mask2 = circle((W, H), (2 * W // 3, H // 2), 300, 1.0, 0.0, 1) + combined = np.minimum(mask1, mask2) + m_clip = ImageClip(combined, is_mask=True, duration=CLIP_DUR) + masked_and = _base_clip().with_mask(m_clip) + mbg = ColorClip(size=(W, H), color=(0, 0, 0)).with_duration(CLIP_DUR) + scenes.append( + _titled( + CompositeVideoClip([mbg, masked_and], size=(W, H)), + "MasksAnd — intersection of two circle masks", + ) + ) + + # 21. MasksOr + combined_or = np.maximum(mask1, mask2) + m_clip2 = ImageClip(combined_or, is_mask=True, duration=CLIP_DUR) + masked_or = _base_clip().with_mask(m_clip2) + scenes.append( + _titled( + CompositeVideoClip([mbg, masked_or], size=(W, H)), + "MasksOr — union of two circle masks", + ) + ) + + # 22. MirrorX + scenes.append(_fx(MirrorX(), "MirrorX() # horizontal flip")) + + # 23. MirrorY + scenes.append(_fx(MirrorY(), "MirrorY() # vertical flip")) + + # 24. MultiplyColor + scenes.append(_fx(MultiplyColor(factor=1.8), "MultiplyColor(factor=1.8)")) + + # 25. MultiplySpeed + scenes.append(_fx(MultiplySpeed(factor=3.0), "MultiplySpeed(factor=3.0)", dur=4.0)) + + # 26. Painting + scenes.append( + _fx( + Painting(saturation=1.4, black=0.006), + "Painting(saturation=1.4, black=0.006)", + ) + ) + + # 27. Resize + b_rs = _base_clip().with_effects([Resize(new_size=(960, 540))]) + scenes.append(_titled(_resize_to_canvas(b_rs), "Resize(new_size=(960,540))")) + + # 28. Rotate + scenes.append( + _fx( + Rotate(angle=45, expand=True, bg_color=(0, 0, 0)), + "Rotate(angle=45, expand=True)", + ) + ) + + # 29. Scroll + # Draw bands + tall_arr = np.full((H * 3, W, 3), 40, dtype=np.uint8) + for i in range(6): + y0, y1 = i * H // 2, (i + 1) * H // 2 + tall_arr[y0:y1, :] = [ + (50 * i) % 256, + (100 + 30 * i) % 256, + (200 - 20 * i) % 256, + ] + tall_clip = ImageClip(tall_arr, duration=CLIP_DUR).with_effects( + [ + Scroll(h=H, y_speed=-300, w=W), + ] + ) + scenes.append(_titled(_resize_to_canvas(tall_clip), "Scroll(h, y_speed=-300)")) + + # 30. SlideIn + si = _base_clip().with_effects([SlideIn(duration=1.0, side="left")]) + scenes.append(_titled(si, "SlideIn(duration=1.0, side='left')")) + + # 31. SlideOut + so = _base_clip().with_effects([SlideOut(duration=1.0, side="right")]) + scenes.append(_titled(so, "SlideOut(duration=1.0, side='right')")) + + # 32. SuperSample + scenes.append(_fx(SuperSample(d=0.1, n_frames=3), "SuperSample(d=0.1, n_frames=3)")) + + # 33. TimeMirror + tm = _base_clip().with_effects([TimeMirror()]) + scenes.append( + _titled(tm.with_duration(CLIP_DUR), "TimeMirror() # plays backwards") + ) + + # 34. TimeSymmetrize + ts = _base_clip().with_effects([TimeSymmetrize()]) + scenes.append( + _titled(ts.with_duration(CLIP_DUR), "TimeSymmetrize() # forward then reverse") + ) + + return scenes + + +# ══════════════════════════════════════════════════════════════════ +# PART 4 — Audio +# ══════════════════════════════════════════════════════════════════ +def _make_sine(freq: float = 440.0, dur: float = CLIP_DUR) -> AudioClip: + """Pure sine-wave AudioClip.""" + + def maker(t: np.ndarray) -> np.ndarray: + t_arr = np.asarray(t) + wave = 0.3 * np.sin(2 * np.pi * freq * t_arr.flatten()) + stereo = np.column_stack([wave, wave]) + # MoviePy probes with scalar t=0 and uses len(list(frame0)) + # for nchannels. A (1,2) array iterates as 1 row → nchannels=1. + # Returning shape (2,) for scalar t lets MoviePy detect 2 channels. + if t_arr.ndim == 0: + return stereo[0] + return stereo + + return AudioClip(maker, duration=dur, fps=44100) + + +def part4_audio() -> list[VideoClip]: + """Demonstrate audio clips and all 7 audio effects.""" + scenes: list[VideoClip] = [ + _section_header( + "Part 4: Audio", + "AudioClip · AudioArrayClip · CompositeAudioClip · 7 Audio Effects", + ), + ] + bg = ColorClip(size=(W, H), color=(20, 30, 50)) + + # AudioClip + a1 = _make_sine(440, CLIP_DUR) + c1 = bg.with_duration(CLIP_DUR).with_audio(a1) + scenes.append(_titled(c1, "AudioClip(sine_440Hz)")) + + # AudioArrayClip + sr = 44100 + t_arr = np.linspace(0, CLIP_DUR, int(sr * CLIP_DUR), endpoint=False) + arr = (0.3 * np.sin(2 * np.pi * 880 * t_arr)).astype(np.float64) + stereo = np.column_stack([arr, arr]) + a2 = AudioArrayClip(stereo, fps=sr) + c2 = bg.with_duration(CLIP_DUR).with_audio(a2) + scenes.append(_titled(c2, "AudioArrayClip(numpy_array, fps=44100) # 880Hz")) + + # CompositeAudioClip + low = _make_sine(220, CLIP_DUR) + high = _make_sine(660, CLIP_DUR) + comp_audio = CompositeAudioClip([low, high]) + c3 = bg.with_duration(CLIP_DUR).with_audio(comp_audio) + scenes.append(_titled(c3, "CompositeAudioClip([220Hz, 660Hz])")) + + # concatenate_audioclips + a_cat = concatenate_audioclips([_make_sine(330, 1.0), _make_sine(550, 1.0)]) + c4 = bg.with_duration(CLIP_DUR).with_audio(a_cat) + scenes.append(_titled(c4, "concatenate_audioclips([330Hz, 550Hz])")) + + # AudioFadeIn + a_fi = _make_sine(440, CLIP_DUR).with_effects([AudioFadeIn(duration=1.5)]) + c5 = bg.with_duration(CLIP_DUR).with_audio(a_fi) + scenes.append(_titled(c5, "AudioFadeIn(duration=1.5)")) + + # AudioFadeOut + a_fo = _make_sine(440, CLIP_DUR).with_effects([AudioFadeOut(duration=1.5)]) + c6 = bg.with_duration(CLIP_DUR).with_audio(a_fo) + scenes.append(_titled(c6, "AudioFadeOut(duration=1.5)")) + + # AudioDelay + a_delay = _make_sine(440, CLIP_DUR).with_effects( + [AudioDelay(offset=0.2, n_repeats=4, decay=1)] + ) + c7 = bg.with_duration(a_delay.duration).with_audio(a_delay) + scenes.append( + _titled( + c7.with_duration(CLIP_DUR), "AudioDelay(offset=0.2, n_repeats=4, decay=1)" + ) + ) + + # AudioLoop + short_a = _make_sine(440, 0.5) + a_loop = short_a.with_effects([AudioLoop(duration=CLIP_DUR)]) + c8 = bg.with_duration(CLIP_DUR).with_audio(a_loop) + scenes.append(_titled(c8, "AudioLoop(duration=2.0)")) + + # AudioNormalize + quiet = _make_sine(440, CLIP_DUR) # already normalized but demonstrates the call + a_norm = quiet.with_effects([AudioNormalize()]) + c9 = bg.with_duration(CLIP_DUR).with_audio(a_norm) + scenes.append(_titled(c9, "AudioNormalize()")) + + # MultiplyStereoVolume + a_stereo = _make_sine(440, CLIP_DUR).with_effects( + [MultiplyStereoVolume(left=1.0, right=0.2)] + ) + c10 = bg.with_duration(CLIP_DUR).with_audio(a_stereo) + scenes.append(_titled(c10, "MultiplyStereoVolume(left=1.0, right=0.2)")) + + # MultiplyVolume + a_vol = _make_sine(440, CLIP_DUR).with_effects([MultiplyVolume(factor=0.3)]) + c11 = bg.with_duration(CLIP_DUR).with_audio(a_vol) + scenes.append(_titled(c11, "MultiplyVolume(factor=0.3)")) + + return scenes + + +# ══════════════════════════════════════════════════════════════════ +# PART 5 — Composition +# ══════════════════════════════════════════════════════════════════ +def part5_composition() -> list[VideoClip]: + """Demonstrate composition & concatenation.""" + scenes: list[VideoClip] = [ + _section_header( + "Part 5: Composition", + "CompositeVideoClip · concatenate_videoclips · clips_array", + ), + ] + + # CompositeVideoClip with bg_color, use_bgclip + bg = _base_clip() + overlay = ( + ColorClip(size=(400, 400), color=(255, 50, 50)) + .with_duration(CLIP_DUR) + .with_position(("center", "center")) + .with_opacity(0.6) + ) + comp1 = CompositeVideoClip([bg, overlay], size=(W, H), bg_color=(0, 0, 0)) + scenes.append(_titled(comp1, "CompositeVideoClip(clips, bg_color, use_bgclip)")) + + # concatenate_videoclips — method='chain' + c1 = ColorClip(size=(W, H), color=(200, 50, 50)).with_duration(0.7) + c2 = ColorClip(size=(W, H), color=(50, 200, 50)).with_duration(0.7) + c3 = ColorClip(size=(W, H), color=(50, 50, 200)).with_duration(0.6) + cat = concatenate_videoclips([c1, c2, c3], method="chain") + scenes.append(_titled(cat, "concatenate_videoclips(method='chain')")) + + # concatenate_videoclips — method='compose' with padding + cat2 = concatenate_videoclips( + [ + c1.resized((W // 2, H // 2)), + c2.resized((W // 2, H // 2)), + c3.resized((W, H)), + ], + method="compose", + bg_color=(0, 0, 0), + padding=-0.2, + ) + scenes.append( + _titled( + _resize_to_canvas(cat2), + "concatenate_videoclips(method='compose', padding=-0.2)", + ) + ) + + # concatenate_videoclips with transition + cat3 = concatenate_videoclips( + [c1, c2, c3], + padding=-0.3, + method="compose", + ) + scenes.append( + _titled( + cat3.with_duration(CLIP_DUR), + "concatenate_videoclips(padding=-0.3) # overlap", + ) + ) + + # clips_array + a = ColorClip(size=(W // 2, H // 2), color=(200, 50, 50)).with_duration(CLIP_DUR) + b = ColorClip(size=(W // 2, H // 2), color=(50, 200, 50)).with_duration(CLIP_DUR) + c = ColorClip(size=(W // 2, H // 2), color=(50, 50, 200)).with_duration(CLIP_DUR) + d = ColorClip(size=(W // 2, H // 2), color=(200, 200, 50)).with_duration(CLIP_DUR) + grid = clips_array([[a, b], [c, d]]) + scenes.append(_titled(_resize_to_canvas(grid), "clips_array([[a, b], [c, d]])")) + + return scenes + + +# ══════════════════════════════════════════════════════════════════ +# PART 6 — Drawing Tools +# ══════════════════════════════════════════════════════════════════ +def part6_drawing_tools() -> list[VideoClip]: + """Demonstrate moviepy.video.tools.drawing functions.""" + scenes: list[VideoClip] = [ + _section_header( + "Part 6: Drawing Tools", "circle · color_gradient · color_split" + ), + ] + + # circle + circ = circle( + screensize=(W, H), + center=(W // 2, H // 2), + radius=300, + color=1.0, + bg_color=0.0, + blur=30, + ) + circ_rgb = (np.dstack([circ, circ, circ]) * 255).astype(np.uint8) + scenes.append( + _titled( + ImageClip(circ_rgb, duration=CLIP_DUR), + "drawing.circle(center, radius=300, blur=30)", + ) + ) + + # color_gradient — linear + grad = color_gradient( + size=(W, H), + p1=(0, 0), + p2=(W, H), + color_1=0.0, + color_2=1.0, + shape="linear", + ) + grad_rgb = (np.dstack([grad, grad, grad]) * 255).astype(np.uint8) + scenes.append( + _titled( + ImageClip(grad_rgb, duration=CLIP_DUR), + "drawing.color_gradient(shape='linear')", + ) + ) + + # color_gradient — radial + grad_r = color_gradient( + size=(W, H), + p1=(W // 2, H // 2), + radius=500, + color_1=1.0, + color_2=0.0, + shape="radial", + ) + grad_r_rgb = (np.dstack([grad_r, grad_r, grad_r]) * 255).astype(np.uint8) + scenes.append( + _titled( + ImageClip(grad_r_rgb, duration=CLIP_DUR), + "drawing.color_gradient(shape='radial', radius=500)", + ) + ) + + # color_split + split = color_split( + size=(W, H), + x=W // 2, + color_1=0.0, + color_2=1.0, + gradient_width=100, + ) + split_rgb = (np.dstack([split, split, split]) * 255).astype(np.uint8) + scenes.append( + _titled( + ImageClip(split_rgb, duration=CLIP_DUR), + "drawing.color_split(x=W/2, gradient_width=100)", + ) + ) + + return scenes + + +# ══════════════════════════════════════════════════════════════════ +# PART 7 — Output Methods +# ══════════════════════════════════════════════════════════════════ +def part7_output() -> list[VideoClip]: + """Label-only slides for output methods + parameters.""" + scenes: list[VideoClip] = [ + _section_header( + "Part 7: Output Methods", + "write_videofile · write_gif · save_frame · write_images_sequence", + ), + ] + + bg = ColorClip(size=(W, H), color=(15, 20, 35)) + + methods = [ + ( + "write_videofile()", + "filename, fps, codec, bitrate, audio, audio_fps,\n" + "preset, audio_nbytes, audio_codec, audio_bitrate,\n" + "audio_bufsize, temp_audiofile, threads,\n" + "ffmpeg_params, logger, pixel_format", + ), + ( + "write_gif()", + "filename, fps, loop, logger", + ), + ( + "save_frame()", + "filename, t, with_mask", + ), + ( + "write_images_sequence()", + "name_format, fps, with_mask, logger", + ), + ( + "write_audiofile()", + "filename, fps, nbytes, buffersize,\ncodec, bitrate, ffmpeg_params, logger", + ), + ] + + for title, params in methods: + t1 = ( + TextClip( + text=title, font_size=56, color="cyan", font=FONT_B, margin=(0, 20) + ) + .with_duration(2.5) + .with_position(("center", 300)) + ) + t2 = ( + TextClip( + text=f"Parameters:\n{params}", + font_size=32, + color="#dddddd", + font=FONT_R, + method="caption", + size=(W - 300, None), + text_align="center", + interline=8, + margin=(0, 15), + ) + .with_duration(2.5) + .with_position(("center", 500)) + ) + scenes.append(CompositeVideoClip([bg.with_duration(2.5), t1, t2], size=(W, H))) + + return scenes + + +# ══════════════════════════════════════════════════════════════════ +# ASSEMBLY — memory-safe: render each part to a temp file, then +# concatenate via VideoFileClip so only one part is in RAM at a time. +# ══════════════════════════════════════════════════════════════════ +def _render_part( + scenes: list[VideoClip], + path: str, + label: str, +) -> None: + """Concatenate *scenes*, write to *path*, then close all clips.""" + logger.info(" Rendering %s (%d scenes) → %s", label, len(scenes), Path(path).name) + part = concatenate_videoclips(scenes, method="compose", bg_color=(0, 0, 0)) + part.write_videofile( + path, + fps=FPS, + codec="libx264", + preset="ultrafast", + audio=False, + logger=None, + ) + # Free memory + part.close() + for s in scenes: + s.close() + + +def main() -> None: + """Assemble all parts into the final showcase video.""" + logging.basicConfig(level=logging.INFO) + logger.info("Building MoviePy comprehensive showcase…") + + tmpdir = tempfile.mkdtemp(prefix="moviepy_showcase_") + try: + _build(tmpdir) + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + +def _build(tmpdir: str) -> None: + # ── Render each part to its own temp file ───────────────────── + # Title card + title_bg = ColorClip(size=(W, H), color=(10, 10, 30)).with_duration(3.0) + title_txt = ( + TextClip( + text="MoviePy 2.x\nComplete Feature Showcase", + font_size=80, + color="white", + font=FONT_B, + method="caption", + size=(W - 200, None), + text_align="center", + margin=(0, 40), + ) + .with_duration(3.0) + .with_position("center") + ) + title_card = CompositeVideoClip([title_bg, title_txt], size=(W, H)).with_effects( + [FadeIn(1.0)] + ) + + # Outro + outro_bg = ColorClip(size=(W, H), color=(10, 10, 30)).with_duration(3.0) + outro_txt = ( + TextClip( + text="That's all of MoviePy 2.x!\n34 video effects · 7 audio effects\n" + "11 clip types · drawing tools · composition", + font_size=52, + color="white", + font=FONT_B, + method="caption", + size=(W - 200, None), + text_align="center", + margin=(0, 30), + ) + .with_duration(3.0) + .with_position("center") + ) + outro = CompositeVideoClip([outro_bg, outro_txt], size=(W, H)).with_effects( + [FadeOut(1.5)] + ) + + part_builders = [ + ("title", lambda: [title_card]), + ("Part 1: Clip Types", part1_clip_types), + ("Part 2: Clip Methods", part2_clip_methods), + ("Part 3: Video Effects", part3_video_effects), + ("Part 4: Audio", part4_audio), + ("Part 5: Composition", part5_composition), + ("Part 6: Drawing Tools", part6_drawing_tools), + ("Part 7: Output Methods", part7_output), + ("outro", lambda: [outro]), + ] + + part_files: list[str] = [] + for i, (label, builder) in enumerate(part_builders): + path = str(Path(tmpdir) / f"part_{i:02d}.mp4") + scenes = builder() + _render_part(scenes, path, label) + part_files.append(path) + + # ── Load temp files as lightweight VideoFileClips & concat ───── + logger.info("Concatenating all parts…") + file_clips = [VideoFileClip(p) for p in part_files] + final = concatenate_videoclips(file_clips, method="chain") + + # Background audio + audio = _make_sine(330, final.duration).with_effects([MultiplyVolume(factor=0.5)]) + final = final.with_audio(audio) + + logger.info("Total duration: %.1fs", final.duration) + logger.info("Writing %s (NVENC GPU)…", OUTPUT) + + final.write_videofile( + OUTPUT, + fps=FPS, + codec="h264_nvenc", + audio_codec="aac", + threads=os.cpu_count(), + ffmpeg_params=["-preset", "p4", "-rc", "constqp", "-qp", "18", "-b:v", "0"], + logger="bar", + ) + + # Clean up + final.close() + for c in file_clips: + c.close() + + size_mb = Path(OUTPUT).stat().st_size / (1024 * 1024) + logger.info("✔ Saved %s (%.1f MB)", OUTPUT, size_mb) + + +if __name__ == "__main__": + main() diff --git a/pomodoro_app/android/app/src/main/AndroidManifest.xml b/pomodoro_app/android/app/src/main/AndroidManifest.xml index 05764e4..51d5919 100644 --- a/pomodoro_app/android/app/src/main/AndroidManifest.xml +++ b/pomodoro_app/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + createState() => PomodoroScreenState(); +} + +/// State for [PomodoroScreen], exposed for testing. +@visibleForTesting +class PomodoroScreenState extends State { + 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 _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( + 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), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/pomodoro_app/lib/services/notification_service.dart b/pomodoro_app/lib/services/notification_service.dart new file mode 100644 index 0000000..2476c8a --- /dev/null +++ b/pomodoro_app/lib/services/notification_service.dart @@ -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 Function(String, List)? runProcess, + }) : _runProcess = runProcess ?? Process.run; + + final Future Function(String, List) _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 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 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 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 _notify({ + required String title, + required String body, + List 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 future) {} diff --git a/pomodoro_app/lib/services/pomodoro_timer.dart b/pomodoro_app/lib/services/pomodoro_timer.dart new file mode 100644 index 0000000..3008976 --- /dev/null +++ b/pomodoro_app/lib/services/pomodoro_timer.dart @@ -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(); + } +} diff --git a/pomodoro_app/lib/services/sound_service.dart b/pomodoro_app/lib/services/sound_service.dart new file mode 100644 index 0000000..bf48048 --- /dev/null +++ b/pomodoro_app/lib/services/sound_service.dart @@ -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 Function(String assetPath)? playCallback, + }) : _playCallback = playCallback; + + final Future 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 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; + } +} diff --git a/pomodoro_app/lib/services/sync_service.dart b/pomodoro_app/lib/services/sync_service.dart new file mode 100644 index 0000000..7c19861 --- /dev/null +++ b/pomodoro_app/lib/services/sync_service.dart @@ -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 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 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 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 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({ + '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; + + // Ignore own messages. + if (map['deviceId'] == deviceId) return; + + final state = _decodeState(map['state'] as Map); + 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 _encodeMessage(PomodoroState state, String action) { + final map = { + 'deviceId': deviceId, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + 'action': action, + 'state': _encodeState(state), + }; + return utf8.encode(jsonEncode(map)); + } + + static Map _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 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 _acquireMulticastLock() async { + if (!Platform.isAndroid) return; + try { + await _methodChannel.invokeMethod('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 _releaseMulticastLock() async { + if (!Platform.isAndroid) return; + try { + await _methodChannel.invokeMethod('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(); + } +} diff --git a/pomodoro_app/lib/theme/pomodoro_theme.dart b/pomodoro_app/lib/theme/pomodoro_theme.dart new file mode 100644 index 0000000..499a138 --- /dev/null +++ b/pomodoro_app/lib/theme/pomodoro_theme.dart @@ -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, + ), + ), + ), + ); + } +} diff --git a/pomodoro_app/lib/widgets/pomodoro_indicators.dart b/pomodoro_app/lib/widgets/pomodoro_indicators.dart new file mode 100644 index 0000000..d938cda --- /dev/null +++ b/pomodoro_app/lib/widgets/pomodoro_indicators.dart @@ -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, + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/pomodoro_app/lib/widgets/timer_controls.dart b/pomodoro_app/lib/widgets/timer_controls.dart new file mode 100644 index 0000000..ba369cb --- /dev/null +++ b/pomodoro_app/lib/widgets/timer_controls.dart @@ -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, + ), + ], + ); + } +} diff --git a/pomodoro_app/lib/widgets/timer_display.dart b/pomodoro_app/lib/widgets/timer_display.dart new file mode 100644 index 0000000..7b1b86b --- /dev/null +++ b/pomodoro_app/lib/widgets/timer_display.dart @@ -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, + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/pomodoro_app/packaging/arch/PKGBUILD b/pomodoro_app/packaging/arch/PKGBUILD index 80384af..a807ebd 100644 --- a/pomodoro_app/packaging/arch/PKGBUILD +++ b/pomodoro_app/packaging/arch/PKGBUILD @@ -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" \ diff --git a/pomodoro_app/test/models/pomodoro_state_test.dart b/pomodoro_app/test/models/pomodoro_state_test.dart index a9ebd92..46b1750 100644 --- a/pomodoro_app/test/models/pomodoro_state_test.dart +++ b/pomodoro_app/test/models/pomodoro_state_test.dart @@ -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(); diff --git a/pomodoro_app/test/screens/pomodoro_screen_test.dart b/pomodoro_app/test/screens/pomodoro_screen_test.dart index d533b51..c460db4 100644 --- a/pomodoro_app/test/screens/pomodoro_screen_test.dart +++ b/pomodoro_app/test/screens/pomodoro_screen_test.dart @@ -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); + }); }); } diff --git a/pomodoro_app/test/services/notification_service_test.dart b/pomodoro_app/test/services/notification_service_test.dart new file mode 100644 index 0000000..908c525 --- /dev/null +++ b/pomodoro_app/test/services/notification_service_test.dart @@ -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 args; +} + +void main() { + group('NotificationService', () { + late List<_Call> calls; + late NotificationService service; + + Future mockRun(String exec, List 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}'); + }); + }); +} diff --git a/pomodoro_app/test/services/pomodoro_timer_test.dart b/pomodoro_app/test/services/pomodoro_timer_test.dart index e071bca..b26ef26 100644 --- a/pomodoro_app/test/services/pomodoro_timer_test.dart +++ b/pomodoro_app/test/services/pomodoro_timer_test.dart @@ -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; diff --git a/pomodoro_app/test/services/sync_service_test.dart b/pomodoro_app/test/services/sync_service_test.dart index 05ed281..69befd3 100644 --- a/pomodoro_app/test/services/sync_service_test.dart +++ b/pomodoro_app/test/services/sync_service_test.dart @@ -10,12 +10,12 @@ import 'package:pomodoro_app/services/sync_service.dart'; /// injecting received messages. class FakeDatagramSocket implements RawDatagramSocket { final _controller = StreamController.broadcast(); - final List<_SentDatagram> sentMessages = []; + final List sentMessages = []; Datagram? _pendingDatagram; @override int send(List 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 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, ); diff --git a/python_pkg/brother_printer/check_brother_printer.py b/python_pkg/brother_printer/check_brother_printer.py index 3d2c8b9..a97280a 100644 --- a/python_pkg/brother_printer/check_brother_printer.py +++ b/python_pkg/brother_printer/check_brother_printer.py @@ -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())