diff --git a/assets/icons/tray_24.png b/assets/icons/tray_24.png deleted file mode 100644 index f03b0ad..0000000 Binary files a/assets/icons/tray_24.png and /dev/null differ diff --git a/assets/icons/tray_48.png b/assets/icons/tray_48.png deleted file mode 100644 index a421bd9..0000000 Binary files a/assets/icons/tray_48.png and /dev/null differ diff --git a/docs/knowledge_graph.md b/docs/knowledge_graph.md index 44a2769..4c91144 100644 --- a/docs/knowledge_graph.md +++ b/docs/knowledge_graph.md @@ -60,10 +60,16 @@ graph TD - **2026-07-15 — Missing app icon after rename**: not a code bug. The dev machine's `~/.local/share` still held the `com.xernai.breaktime` desktop entry + hicolor SVG (Exec pointing at the deleted `Github/breaktime` clone) and nothing had installed `com.xernai.restifeye.svg`, so every surface (shell, dock, notifications, autostart entry) fell back to the generic icon — dev installs are hand-rolled, only RPM/AppImage install the icon. Fixed by installing the new desktop entry + SVG locally and deleting the stale pair. One repo change: `StartupWMClass` in the desktop asset corrected `RestifEye` → `com.xernai.restifeye`, because the runner calls `g_set_prgname(APPLICATION_ID)` so the window class/app-id is the reverse-DNS id — the old hint named a class no window ever has. **Reminder: after any app-id/binary rename, refresh the manual dev install (desktop entry, hicolor icon, autostart Exec).** ## 5. Current State -- **M0–M4 code complete**: engine, data, adapters, overlay+exercises, analytics+advice, updates+autostart, packaging+site+workflows. **95 tests green**, `flutter analyze --fatal-infos` clean, `dart format` clean. +- **M0–M4 code complete**: engine, data, adapters, overlay+exercises, analytics+advice, updates+autostart, packaging+site+workflows. **131 tests green**, `flutter analyze --fatal-infos` clean, `dart format` clean. - **2026-07-13 live-verified on Fedora GNOME Wayland**: release build succeeded; app ran 85 s in dev mode (isolated XDG_DATA_HOME), recorded activity, persisted snapshots, and correctly HELD a due break while the user was idle (anti-annoyance behavior observed in the wild). Mutter IdleMonitor and gnome-shell notification daemon confirmed present. - **Repo**: `github.com/Xern-AI/RestifEye` (renamed from `breaktime` 2026-07-15; both old URLs redirect on github.com — GitHub **Pages does NOT redirect**, the site moves to `xern-ai.github.io/RestifEye`). All baked-in slugs (version.dart update checker, metainfo, RPM spec, site) point to Xern-AI/RestifEye. - **Remaining before v0.1.0 tag**: user re-runs docs/qa-checklist.md (new items: single-instance relaunch, Skip action + budget, windowed breaks, tray menu, autostart default) then `git tag v0.1.0 && git push --tags`. - **RENAME EXECUTED 2026-07-15 — the app is RestifEye** (superseded the 2026-07-13 shortlist Fermata/Pausely/Unclench/Restio/Lull, never verified). Casing scheme: display + binary + artifacts `RestifEye` (RPM Name, AppImage, `/opt/RestifEye`, `Exec=`); app id **lowercase** `com.xernai.restifeye` (also `StartupWMClass=`, since `g_set_prgname(APPLICATION_ID)` makes it the real window class) (desktop/metainfo/icon basenames match); Dart package `restifeye` (lowercase is compiler-enforced); env var `RESTIFEYE_DEV`; repo slug `Xern-AI/RestifEye`. `AppDatabase.open()` does a one-shot legacy migration (`~/.local/share/breaktime/breaktime.db[-wal/-shm]` → `RestifEye/RestifEye.db`). Uncommitted; email `xernaitech@gmail.com` alias not yet created; restifeye.com/.app/.io still unregistered; USPTO TESS check still open. - **Names verified & rejected 2026-07-15**: *Cessation* (clinical/permanent-stop connotation, no check needed). *Respite* — FATAL: getrespite.app is an upcoming Mac break-reminder app with the exact name+category (and Mac is our future paid platform); respite.app registered; crates.io taken. *Interlude* — crowded: App Store already has "Interlude: End Phone Addiction" and "Interlude – Mindful Wait" (both digital-wellbeing/break apps → weak trademark position in-class); interlude.app parked on Atom.com marketplace (premium price); interludeapp.com taken; crates.io taken; pub.dev free; no dominant GitHub repo. USPTO full TESS search still not done for any candidate. - **Restifeye (user's coinage, verified 2026-07-15)**: PASSES all availability checks — zero web hits, GitHub 0 repos, pub.dev free, crates.io free, App Store 0 results, restifeye.com/.app/.io all unregistered. Risks flagged: (1) mishearing/misspelling lands on "Restify" — an existing Play Store screen-time app (com.restify) in our exact category, plus the restify.js Node framework; (2) spelling/pronunciation ambiguity hurts word-of-mouth; (3) "-eye" suffix undersells non-eye features (posture breaks, analytics). USPTO TESS formality still open but coined term + zero web presence = low risk. Registering the domains is cheap insurance if the user is leaning this way. + +- **2026-07-18 — Snooze budget was per-*engine*, not per-*kind* (user QA round 4)**: a 3-snooze budget could be stretched to 5 on a long break. `_snoozesLeft` was one global counter, but micro and long run independent, interleavable cycles. Snoozing a long break pushes `_longDue` past `_microDue + _mergeWindow`, so `_nextKind()` flips to the already-due micro break, which fires in the gap; completing that micro cycle refilled the shared counter and handed the still-pending long break a fresh budget on top of what it had spent. Now `Map`, refilled only by that kind's own `_finishCycle` (a long break still refills micro, because it genuinely restarts that cycle). Second half of the fix: `_resetDue` no longer refills the budget as a **side effect of rescheduling a timer** — an SRP violation that made any micro reschedule silently rearm a pending long break. Budget changes now live only at real cycle boundaries, so the three resume paths (pause, work hours, `updateConfig`) refill explicitly. +- **2026-07-18 — Media/fullscreen pause (`PresentationSignals`)**: breaks hold while something on screen must not be interrupted. Built on **idle inhibitors, not audio** — video players, browsers in fullscreen video, presentation tools and games all take one so the screen never blanks; music players do not, so a background playlist can't suppress breaks all day. Two sources unioned: gnome-session `IsInhibited(8)` (where `org.freedesktop.ScreenSaver.Inhibit` lands on GNOME; `GetInhibitors`→`GetAppId` also names the app) and logind `ListInhibitors` filtered to `mode == block` + `what` containing `idle` (desktop-independent fallback). **Both verified live** on Fedora GNOME Wayland against a real `ScreenSaver.Inhibit` and a `systemd-inhibit --what=idle`. Deliberately a *pause*, not the busy/deferral path — a film outlasts any sane `deferCap`, and a cap would guarantee the interruption the feature prevents. Two semantics worth remembering: (1) **timers are not reset on resume** — the monotonic interval keeps running down during playback, because two hours of video is still two hours of screen time and a film must not buy a fresh 20 minutes; an overdue break arrives via the existing `_rewarn` short lead rather than seizing the screen as the credits roll. (2) A break already on screen is **left to finish** — abandoning it would log a `BreakEscaped` the user never earned. `Paused` gained a `PauseReason` (user/workHours/media, precedence in that order) + `byApp`. TTL/in-flight caching was generalised out of `ContextSampler` into `PolledValue`; the presentation sampler polls unconditionally (the pause must engage while the film plays, which is exactly when no break is near) while busy-probing stays gated on break proximity. +- **2026-07-18 — Expressive tray icon (`core/mood/` + `TrayMoodPresenter`)**: the icon carries a face whose colour AND shape track how the day is going. Colour is never the only signal — every mood changes eyes or mouth too, for colour-vision deficiency and monochrome tray themes — and the SNI `ToolTip` states the mood in words, because a red icon with no explanation is just anxiety. The mark's two pause bars were already readable as eyes, which is what made this possible without abandoning the brand. Faces are **drawn at runtime** with `PictureRecorder` → `rawStraightRgba` → ARGB32 (7 moods × 2 sizes would be 14 assets to keep in sync, and the pulse needs in-between frames); the bundled `assets/icons/tray_*.png` and `app/tray_icons.dart` are **gone**. SNI hosts cache icons, so `update()` must emit `NewIcon`/`NewToolTip` or nothing visibly changes. Rules run over a **rolling window of recent break responses**, not day totals — totals mean one bad morning colours the icon red until midnight and a good afternoon can never earn its way back. `MoodTracker` adds **asymmetric hysteresis: praise quickly, scold slowly** (improvement adopted at once, worsening must persist 3 samples), without which the icon flickers as breaks roll through the window. A snooze is not counted as a miss until habitual — it's a deferral the app itself offers. The window is persisted (`mood_recent`) so restarting isn't a way to clear a warning. Animation is a 4-frame acknowledgement on change, never an idle loop: every frame is a D-Bus signal plus a host re-read. `MoodService` (what the app feels) is split from `TrayMoodPresenter` (how it looks) so only the second half needs a rendering pipeline. **Regenerate the face contact sheet** by rendering `renderTrayFace` for each `Mood` into a canvas in a throwaway test and writing the PNG to /tmp — that is how the `tired` lids were caught reading as *angry* (both sloped the same way; they must mirror, drooping outward). +- **2026-07-18 — Break no longer leaves the window on screen**: `WindowTakeover` samples `isVisible()` on the way *into* a break and restores it on the way out, so a break that started from the tray ends back in the tray instead of leaving the app in the user's face to be dismissed by hand. Guarded on the `leaving` transition so `forceRestore()` can never hide a window the user opened themselves; an unreadable visibility is treated as "was visible" (a window left open is recoverable, a hidden one the user was using is not). +- **2026-07-18 — Exercise intensity + deck 12→24**: every exercise carries `ExerciseIntensity` (soft/medium/heavy) with a user ceiling in Settings, default medium. It's a physical constraint, not a taste: squats are impossible in an open-plan office, and an overlay suggesting the impossible teaches people to dismiss overlays. When opt-outs and the ceiling together leave nothing, **opt-outs yield first**. Ten new illustrations came from extending the shared `_figure` primitive (fold/twist/legBend/kneeLift/stance/armsUp) rather than new painters, so a squat bends visible knees instead of shrinking. Two bugs found only by rendering frames and looking: jumping-jack arms swept past vertical and drew through the head, and rooting arms at the spine grazed it even after clamping — arms now hang from shoulder width and peak at ~43°. The art→painter switch was collapsed from 12 near-identical blocks to one line per art (would have been 22) while keeping compile-time exhaustiveness. Exercise ids stay append-only (the exercise log references them). diff --git a/lib/app/bootstrap.dart b/lib/app/bootstrap.dart index 1195a3d..b8ca4ef 100644 --- a/lib/app/bootstrap.dart +++ b/lib/app/bootstrap.dart @@ -6,6 +6,7 @@ import 'package:dbus/dbus.dart'; import '../core/clock.dart'; import '../core/engine/engine.dart'; import '../core/models/break_config.dart'; +import '../core/models/exercise.dart'; import '../data/activity_repository.dart'; import '../data/break_log_repository.dart'; import '../data/database.dart'; @@ -20,6 +21,7 @@ import '../platform/linux/gnome_tray_support.dart'; import '../platform/linux/linux_break_notifier.dart'; import '../platform/linux/linux_context_signals.dart'; import '../platform/linux/linux_idle_monitor.dart'; +import '../platform/linux/linux_presentation_signals.dart'; import '../platform/linux/linux_session_signals.dart'; import '../platform/linux/linux_sound_player.dart'; import '../platform/linux/sni_tray.dart'; @@ -29,6 +31,7 @@ import '../services/activity_recorder.dart'; import '../services/context_sampler.dart'; import '../services/engine_service.dart'; import '../services/exercise_picker.dart'; +import '../services/mood_service.dart'; import '../services/notification_coordinator.dart'; import '../services/rollup_service.dart'; import '../services/update_service.dart'; @@ -68,6 +71,8 @@ typedef BootResult = ({ TrayIndicator tray, SoundPlayer sounds, TrayHostSupport traySupport, + MoodService mood, + bool moodIndicator, }); /// Builds the real object graph: database, engine, Linux adapters, @@ -79,6 +84,9 @@ Future bootstrap() async { final config = isDevMode ? _devConfig : await settings.loadConfig(); final snapshot = isDevMode ? null : await settings.loadSnapshot(); final optOuts = await settings.loadExerciseOptOuts(); + final maxIntensityName = await settings.readValue( + SettingsRepository.keyMaxIntensity, + ); final clock = SystemClock(); final engine = BreakEngine( @@ -150,27 +158,58 @@ Future bootstrap() async { ).maybeCheck(), ); - final service = EngineService( + final service = + EngineService( + engine: engine, + clock: clock, + idleMonitor: LinuxIdleMonitor(sessionBus), + sessionSignals: LinuxSessionSignals( + session: sessionBus, + system: systemBus, + ), + sampler: ContextSampler(LinuxContextSignals()), + presentation: PresentationSampler( + LinuxPresentationSignals(session: sessionBus, system: systemBus), + ), + breakLog: breakLogRepo, + settings: settings, + recorder: ActivityRecorder(activityRepo.insertSlice), + ) + ..pauseDuringMedia = await settings.getFlag( + SettingsRepository.flagPauseDuringMedia, + fallback: true, + ) + ..start(); + + final mood = MoodService( engine: engine, clock: clock, - idleMonitor: LinuxIdleMonitor(sessionBus), - sessionSignals: LinuxSessionSignals(session: sessionBus, system: systemBus), - sampler: ContextSampler(LinuxContextSignals()), - breakLog: breakLogRepo, settings: settings, - recorder: ActivityRecorder(activityRepo.insertSlice), - )..start(); + activity: activityRepo, + ); + await mood.start(); return ( db: db, service: service, overlay: overlay, config: config, - picker: ExercisePicker(optOuts: optOuts), + picker: ExercisePicker( + optOuts: optOuts, + maxIntensity: ExerciseIntensity.values.firstWhere( + (v) => v.name == maxIntensityName, + orElse: () => ExerciseIntensity.medium, + ), + ), autostart: autostart, tray: SniTrayIndicator(sessionBus), sounds: sounds, traySupport: traySupport, + mood: mood, + moodIndicator: await settings.getFlag( + SettingsRepository.flagMoodIndicator, + fallback: true, + ), ); } diff --git a/lib/app/tray_face.dart b/lib/app/tray_face.dart new file mode 100644 index 0000000..c045639 --- /dev/null +++ b/lib/app/tray_face.dart @@ -0,0 +1,286 @@ +import 'dart:ui' as ui; + +import 'package:flutter/painting.dart'; + +import '../core/mood/mood.dart'; +import '../platform/interfaces/tray_indicator.dart'; + +/// How a mood looks. Colour is never the only difference — every mood also +/// changes the eyes or the mouth, so the indicator still reads for the ~8% of +/// men with a colour vision deficiency, and in the monochrome tray themes +/// some desktops apply. +class MoodFace { + const MoodFace({ + required this.background, + required this.eyes, + required this.mouth, + required this.tooltip, + }); + + final Color background; + final TrayEyes eyes; + final TrayMouth mouth; + + /// Shown on hover. A coloured face with no explanation is just anxiety, so + /// every mood says in words what it means. + final String tooltip; + + static const _brand = Color(0xFF00897B); + + static MoodFace of(Mood mood) => switch (mood) { + Mood.paused => const MoodFace( + background: Color(0xFF6B7280), + eyes: TrayEyes.closed, + mouth: TrayMouth.flat, + tooltip: 'Paused — breaks are off', + ), + Mood.resting => const MoodFace( + background: Color(0xFF3B82F6), + eyes: TrayEyes.closed, + mouth: TrayMouth.smile, + tooltip: 'Break time — rest your eyes', + ), + Mood.great => const MoodFace( + background: Color(0xFF16A34A), + eyes: TrayEyes.happy, + mouth: TrayMouth.grin, + tooltip: "You're taking your breaks — keep it up", + ), + Mood.good => const MoodFace( + background: _brand, + eyes: TrayEyes.open, + mouth: TrayMouth.smile, + tooltip: 'On schedule', + ), + Mood.tired => const MoodFace( + background: Color(0xFFEA580C), + eyes: TrayEyes.droopy, + mouth: TrayMouth.flat, + tooltip: "That's a long stretch at the screen — take a proper break", + ), + Mood.slipping => const MoodFace( + background: Color(0xFFEAB308), + eyes: TrayEyes.open, + mouth: TrayMouth.flat, + tooltip: 'Breaks are slipping — try taking the next one', + ), + Mood.ignoring => const MoodFace( + background: Color(0xFFDC2626), + eyes: TrayEyes.open, + mouth: TrayMouth.frown, + tooltip: 'Several breaks skipped — your eyes need one', + ), + }; +} + +/// Eye treatments. Public because [MoodFace] exposes them, and because the +/// shape — not just the colour — is what carries the meaning. +enum TrayEyes { open, happy, closed, droopy } + +enum TrayMouth { grin, smile, flat, frown } + +/// Sizes the tray asks for. 24 px covers most panels, 48 px covers HiDPI; +/// the host picks whichever fits. +const _traySizes = [24, 48]; + +/// Renders [mood] into the ARGB32 pixmaps the StatusNotifierItem protocol +/// expects, at every size a host might want. +/// +/// Drawn at runtime rather than shipped as PNGs: seven moods times two sizes +/// is fourteen assets to keep in sync, and the pulse animation needs +/// in-between frames that could not be pre-rendered sensibly anyway. +Future> renderTrayFace(Mood mood, {double scale = 1}) async { + final face = MoodFace.of(mood); + final pixmaps = []; + for (final size in _traySizes) { + pixmaps.add(await _render(face, size, scale)); + } + return pixmaps; +} + +Future _render(MoodFace face, int size, double scale) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + final s = size.toDouble(); + + // The pulse scales the face about its centre. Clamped so a frame can never + // grow past the pixmap and get clipped into a square. + final factor = scale.clamp(0.8, 1.06); + canvas + ..translate(s / 2, s / 2) + ..scale(factor) + ..translate(-s / 2, -s / 2); + + _paintFace(canvas, face, s); + + final picture = recorder.endRecording(); + final image = await picture.toImage(size, size); + picture.dispose(); + final rgba = await image.toByteData( + format: ui.ImageByteFormat.rawStraightRgba, + ); + image.dispose(); + + return TrayPixmap( + width: size, + height: size, + argb32: _rgbaToArgb32(rgba!.buffer.asUint8List()), + ); +} + +void _paintFace(Canvas canvas, MoodFace face, double s) { + // All geometry is a fraction of the canvas so 24 px and 48 px are the same + // drawing, not two hand-tuned ones. + final inset = s * 0.0625; // matches the app icon's 8/128 margin + final rect = Rect.fromLTWH(inset, inset, s - inset * 2, s - inset * 2); + final body = Paint()..color = face.background; + canvas.drawRRect( + RRect.fromRectAndRadius(rect, Radius.circular(s * 0.22)), + body, + ); + + const ink = Color(0xFFFFFFFF); + final stroke = Paint() + ..color = ink + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeWidth = s * 0.075; + final fill = Paint()..color = ink; + + final eyeY = s * 0.42; + final eyeDx = s * 0.15; + _paintEyes(canvas, face.eyes, s, eyeY, eyeDx, stroke, fill); + _paintMouth(canvas, face.mouth, s, stroke); +} + +void _paintEyes( + Canvas canvas, + TrayEyes eyes, + double s, + double eyeY, + double eyeDx, + Paint stroke, + Paint fill, +) { + final centres = [s / 2 - eyeDx, s / 2 + eyeDx]; + switch (eyes) { + case TrayEyes.open: + // The app mark's two pause bars, read as eyes — the whole reason this + // icon can carry an expression at all. + for (final x in centres) { + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(x, eyeY), + width: s * 0.09, + height: s * 0.2, + ), + Radius.circular(s * 0.045), + ), + fill, + ); + } + case TrayEyes.happy: + // Upward arcs: the classic ^^ smiling eyes. + for (final x in centres) { + canvas.drawArc( + Rect.fromCenter( + center: Offset(x, eyeY + s * 0.03), + width: s * 0.16, + height: s * 0.16, + ), + 3.34, // ~191° + 2.5, // sweep across the top + false, + stroke, + ); + } + case TrayEyes.closed: + for (final x in centres) { + canvas.drawLine( + Offset(x - s * 0.06, eyeY), + Offset(x + s * 0.06, eyeY), + stroke, + ); + } + case TrayEyes.droopy: + // Half-lidded: a short bar with a heavy lid over its top half. + // + // The lids must be *mirrored*, sloping down toward the outside of the + // face. Sloping both the same way gives one raised brow and one lowered + // one, which the eye reads as anger rather than fatigue — and telling a + // tired user off is the opposite of the point. + for (var i = 0; i < centres.length; i++) { + final x = centres[i]; + final outward = i == 0 ? -1.0 : 1.0; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(x, eyeY + s * 0.03), + width: s * 0.09, + height: s * 0.11, + ), + Radius.circular(s * 0.045), + ), + fill, + ); + canvas.drawLine( + Offset(x + outward * s * 0.085, eyeY - s * 0.015), + Offset(x - outward * s * 0.085, eyeY - s * 0.065), + stroke, + ); + } + } +} + +void _paintMouth(Canvas canvas, TrayMouth mouth, double s, Paint stroke) { + final centre = Offset(s / 2, s * 0.62); + switch (mouth) { + case TrayMouth.grin: + canvas.drawArc( + Rect.fromCenter(center: centre, width: s * 0.38, height: s * 0.3), + 0.35, + 2.44, + false, + stroke, + ); + case TrayMouth.smile: + canvas.drawArc( + Rect.fromCenter(center: centre, width: s * 0.3, height: s * 0.2), + 0.52, + 2.1, + false, + stroke, + ); + case TrayMouth.flat: + canvas.drawLine( + Offset(s / 2 - s * 0.13, centre.dy + s * 0.03), + Offset(s / 2 + s * 0.13, centre.dy + s * 0.03), + stroke, + ); + case TrayMouth.frown: + canvas.drawArc( + Rect.fromCenter( + center: Offset(s / 2, s * 0.74), + width: s * 0.32, + height: s * 0.22, + ), + 3.66, // mirrored: arc opens upward + 1.96, + false, + stroke, + ); + } +} + +/// Flutter hands back straight RGBA; SNI wants ARGB32 in network byte order. +List _rgbaToArgb32(List rgba) { + final argb = List.filled(rgba.length, 0); + for (var i = 0; i < rgba.length; i += 4) { + argb[i] = rgba[i + 3]; // A + argb[i + 1] = rgba[i]; // R + argb[i + 2] = rgba[i + 1]; // G + argb[i + 3] = rgba[i + 2]; // B + } + return argb; +} diff --git a/lib/app/tray_icons.dart b/lib/app/tray_icons.dart deleted file mode 100644 index 0afc235..0000000 --- a/lib/app/tray_icons.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'dart:ui' as ui; - -import 'package:flutter/services.dart'; - -import '../platform/interfaces/tray_indicator.dart'; - -/// Decodes the bundled tray PNGs into the ARGB32 pixmaps the -/// StatusNotifierItem protocol expects (network byte order). -Future> loadTrayPixmaps() async { - const paths = ['assets/icons/tray_24.png', 'assets/icons/tray_48.png']; - final pixmaps = []; - for (final path in paths) { - final data = await rootBundle.load(path); - final codec = await ui.instantiateImageCodec(data.buffer.asUint8List()); - final image = (await codec.getNextFrame()).image; - final rgba = await image.toByteData( - format: ui.ImageByteFormat.rawStraightRgba, - ); - if (rgba == null) continue; - final bytes = rgba.buffer.asUint8List(); - final argb = Uint8List(bytes.length); - for (var i = 0; i < bytes.length; i += 4) { - argb[i] = bytes[i + 3]; // A - argb[i + 1] = bytes[i]; // R - argb[i + 2] = bytes[i + 1]; // G - argb[i + 3] = bytes[i + 2]; // B - } - pixmaps.add( - TrayPixmap(width: image.width, height: image.height, argb32: argb), - ); - image.dispose(); - } - return pixmaps; -} diff --git a/lib/core/engine/engine.dart b/lib/core/engine/engine.dart index 89b86e9..cc8d6d7 100644 --- a/lib/core/engine/engine.dart +++ b/lib/core/engine/engine.dart @@ -13,6 +13,8 @@ class TickInput { this.idle = Duration.zero, this.locked = false, this.busy = false, + this.presenting = false, + this.presentingApp, }); /// Time since the last user input. @@ -23,6 +25,15 @@ class TickInput { /// Microphone/camera in use, or Do Not Disturb enabled. final bool busy; + + /// Something on screen must not be interrupted — a fullscreen video, a + /// presentation, a game. Distinct from [busy]: [busy] *defers* a break by + /// up to `deferCap`, while this *pauses* scheduling outright, because a + /// film runs far longer than any sane deferral cap. + final bool presenting; + + /// Which app is presenting, when the desktop tells us. + final String? presentingApp; } /// Deterministic break scheduler. Pure Dart: no timers, no I/O — the caller @@ -41,7 +52,7 @@ class BreakEngine { // seeded on *every* construction path. Leaving them at zero on restore // made the first break after each login strict (and stripped Snooze from // its notification), because `_strictNow = _snoozesLeft <= 0 && strict`. - _snoozesLeft = _config.snoozeBudget; + _refillSnoozes(); _consecutiveSkips = 0; if (restoreFrom != null) { final gap = _clock.now().difference(restoreFrom.savedAt); @@ -74,7 +85,15 @@ class BreakEngine { BreakKind _activeKind = BreakKind.micro; Duration _breakEndsAt = Duration.zero; - int _snoozesLeft = 0; + + /// Snoozes left, tracked **per break kind**. + /// + /// The two kinds run independent, interleavable cycles: snoozing a long + /// break pushes it past the merge window, letting an already-due micro + /// break fire in the gap. With one shared counter, that micro cycle's + /// completion refilled the budget the pending long break had already been + /// spending — so a 3-snooze budget could be stretched to 5. + final Map _snoozesLeft = {}; int _consecutiveSkips = 0; bool _strictNow = false; @@ -101,22 +120,42 @@ class BreakEngine { Duration _awayFloor = Duration.zero; bool _pausedByUser = false; bool _pausedByHours = false; + bool _pausedByMedia = false; + String? _presentingApp; BreakConfig get config => _config; Stream get phases => _phaseController.stream; Stream get events => _eventController.stream; - /// Whether the current break cycle still has snoozes left. - bool get canSnooze => _snoozesLeft > 0; + /// Whether the break cycle currently in play still has snoozes left. + bool get canSnooze => _snoozesLeftFor(_activeKind) > 0; + + int _snoozesLeftFor(BreakKind kind) => + _snoozesLeft[kind] ?? _config.snoozeBudget; + + /// Restores every kind's budget. Used at construction and wherever a + /// genuinely fresh scheduling epoch begins (resume, config change) — never + /// as a side effect of merely rescheduling a timer. + void _refillSnoozes() { + for (final kind in BreakKind.values) { + _snoozesLeft[kind] = _config.snoozeBudget; + } + } /// Whether the consecutive-skip budget still allows skipping a break. bool get canSkip => _consecutiveSkips < _config.skipBudget; EnginePhase get phase { + // Order is precedence: an explicit user pause outranks the automatic + // reasons, and work hours outrank media (no point reporting "video + // playing" at 2 a.m. on a Sunday when the engine is off anyway). if (_pausedByUser) { - return Paused(byUser: true, until: _pausedUntil); + return Paused(reason: PauseReason.user, until: _pausedUntil); + } + if (_pausedByHours) return const Paused(reason: PauseReason.workHours); + if (_pausedByMedia) { + return Paused(reason: PauseReason.media, byApp: _presentingApp); } - if (_pausedByHours) return const Paused(byUser: false); final now = _clock.elapsed(); return switch (_mode) { _Mode.monitoring => Monitoring( @@ -132,7 +171,7 @@ class BreakEngine { _Mode.inBreak => InBreak( kind: _activeKind, remaining: _clampZero(_breakEndsAt - now), - snoozesLeft: _snoozesLeft, + snoozesLeft: _snoozesLeftFor(_activeKind), strict: _strictNow, ), _Mode.deferred => Deferred( @@ -147,7 +186,9 @@ class BreakEngine { // The phase is published on every tick, even while paused: consumers // (the window takeover above all) re-derive their state from it, so a // silent engine would let them drift out of sync. - if (_updateUserPause() || _updateWorkHoursPause()) { + if (_updateUserPause() || + _updateWorkHoursPause() || + _updateMediaPause(input)) { _publishPhase(); return; } @@ -206,12 +247,13 @@ class BreakEngine { /// Returns false when the budget is exhausted (strict mode holds). bool snooze() { if (_mode != _Mode.warning && _mode != _Mode.inBreak) return false; - if (_snoozesLeft <= 0) return false; - _snoozesLeft -= 1; + final left = _snoozesLeftFor(_activeKind); + if (left <= 0) return false; + _snoozesLeft[_activeKind] = left - 1; final now = _clock.elapsed(); _setDue(_activeKind, now + _config.snoozeLength); _mode = _Mode.monitoring; - _emit(BreakSnoozed(_clock.now(), _activeKind, snoozesLeft: _snoozesLeft)); + _emit(BreakSnoozed(_clock.now(), _activeKind, snoozesLeft: left - 1)); _publishPhase(); return true; } @@ -264,6 +306,7 @@ class BreakEngine { final now = _clock.elapsed(); _resetDue(BreakKind.micro, from: now); _resetDue(BreakKind.long, from: now); + _refillSnoozes(); _mode = _Mode.monitoring; // Time spent paused is not rest to be credited. _awayFloor = now; @@ -293,6 +336,9 @@ class BreakEngine { final now = _clock.elapsed(); _resetDue(BreakKind.micro, from: now); _resetDue(BreakKind.long, from: now); + // New settings may raise or lower snoozeBudget; restart both budgets so + // the counters can never exceed the freshly-configured ceiling. + _refillSnoozes(); if (_mode != _Mode.inBreak) _mode = _Mode.monitoring; _publishPhase(); } @@ -398,6 +444,53 @@ class BreakEngine { _finishCycle(_activeKind, _clock.elapsed()); } + /// Holds all scheduling while something on screen must not be interrupted. + /// + /// Unlike the busy/deferral path this has no cap: a film is two hours, and + /// a cap would simply guarantee the interruption it exists to prevent. + bool _updateMediaPause(TickInput input) { + if (input.presenting && !_pausedByMedia) { + // A break already on screen is left to finish. The video is behind the + // overlay anyway, and abandoning it here would log a BreakEscaped the + // user did not earn. The pause takes effect on the next cycle instead. + if (_mode == _Mode.inBreak) return false; + _pausedByMedia = true; + _presentingApp = input.presentingApp; + _emit(EnginePausedByMedia(_clock.now(), byApp: input.presentingApp)); + } else if (!input.presenting && _pausedByMedia) { + _pausedByMedia = false; + _presentingApp = null; + final now = _clock.elapsed(); + // Timers are deliberately NOT reset. Two hours of fullscreen video is + // still two hours of screen time; restarting the interval would mean a + // long film buys the user zero breaks. The schedule simply resumes + // where it left off. + // + // Sitting through a film is not rest either, so nothing before this + // moment may be credited as an away span. + _awayFloor = now; + _awayBegan = null; + _awayWasLocked = false; + final due = _nextDue(); + if (now >= due - _config.warningLead) { + // Already due (usually well past it). Give the short heads-up rather + // than seizing the screen the instant the credits roll. + _activeKind = _nextKind(); + _cycleDue = due; + _rewarn(now); + } else { + _mode = _Mode.monitoring; + } + _emit(EngineResumed(_clock.now())); + } else if (_pausedByMedia && input.presentingApp != _presentingApp) { + _presentingApp = input.presentingApp; // one player handed off to another + } + return _pausedByMedia; + } + + /// Which app is currently holding breaks back, when the desktop says. + String? get presentingApp => _presentingApp; + bool _updateWorkHoursPause() { final within = _config.isWithinWorkHours(_clock.now()); if (!within && !_pausedByHours) { @@ -410,6 +503,7 @@ class BreakEngine { final now = _clock.elapsed(); _resetDue(BreakKind.micro, from: now); _resetDue(BreakKind.long, from: now); + _refillSnoozes(); _mode = _Mode.monitoring; // Time spent paused is not rest to be credited. _awayFloor = now; @@ -424,7 +518,7 @@ class BreakEngine { void _startBreak(Duration now) { _mode = _Mode.inBreak; _breakEndsAt = now + _config.breakDuration(_activeKind); - _strictNow = _snoozesLeft <= 0 && _config.strictMode; + _strictNow = _snoozesLeftFor(_activeKind) <= 0 && _config.strictMode; _emit(BreakStarted(_clock.now(), _activeKind, strict: _strictNow)); } @@ -444,9 +538,16 @@ class BreakEngine { /// A long break always resets the micro timer too. void _finishCycle(BreakKind kind, Duration now) { _resetDue(kind, from: now); - if (kind == BreakKind.long) _resetDue(BreakKind.micro, from: now); + _snoozesLeft[kind] = _config.snoozeBudget; + // A long break rests the eyes too, so it restarts the micro cycle — and + // a restarted cycle gets its budget back. Only *this* path may refill the + // micro budget; a micro cycle finishing must never touch the long one's, + // which may be mid-snooze while its break sits pending. + if (kind == BreakKind.long) { + _resetDue(BreakKind.micro, from: now); + _snoozesLeft[BreakKind.micro] = _config.snoozeBudget; + } _mode = _Mode.monitoring; - _snoozesLeft = _config.snoozeBudget; _strictNow = false; // Rest up to this point is settled; nothing before it may be credited // again. @@ -466,9 +567,12 @@ class BreakEngine { Duration _nextDue() => _nextKind() == BreakKind.long ? _longDue : _microDue; + /// Reschedules [kind]'s timer. Deliberately free of side effects: this used + /// to refill the snooze budget, which meant any micro reschedule silently + /// rearmed a pending long break's snoozes. Budget changes now live only at + /// real cycle boundaries. void _resetDue(BreakKind kind, {required Duration from}) { _setDue(kind, from + _config.interval(kind)); - if (kind == BreakKind.micro) _snoozesLeft = _config.snoozeBudget; } void _setDue(BreakKind kind, Duration due) { diff --git a/lib/core/engine/events.dart b/lib/core/engine/events.dart index a4aff6f..00621f9 100644 --- a/lib/core/engine/events.dart +++ b/lib/core/engine/events.dart @@ -64,6 +64,16 @@ class EnginePausedByWorkHours extends EngineEvent { const EnginePausedByWorkHours(super.at); } +/// Scheduling is on hold because something on screen must not be +/// interrupted — a fullscreen video, a presentation, a game. +class EnginePausedByMedia extends EngineEvent { + const EnginePausedByMedia(super.at, {this.byApp}); + + /// The app holding the inhibitor, when the desktop tells us. Null is + /// normal, not an error. + final String? byApp; +} + class EngineResumed extends EngineEvent { const EngineResumed(super.at); } diff --git a/lib/core/engine/phase.dart b/lib/core/engine/phase.dart index b32068a..302c374 100644 --- a/lib/core/engine/phase.dart +++ b/lib/core/engine/phase.dart @@ -57,14 +57,35 @@ class Deferred extends EnginePhase { final Duration recheckIn; } -/// Outside work hours, or paused by the user. +/// Why the engine is holding all scheduling. +enum PauseReason { + /// The user asked for it (indefinitely, or until [Paused.until]). + user, + + /// The clock is outside the configured work window. + workHours, + + /// Something on screen must not be interrupted — a fullscreen video, a + /// presentation, anything holding an idle inhibitor. + media, +} + +/// Outside work hours, paused by the user, or held back for media. class Paused extends EnginePhase { - const Paused({required this.byUser, this.until}); + const Paused({required this.reason, this.until, this.byApp}); + + final PauseReason reason; - final bool byUser; + /// For [PauseReason.media], the app holding breaks back, when the desktop + /// names it. Null is common and must not be treated as an error. + final String? byApp; /// Wall-clock moment a timed pause lapses and breaks resume by themselves. - /// Null for an open-ended pause (and always null outside work hours, which - /// ends on its own schedule). + /// Null for an open-ended pause (and always null for the automatic reasons, + /// which end on their own schedule). final DateTime? until; + + /// Only a user pause is reflected in the UI's pause toggle — the automatic + /// reasons must not make the toggle look switched on. + bool get byUser => reason == PauseReason.user; } diff --git a/lib/core/models/exercise.dart b/lib/core/models/exercise.dart index 58f1f87..b08cac7 100644 --- a/lib/core/models/exercise.dart +++ b/lib/core/models/exercise.dart @@ -15,6 +15,39 @@ enum ExerciseArt { sideStretch, walk, breathe, + neckTilt, + chestOpen, + torsoTwist, + forwardFold, + calfRaise, + armCircles, + squat, + lunge, + marchInPlace, + jumpingJacks, +} + +/// How much the exercise asks of the user — and of the room they are in. +/// +/// This exists because "stand up and do ten squats" is excellent advice at +/// home and impossible in an open-plan office or a coffee shop. Without a +/// ceiling, the heavier exercises would simply train users to dismiss the +/// overlay, which costs far more than the exercise was worth. +enum ExerciseIntensity { + /// Seated, invisible to anyone watching. + soft, + + /// Standing or stretching. Noticeable, but nothing anyone would remark on. + medium, + + /// Raises the heart rate. Needs space and a certain lack of self + /// consciousness. + heavy; + + /// Whether this fits within a [ceiling] the user has chosen. Relies on + /// declaration order, so the values must stay ordered least to most + /// demanding. + bool allowedBy(ExerciseIntensity ceiling) => index <= ceiling.index; } class Exercise { @@ -24,6 +57,7 @@ class Exercise { required this.steps, required this.tier, required this.art, + this.intensity = ExerciseIntensity.soft, }); final String id; @@ -33,6 +67,7 @@ class Exercise { /// Which break tier this exercise fits. final BreakKind tier; final ExerciseArt art; + final ExerciseIntensity intensity; } /// The built-in deck. IDs are stable — they appear in the exercise log @@ -138,6 +173,7 @@ const exerciseDeck = [ ], tier: BreakKind.long, art: ExerciseArt.posture, + intensity: ExerciseIntensity.medium, ), Exercise( id: 'side_stretch', @@ -149,6 +185,7 @@ const exerciseDeck = [ ], tier: BreakKind.long, art: ExerciseArt.sideStretch, + intensity: ExerciseIntensity.medium, ), Exercise( id: 'walk_around', @@ -160,6 +197,7 @@ const exerciseDeck = [ ], tier: BreakKind.long, art: ExerciseArt.walk, + intensity: ExerciseIntensity.medium, ), Exercise( id: 'deep_breathing', @@ -172,4 +210,136 @@ const exerciseDeck = [ tier: BreakKind.long, art: ExerciseArt.breathe, ), + // ---- long: added soft options ------------------------------------- + Exercise( + id: 'neck_side_stretch', + name: 'Neck side stretch', + steps: [ + 'Sit tall and drop one ear toward that shoulder', + 'Rest a hand on your head for gentle weight — do not pull', + 'Hold 15 seconds, then switch sides', + ], + tier: BreakKind.long, + art: ExerciseArt.neckTilt, + ), + Exercise( + id: 'chest_opener', + name: 'Chest opener', + steps: [ + 'Clasp your hands behind your back', + 'Straighten your arms and lift them slightly', + 'Open your chest and breathe; hold 15 seconds', + ], + tier: BreakKind.long, + art: ExerciseArt.chestOpen, + ), + Exercise( + id: 'seated_twist', + name: 'Seated twist', + steps: [ + 'Sit tall, feet flat on the floor', + 'Turn your torso to one side, hand on the chair back', + 'Hold 15 seconds, unwind slowly, switch sides', + ], + tier: BreakKind.long, + art: ExerciseArt.torsoTwist, + ), + // ---- long: medium --------------------------------------------------- + Exercise( + id: 'forward_fold', + name: 'Standing forward fold', + steps: [ + 'Stand with feet hip-width apart', + 'Hinge at the hips and let your upper body hang', + 'Keep a soft bend in the knees; hold 20 seconds', + ], + tier: BreakKind.long, + art: ExerciseArt.forwardFold, + intensity: ExerciseIntensity.medium, + ), + Exercise( + id: 'calf_raises', + name: 'Calf raises', + steps: [ + 'Stand tall, hand on the desk for balance', + 'Rise onto the balls of your feet', + 'Lower slowly; repeat 15 times', + ], + tier: BreakKind.long, + art: ExerciseArt.calfRaise, + intensity: ExerciseIntensity.medium, + ), + Exercise( + id: 'arm_circles', + name: 'Arm circles', + steps: [ + 'Stand and stretch both arms out to the sides', + 'Draw small circles forward for 15 seconds', + 'Reverse direction for another 15', + ], + tier: BreakKind.long, + art: ExerciseArt.armCircles, + intensity: ExerciseIntensity.medium, + ), + Exercise( + id: 'stairs_trip', + name: 'Take the stairs', + steps: [ + 'Leave the room and find a staircase', + 'Walk up and down one flight at an easy pace', + 'Come back with a glass of water', + ], + tier: BreakKind.long, + art: ExerciseArt.walk, + intensity: ExerciseIntensity.medium, + ), + // ---- long: heavy ---------------------------------------------------- + Exercise( + id: 'bodyweight_squats', + name: 'Bodyweight squats', + steps: [ + 'Stand with feet shoulder-width apart', + 'Sit back and down, keeping your chest up', + 'Drive through your heels to stand; repeat 12 times', + ], + tier: BreakKind.long, + art: ExerciseArt.squat, + intensity: ExerciseIntensity.heavy, + ), + Exercise( + id: 'alternating_lunges', + name: 'Alternating lunges', + steps: [ + 'Step forward and lower your back knee toward the floor', + 'Push back to standing', + 'Alternate legs, 8 each side', + ], + tier: BreakKind.long, + art: ExerciseArt.lunge, + intensity: ExerciseIntensity.heavy, + ), + Exercise( + id: 'high_knees', + name: 'March on the spot', + steps: [ + 'Stand tall and drive one knee up to hip height', + 'Alternate at a brisk pace, swinging your arms', + 'Keep going for 30 seconds', + ], + tier: BreakKind.long, + art: ExerciseArt.marchInPlace, + intensity: ExerciseIntensity.heavy, + ), + Exercise( + id: 'jumping_jacks', + name: 'Jumping jacks', + steps: [ + 'Stand with feet together, arms at your sides', + 'Jump your feet wide as your arms sweep overhead', + 'Jump back together; repeat for 30 seconds', + ], + tier: BreakKind.long, + art: ExerciseArt.jumpingJacks, + intensity: ExerciseIntensity.heavy, + ), ]; diff --git a/lib/core/mood/mood.dart b/lib/core/mood/mood.dart new file mode 100644 index 0000000..df13fa0 --- /dev/null +++ b/lib/core/mood/mood.dart @@ -0,0 +1,48 @@ +/// How the app feels about how the user's day is going, expressed through +/// the tray icon's face. +/// +/// Two of these are *transient* states of the moment rather than judgements +/// of behaviour ([Mood.paused], [Mood.resting]); the rest sit on a severity +/// scale that drives the hysteresis in `MoodTracker`. +enum Mood { + /// Breaks are off. Neutral — the app has no opinion while it is not + /// working. + paused, + + /// A break is happening right now. Eyes closed, at peace. + resting, + + /// Following breaks consistently. + great, + + /// Nothing to report. The everyday resting state. + good, + + /// A long stretch at the screen without enough rest behind it. + tired, + + /// Breaks are starting to be pushed away. + slipping, + + /// Breaks are being skipped repeatedly. + ignoring; + + /// Whether this mood describes the current moment rather than a pattern of + /// behaviour. Transient moods bypass hysteresis: they must appear and clear + /// instantly, and they must not overwrite the behavioural mood underneath. + bool get isTransient => this == Mood.paused || this == Mood.resting; + + /// Ordering used to decide whether a change is an escalation (slower, to + /// avoid nagging on one bad break) or a de-escalation (immediate, because + /// good behaviour should be acknowledged at once). + /// + /// Transient moods are not on the scale and must never be compared. + int get severity => switch (this) { + Mood.great => 0, + Mood.good => 1, + Mood.tired => 2, + Mood.slipping => 3, + Mood.ignoring => 4, + Mood.paused || Mood.resting => -1, + }; +} diff --git a/lib/core/mood/mood_rules.dart b/lib/core/mood/mood_rules.dart new file mode 100644 index 0000000..542afea --- /dev/null +++ b/lib/core/mood/mood_rules.dart @@ -0,0 +1,118 @@ +import 'mood.dart'; + +/// What happened to a break the user was offered. +enum BreakResponse { honored, snoozed, skipped, escaped } + +/// Everything the mood rules are allowed to see. +/// +/// Deliberately a *rolling window* of recent responses rather than day +/// totals: judging on totals means one bad morning colours the icon red +/// until midnight, and a good afternoon can never earn its way back. +class MoodInputs { + const MoodInputs({ + this.recent = const [], + this.screenTime = Duration.zero, + this.sinceLastRest = Duration.zero, + this.inBreak = false, + this.paused = false, + }); + + /// Most recent responses, oldest first. Callers pass at most + /// [MoodRules.window] entries; longer lists are truncated to it. + final List recent; + + /// Active screen time so far today. + final Duration screenTime; + + /// Time since the last break that actually happened (completed or + /// credited). + final Duration sinceLastRest; + + final bool inBreak; + final bool paused; +} + +/// Thresholds, gathered so they can be reasoned about (and tuned) in one +/// place instead of being scattered through the branches below. +class MoodRules { + const MoodRules({ + this.window = 5, + this.ignoringMisses = 3, + this.slippingMisses = 2, + this.slippingSnoozes = 3, + this.greatHonorRate = 0.8, + this.tiredAfterRest = const Duration(minutes: 90), + this.tiredAfterScreenTime = const Duration(hours: 6), + }); + + /// How many recent responses the mood is judged on. + final int window; + + /// Skips/escapes within the window that mean breaks are being ignored. + final int ignoringMisses; + + /// Skips/escapes within the window that mean breaks are slipping. + final int slippingMisses; + + /// Snoozes alone are mild; this many in the window still counts as + /// slipping. + final int slippingSnoozes; + + /// Fraction of the window that must be honored to earn [Mood.great]. + final double greatHonorRate; + + /// No real rest for this long reads as tired... + final Duration tiredAfterRest; + + /// ...as does a long day, even a compliant one. + final Duration tiredAfterScreenTime; +} + +/// Maps behaviour to a face. Pure: same inputs, same mood, always. +/// +/// Precedence is deliberate. The two transient states win outright — the +/// icon must reflect what is happening right now before it reflects a +/// pattern. Among the behavioural moods the most actionable wins: being +/// told you are ignoring breaks matters more than being told the day is +/// long, and both matter more than praise. +Mood computeMood(MoodInputs inputs, {MoodRules rules = const MoodRules()}) { + if (inputs.paused) return Mood.paused; + if (inputs.inBreak) return Mood.resting; + + final recent = inputs.recent.length > rules.window + ? inputs.recent.sublist(inputs.recent.length - rules.window) + : inputs.recent; + + // No history yet: a fresh install, or the first break of the day. Withhold + // judgement rather than inventing an opinion from nothing. + if (recent.isEmpty) return _fatigueOr(Mood.good, inputs, rules); + + // A skip and an escape are the same act — a break offered and refused. A + // snooze is not: it is a deferral the app explicitly offers, so it only + // counts once it becomes a habit. + final misses = recent + .where((r) => r == BreakResponse.skipped || r == BreakResponse.escaped) + .length; + final snoozes = recent.where((r) => r == BreakResponse.snoozed).length; + final honored = recent.where((r) => r == BreakResponse.honored).length; + + if (misses >= rules.ignoringMisses) return Mood.ignoring; + if (misses >= rules.slippingMisses || snoozes >= rules.slippingSnoozes) { + return Mood.slipping; + } + + final honorRate = honored / recent.length; + final base = honorRate >= rules.greatHonorRate && misses == 0 + ? Mood.great + : Mood.good; + return _fatigueOr(base, inputs, rules); +} + +/// Tiredness outranks "fine" and "great" but not the behavioural warnings: +/// someone taking every break on a nine-hour day is still doing well, but +/// the icon should say the day has been long. +Mood _fatigueOr(Mood base, MoodInputs inputs, MoodRules rules) { + final overdue = inputs.sinceLastRest >= rules.tiredAfterRest; + final longDay = inputs.screenTime >= rules.tiredAfterScreenTime; + return overdue || longDay ? Mood.tired : base; +} diff --git a/lib/core/mood/mood_tracker.dart b/lib/core/mood/mood_tracker.dart new file mode 100644 index 0000000..bd4e6f8 --- /dev/null +++ b/lib/core/mood/mood_tracker.dart @@ -0,0 +1,67 @@ +import 'mood.dart'; + +/// Smooths the raw mood so the tray icon does not flicker. +/// +/// Without this the icon changes on almost every tick as one break rolls out +/// of the window and another rolls in — which reads as noise, and noise in +/// the corner of the eye is exactly what this app exists not to be. +/// +/// The asymmetry is the point: **praise quickly, scold slowly.** Improving +/// is adopted the moment it happens, so doing the right thing is +/// acknowledged immediately. Getting worse has to persist across +/// [escalateAfter] samples, so a single skipped break — which everyone does, +/// for good reasons — never turns the icon red on its own. +class MoodTracker { + MoodTracker({this.escalateAfter = 3, Mood initial = Mood.good}) + : _steady = initial; + + /// Consecutive worse-than-current samples required before escalating. + final int escalateAfter; + + /// The last settled behavioural mood. Transient moods never touch this, so + /// a break or a pause cannot erase what the app had concluded about the + /// day — it is still there when the break ends. + Mood _steady; + + Mood? _pending; + int _pendingCount = 0; + + Mood get current => _steady; + + /// Feeds one sample and returns the mood to display. + Mood update(Mood raw) { + // "In a break" and "paused" describe the moment, not the pattern. They + // show through immediately and are not subject to hysteresis, because + // the icon lagging three ticks behind the break the user is looking at + // would just be wrong. + if (raw.isTransient) return raw; + + if (raw == _steady) { + _pending = null; + _pendingCount = 0; + return _steady; + } + + if (raw.severity < _steady.severity) { + _steady = raw; // improving: adopt at once + _pending = null; + _pendingCount = 0; + return _steady; + } + + // Worsening: require persistence. A different worse mood restarts the + // count rather than inheriting the previous candidate's progress. + if (raw != _pending) { + _pending = raw; + _pendingCount = 1; + } else { + _pendingCount++; + } + if (_pendingCount >= escalateAfter) { + _steady = raw; + _pending = null; + _pendingCount = 0; + } + return _steady; + } +} diff --git a/lib/data/break_log_repository.dart b/lib/data/break_log_repository.dart index eb2f3a6..2ccd77b 100644 --- a/lib/data/break_log_repository.dart +++ b/lib/data/break_log_repository.dart @@ -57,7 +57,9 @@ class BreakLogRepository { action: BreakAction.credited, valueMs: awayFor.inMilliseconds, ), - EnginePausedByWorkHours() || EngineResumed() => null, + EnginePausedByWorkHours() || + EnginePausedByMedia() || + EngineResumed() => null, }; if (row == null) return Future.value(); return _db diff --git a/lib/data/settings_repository.dart b/lib/data/settings_repository.dart index 5856982..26b55ba 100644 --- a/lib/data/settings_repository.dart +++ b/lib/data/settings_repository.dart @@ -21,6 +21,15 @@ class SettingsRepository { static const flagAutostartApplied = 'autostart_default_applied'; static const flagHideNoticeShown = 'hide_notice_shown'; static const flagSounds = 'sounds_enabled'; + static const flagMoodIndicator = 'mood_indicator_enabled'; + + /// Ceiling on exercise intensity, stored as an [ExerciseIntensity] name. + static const keyMaxIntensity = 'max_exercise_intensity'; + + /// Rolling window of recent break responses, comma-separated, behind the + /// tray mood. Not a flag — see [readValue]/[writeValue]. + static const keyMoodRecent = 'mood_recent'; + static const flagPauseDuringMedia = 'pause_during_media'; Future loadConfig() async { final raw = await _read(_configKey); diff --git a/lib/features/dashboard/dashboard_screen.dart b/lib/features/dashboard/dashboard_screen.dart index 2d48491..0c85bf9 100644 --- a/lib/features/dashboard/dashboard_screen.dart +++ b/lib/features/dashboard/dashboard_screen.dart @@ -75,9 +75,16 @@ class _NextBreakCard extends ConsumerWidget { null, ), Deferred() => ('Break waiting', 'until your call ends', null), - Paused(:final byUser) => ( + Paused(:final reason, :final byApp) => ( 'Paused', - byUser ? 'by you' : 'outside work hours', + switch (reason) { + PauseReason.user => 'by you', + PauseReason.workHours => 'outside work hours', + PauseReason.media => + byApp == null + ? 'while something is playing' + : 'while $byApp is playing', + }, null, ), null => ('Starting up', '…', null), diff --git a/lib/features/exercises/body_art.dart b/lib/features/exercises/body_art.dart index 160a95d..380d492 100644 --- a/lib/features/exercises/body_art.dart +++ b/lib/features/exercises/body_art.dart @@ -12,6 +12,16 @@ enum BodyMode { sideStretch, walk, breathe, + neckTilt, + chestOpen, + torsoTwist, + forwardFold, + calfRaise, + armCircles, + squat, + lunge, + marchInPlace, + jumpingJacks, } /// A friendly round-headed figure animating the given exercise. @@ -75,6 +85,52 @@ class BodyArtPainter extends CustomPainter { case BodyMode.walk: final step = wave; _figure(canvas, size, legSwing: step, armAngle: -step * 0.5); + + case BodyMode.neckTilt: + // Ear toward the shoulder: the head travels sideways and tips with + // it, unlike headRoll which only translates. + final tilt = wave * 0.45; + _figure( + canvas, + size, + headOffset: Offset(sin(tilt) * u * 0.16, u * 0.02), + headTilt: tilt, + ); + + case BodyMode.chestOpen: + final open = (wave + 1) / 2; + _figure(canvas, size, armsBack: open); + + case BodyMode.torsoTwist: + _figure(canvas, size, twist: wave * 0.8); + + case BodyMode.forwardFold: + final fold = (wave + 1) / 2; + _figure(canvas, size, fold: fold, armAngle: 0.2); + + case BodyMode.calfRaise: + _figure(canvas, size, heelLift: (wave + 1) / 2); + + case BodyMode.armCircles: + _figure(canvas, size, armSweep: t * 2 * pi); + + case BodyMode.squat: + _figure(canvas, size, legBend: (wave + 1) / 2, armAngle: -0.4); + + case BodyMode.lunge: + _figure( + canvas, + size, + legBend: (wave + 1) / 2, + stagger: wave.sign * 0.8, + ); + + case BodyMode.marchInPlace: + _figure(canvas, size, kneeLift: wave, armAngle: -wave * 0.7); + + case BodyMode.jumpingJacks: + final open = (wave + 1) / 2; + _figure(canvas, size, stance: open, armsUp: open); } } @@ -90,10 +146,26 @@ class BodyArtPainter extends CustomPainter { double armAngle = 0.6, double forearmFlex = 0, bool armRaised = false, + double headTilt = 0, + double armsBack = 0, + double twist = 0, + double fold = 0, + double heelLift = 0, + double? armSweep, + double legBend = 0, + double stagger = 0, + double kneeLift = 0, + double stance = 0, + double armsUp = 0, }) { final u = size.shortestSide; final cx = size.width / 2; - final hipY = size.height * 0.78; + final legLen = u * 0.2; + // Bending the knees drops the hips and shortens the visible leg; lifting + // the heels raises the whole figure. Both move the same anchor, so they + // are resolved here rather than in each mode. + final hipY = + size.height * 0.78 + legBend * legLen * 0.55 - heelLift * u * 0.05; final hip = Offset(cx, hipY); canvas.save(); @@ -101,9 +173,17 @@ class BodyArtPainter extends CustomPainter { canvas.rotate(lean); canvas.translate(-hip.dx, -hip.dy); - // Spine: curved when slouching, straight when tall. - final shoulderY = hipY - u * 0.34 + slouch * u * 0.05 - shoulderLift; - final shoulder = Offset(cx + slouch * u * 0.06, shoulderY); + // Spine: curved when slouching, straight when tall. Folding at the hip + // pitches the whole upper body forward, so the shoulder swings out and + // down along an arc rather than simply sinking. + final foldAngle = fold * 1.15; // up to ~66 degrees + final spineLen = u * 0.34; + final shoulderY = + hipY - cos(foldAngle) * spineLen + slouch * u * 0.05 - shoulderLift; + final shoulder = Offset( + cx + slouch * u * 0.06 + sin(foldAngle) * spineLen, + shoulderY, + ); final spine = Path() ..moveTo(hip.dx, hip.dy) ..quadraticBezierTo( @@ -115,14 +195,61 @@ class BodyArtPainter extends CustomPainter { canvas.drawPath(spine, _stroke); // Head. + // The head follows the fold and can tip independently (neck tilt). + final headDir = foldAngle + headTilt; final head = shoulder + - Offset(headOffset.dx + slouch * u * 0.08, -u * 0.13 + headOffset.dy); + Offset( + headOffset.dx + slouch * u * 0.08 + sin(headDir) * u * 0.13, + headOffset.dy - cos(headDir) * u * 0.13, + ); canvas.drawCircle(head, u * 0.09, _stroke); // Arms. final armLen = u * 0.22; - if (armRaised) { + if (armSweep != null) { + // Both arms out to the sides, tracing circles from the shoulder. + for (final side in [-1.0, 1.0]) { + final hand = + shoulder + + Offset( + side * armLen * (0.9 + 0.25 * cos(armSweep)), + armLen * 0.25 * sin(armSweep), + ); + canvas.drawLine(shoulder, hand, _stroke); + } + } else if (armsUp > 0) { + // Jumping jack: arms sweep from low at the sides up into a wide star. + // + // Two constraints, both learned by looking at the render. The angle + // stays inside ±pi/2 so cos never goes negative — past vertical each + // arm crossed the centre line and drew straight through the head. And + // it peaks at ~43 degrees rather than overhead, which keeps the arms + // clear of the head circle while still reading as the top of a jack. + final angle = -0.9 + armsUp * 1.65; + for (final side in [-1.0, 1.0]) { + // Arms hang from the ends of the shoulders, not from the neck. + final root = shoulder + Offset(side * u * 0.06, 0); + canvas.drawLine( + root, + root + Offset(side * cos(angle) * armLen, -sin(angle) * armLen), + _stroke, + ); + } + } else if (armsBack > 0) { + // Chest opener: hands clasped low behind the back. + for (final side in [-1.0, 1.0]) { + canvas.drawLine( + shoulder, + shoulder + + Offset( + side * armLen * (0.5 - armsBack * 0.35), + armLen * (0.75 + armsBack * 0.2), + ), + _stroke, + ); + } + } else if (armRaised) { // One arm overhead following the lean, one resting. canvas.drawLine( shoulder, @@ -136,7 +263,11 @@ class BodyArtPainter extends CustomPainter { ); } else { final elbow = - shoulder + Offset(armLen * 0.8, armLen * (0.5 + armAngle * 0.3)); + shoulder + + Offset( + armLen * 0.8 + twist * armLen * 0.5, + armLen * (0.5 + armAngle * 0.3), + ); canvas.drawLine(shoulder, elbow, _stroke); // Forearm/hand, flexing at the elbow for the wrist exercise. final hand = @@ -144,23 +275,38 @@ class BodyArtPainter extends CustomPainter { canvas.drawLine(elbow, hand, _stroke); canvas.drawLine( shoulder, - shoulder + Offset(-armLen * 0.8, armLen * (0.5 - armAngle * 0.3)), + shoulder + + Offset( + -armLen * 0.8 + twist * armLen * 0.5, + armLen * (0.5 - armAngle * 0.3), + ), _stroke, ); } - // Legs. - final legLen = u * 0.2; - canvas.drawLine( - hip, - hip + Offset(legSwing * legLen * 0.6 - legLen * 0.25, legLen), - _stroke, - ); - canvas.drawLine( - hip, - hip + Offset(-legSwing * legLen * 0.6 + legLen * 0.25, legLen), - _stroke, - ); + // Legs. Knees are drawn explicitly whenever the figure bends, so a squat + // reads as a squat rather than as a figure that simply shrank. + final spread = legLen * (0.25 + stance * 0.7); + for (final side in [1.0, -1.0]) { + final swing = side * legSwing * legLen * 0.6; + final lift = side > 0 ? max(0.0, kneeLift) : max(0.0, -kneeLift); + final footY = hip.dy + legLen * (1 - lift * 0.45); + final foot = Offset( + hip.dx + swing + side * spread + side * stagger * legLen * 0.5, + footY, + ); + if (legBend > 0 || lift > 0) { + final knee = Offset( + (hip.dx + foot.dx) / 2 + side * legLen * (0.3 * legBend + 0.2 * lift), + hip.dy + legLen * 0.5 * (1 - lift * 0.5), + ); + canvas + ..drawLine(hip, knee, _stroke) + ..drawLine(knee, foot, _stroke); + } else { + canvas.drawLine(hip, foot, _stroke); + } + } canvas.restore(); } diff --git a/lib/features/exercises/exercise_figure.dart b/lib/features/exercises/exercise_figure.dart index 66a60ab..4dc6578 100644 --- a/lib/features/exercises/exercise_figure.dart +++ b/lib/features/exercises/exercise_figure.dart @@ -36,68 +36,37 @@ class _ExerciseFigureState extends State animation: _controller, builder: (context, _) { final t = _controller.value; - final painter = switch (widget.art) { - ExerciseArt.eyesFarNear => EyeArtPainter( - t: t, - mode: EyeMode.farNear, - scheme: scheme, - ), - ExerciseArt.eyesPalming => EyeArtPainter( - t: t, - mode: EyeMode.palming, - scheme: scheme, - ), - ExerciseArt.eyesFigureEight => EyeArtPainter( - t: t, - mode: EyeMode.figureEight, - scheme: scheme, - ), - ExerciseArt.eyesBlink => EyeArtPainter( - t: t, - mode: EyeMode.blink, - scheme: scheme, - ), - ExerciseArt.headRoll => BodyArtPainter( - t: t, - mode: BodyMode.headRoll, - scheme: scheme, - ), - ExerciseArt.chinTuck => BodyArtPainter( - t: t, - mode: BodyMode.chinTuck, - scheme: scheme, - ), - ExerciseArt.shoulders => BodyArtPainter( - t: t, - mode: BodyMode.shoulders, - scheme: scheme, - ), - ExerciseArt.wrists => BodyArtPainter( - t: t, - mode: BodyMode.wrists, - scheme: scheme, - ), - ExerciseArt.posture => BodyArtPainter( - t: t, - mode: BodyMode.posture, - scheme: scheme, - ), - ExerciseArt.sideStretch => BodyArtPainter( - t: t, - mode: BodyMode.sideStretch, - scheme: scheme, - ), - ExerciseArt.walk => BodyArtPainter( - t: t, - mode: BodyMode.walk, - scheme: scheme, - ), - ExerciseArt.breathe => BodyArtPainter( - t: t, - mode: BodyMode.breathe, - scheme: scheme, - ), + // One line per art, and still exhaustively checked: the switch + // yields the *mode*, and the painter is chosen from its type. The + // previous form repeated the painter construction in every arm, + // which was twelve near-identical blocks before the deck grew. + final mode = switch (widget.art) { + ExerciseArt.eyesFarNear => EyeMode.farNear, + ExerciseArt.eyesPalming => EyeMode.palming, + ExerciseArt.eyesFigureEight => EyeMode.figureEight, + ExerciseArt.eyesBlink => EyeMode.blink, + ExerciseArt.headRoll => BodyMode.headRoll, + ExerciseArt.chinTuck => BodyMode.chinTuck, + ExerciseArt.shoulders => BodyMode.shoulders, + ExerciseArt.wrists => BodyMode.wrists, + ExerciseArt.posture => BodyMode.posture, + ExerciseArt.sideStretch => BodyMode.sideStretch, + ExerciseArt.walk => BodyMode.walk, + ExerciseArt.breathe => BodyMode.breathe, + ExerciseArt.neckTilt => BodyMode.neckTilt, + ExerciseArt.chestOpen => BodyMode.chestOpen, + ExerciseArt.torsoTwist => BodyMode.torsoTwist, + ExerciseArt.forwardFold => BodyMode.forwardFold, + ExerciseArt.calfRaise => BodyMode.calfRaise, + ExerciseArt.armCircles => BodyMode.armCircles, + ExerciseArt.squat => BodyMode.squat, + ExerciseArt.lunge => BodyMode.lunge, + ExerciseArt.marchInPlace => BodyMode.marchInPlace, + ExerciseArt.jumpingJacks => BodyMode.jumpingJacks, }; + final painter = mode is EyeMode + ? EyeArtPainter(t: t, mode: mode, scheme: scheme) + : BodyArtPainter(t: t, mode: mode as BodyMode, scheme: scheme); return CustomPaint(size: Size.square(widget.size), painter: painter); }, ), diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index c4e1c93..3fe85f3 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/models/break_config.dart'; +import '../../core/models/exercise.dart'; import '../../platform/interfaces/tray_support.dart'; import '../../services/providers.dart'; @@ -134,6 +135,23 @@ class SettingsScreen extends ConsumerWidget { .read(generalSettingsProvider.notifier) .setFullscreenOverlay(v), ), + SwitchListTile( + secondary: const Icon(Icons.movie_outlined), + title: const Text('Pause for video'), + subtitle: const Text( + 'Hold breaks while a full-screen video or presentation is ' + 'playing, and resume when it ends. Background music keeps ' + 'breaks running.', + ), + value: + ref.watch(generalSettingsProvider).value?.pauseDuringMedia ?? + true, + onChanged: (v) => ref + .read(generalSettingsProvider.notifier) + .setPauseDuringMedia(v), + ), + const _SectionHeader('Exercises'), + const _IntensityTile(), const _SectionHeader('Work hours'), _WorkHoursTile(config: config, onChanged: notifier.update), _WorkDaysTile(config: config, onChanged: notifier.update), @@ -149,6 +167,20 @@ class SettingsScreen extends ConsumerWidget { onChanged: (v) => ref.read(generalSettingsProvider.notifier).setSounds(v), ), + SwitchListTile( + secondary: const Icon(Icons.sentiment_satisfied_outlined), + title: const Text('Expressive tray icon'), + subtitle: const Text( + 'Let the tray icon\'s face and colour reflect how your day ' + 'is going. Hover it to read what it means.', + ), + value: + ref.watch(generalSettingsProvider).value?.moodIndicator ?? + true, + onChanged: (v) => ref + .read(generalSettingsProvider.notifier) + .setMoodIndicator(v), + ), const _TrayTile(), SwitchListTile( secondary: const Icon(Icons.rocket_launch_outlined), @@ -248,6 +280,45 @@ class _TrayTile extends ConsumerWidget { } } +/// How demanding an exercise the user is willing to be shown. +/// +/// A ceiling rather than a preference: the point is that heavier exercises +/// are impossible in some rooms, and an overlay suggesting the impossible +/// teaches people to dismiss overlays. +class _IntensityTile extends ConsumerWidget { + const _IntensityTile(); + + static const _labels = { + ExerciseIntensity.soft: ('Seated only', 'Nothing anyone would notice'), + ExerciseIntensity.medium: ( + 'Up to stretches', + 'Standing stretches and a walk', + ), + ExerciseIntensity.heavy: ('Anything', 'Includes squats, lunges and jacks'), + }; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final value = + ref.watch(generalSettingsProvider).value?.maxIntensity ?? + ExerciseIntensity.medium; + return ListTile( + leading: const Icon(Icons.fitness_center_outlined), + title: const Text('Most demanding exercise'), + subtitle: Text(_labels[value]!.$2), + trailing: SegmentedButton( + segments: [ + for (final entry in _labels.entries) + ButtonSegment(value: entry.key, label: Text(entry.value.$1)), + ], + selected: {value}, + onSelectionChanged: (s) => + ref.read(generalSettingsProvider.notifier).setMaxIntensity(s.first), + ), + ); + } +} + class _SectionHeader extends StatelessWidget { const _SectionHeader(this.title); diff --git a/lib/main.dart b/lib/main.dart index ef36561..6bbaf08 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,10 +5,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'app/app.dart'; import 'app/bootstrap.dart'; -import 'app/tray_icons.dart'; +import 'app/tray_face.dart'; import 'platform/interfaces/tray_indicator.dart'; import 'services/app_lifecycle.dart'; import 'services/providers.dart'; +import 'services/tray_mood_presenter.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -18,6 +19,7 @@ Future main() async { overlay: boot.overlay, service: boot.service, tray: boot.tray, + mood: boot.mood, ); final container = ProviderContainer( @@ -52,11 +54,21 @@ Future _wireTray( AppLifecycle lifecycle, ) async { try { - await boot.tray.init(icons: await loadTrayPixmaps()); + await boot.tray.init(icons: await renderTrayFace(boot.mood.current)); } on Exception { return; // no status area / no bus — the app works without it } + // The face only starts moving once there is a tray to draw it on. + final presenter = TrayMoodPresenter( + tray: boot.tray, + moods: boot.mood.moods, + enabled: boot.moodIndicator, + initial: boot.mood.current, + ); + container.read(trayMoodPresenterProvider).presenter = presenter; + await presenter.start(); + boot.tray.actions.listen((action) async { switch (action) { case TrayAction.open: diff --git a/lib/platform/fake/fake_signals.dart b/lib/platform/fake/fake_signals.dart index 62b7fc4..f48acd6 100644 --- a/lib/platform/fake/fake_signals.dart +++ b/lib/platform/fake/fake_signals.dart @@ -3,6 +3,7 @@ import 'dart:async'; import '../interfaces/autostart.dart'; import '../interfaces/context_signals.dart'; import '../interfaces/idle_monitor.dart'; +import '../interfaces/presentation_signals.dart'; import '../interfaces/session_signals.dart'; import '../interfaces/tray_indicator.dart'; @@ -36,6 +37,16 @@ class FakeContextSignals implements ContextSignals { Future isBusy() async => busy; } +class FakePresentationSignals implements PresentationSignals { + PresentationState state = PresentationState.idle; + + void setPresenting(bool value, {String? byApp}) => + state = PresentationState(active: value, byApp: byApp); + + @override + Future sample() async => state; +} + class FakeAutostart implements Autostart { bool enabled = false; @@ -56,9 +67,23 @@ class FakeTrayIndicator implements TrayIndicator { @override Future init({required List icons}) async {} + List icons = const []; + String tooltip = ''; + int iconUpdates = 0; + @override Future setPaused(bool value) async => paused = value; + @override + Future setIcon( + List value, { + required String tooltip, + }) async { + icons = value; + this.tooltip = tooltip; + iconUpdates++; + } + @override Stream get actions => _controller.stream; diff --git a/lib/platform/interfaces/presentation_signals.dart b/lib/platform/interfaces/presentation_signals.dart new file mode 100644 index 0000000..c13443b --- /dev/null +++ b/lib/platform/interfaces/presentation_signals.dart @@ -0,0 +1,37 @@ +/// Whether something on screen must not be interrupted right now. +class PresentationState { + const PresentationState({required this.active, this.byApp}); + + /// Nothing is claiming the screen. + static const idle = PresentationState(active: false); + + /// True while a fullscreen video, presentation, or game is running. + final bool active; + + /// The app responsible, when the desktop names it (e.g. `mpv`, `Firefox`). + /// Null is the normal degraded case, not an error. + final String? byApp; + + @override + bool operator ==(Object other) => + other is PresentationState && + other.active == active && + other.byApp == byApp; + + @override + int get hashCode => Object.hash(active, byApp); +} + +/// Detects "do not interrupt me, I'm watching something". +/// +/// Deliberately built on **idle inhibitors** rather than audio playback. +/// Video players, browsers in fullscreen video, presentation tools and games +/// all take an idle inhibitor so the screen never blanks on them; music +/// players do not. Keying off raw audio instead would let a background +/// playlist suppress breaks for an entire working day. +abstract interface class PresentationSignals { + /// Implementations must return [PresentationState.idle] on backend errors + /// — never throw. Failing open means breaks keep working, which is the + /// safe direction for a health app. + Future sample(); +} diff --git a/lib/platform/interfaces/tray_indicator.dart b/lib/platform/interfaces/tray_indicator.dart index 38ef501..0348933 100644 --- a/lib/platform/interfaces/tray_indicator.dart +++ b/lib/platform/interfaces/tray_indicator.dart @@ -24,6 +24,12 @@ abstract interface class TrayIndicator { /// Reflects the engine's paused state in the menu. Future setPaused(bool paused); + /// Swaps the icon while running, with the hover text that explains it. + /// + /// The text is not decoration: an icon that turns red without saying why + /// is just a source of anxiety in the corner of the screen. + Future setIcon(List icons, {required String tooltip}); + /// Menu selections and icon activations. Stream get actions; diff --git a/lib/platform/linux/linux_presentation_signals.dart b/lib/platform/linux/linux_presentation_signals.dart new file mode 100644 index 0000000..c546496 --- /dev/null +++ b/lib/platform/linux/linux_presentation_signals.dart @@ -0,0 +1,157 @@ +import 'package:dbus/dbus.dart'; + +import '../../app/brand.dart'; +import '../interfaces/presentation_signals.dart'; + +/// "Don't interrupt me" detection from idle inhibitors, the one signal that +/// works on Wayland without window snooping. +/// +/// Two independent sources, unioned because they hold different clients: +/// - **gnome-session** — where `org.freedesktop.ScreenSaver.Inhibit` calls +/// land on GNOME. This is what Firefox, Chrome, VLC and mpv take when a +/// video goes fullscreen, and what presentation tools take throughout. +/// - **logind** — desktop-independent inhibitors, so the feature degrades to +/// something useful on KDE, wlroots compositors and bare sessions. +/// +/// Each source is disabled permanently the first time it proves absent, so a +/// non-GNOME box does not pay for a failing call every few seconds. +class LinuxPresentationSignals implements PresentationSignals { + LinuxPresentationSignals({required this._session, required this._system}); + + /// gnome-session's INHIBIT_IDLE bit. + static const _inhibitIdle = 8; + + final DBusClient _session; + final DBusClient _system; + bool _gnomeAvailable = true; + bool _logindAvailable = true; + + @override + Future sample() async { + final gnome = await _gnomeSession(); + if (gnome.active) return gnome; + return _logind(); + } + + Future _gnomeSession() async { + if (!_gnomeAvailable) return PresentationState.idle; + final manager = DBusRemoteObject( + _session, + name: 'org.gnome.SessionManager', + path: DBusObjectPath('/org/gnome/SessionManager'), + ); + try { + final reply = await manager.callMethod( + 'org.gnome.SessionManager', + 'IsInhibited', + [const DBusUint32(_inhibitIdle)], + replySignature: DBusSignature('b'), + ); + if (!(reply.returnValues.first as DBusBoolean).value) { + return PresentationState.idle; + } + return PresentationState(active: true, byApp: await _gnomeInhibitorApp()); + } on DBusServiceUnknownException { + _gnomeAvailable = false; // not a GNOME session + return PresentationState.idle; + } on Exception { + return PresentationState.idle; // transient: fail open, keep breaks alive + } + } + + /// Best-effort name of the app holding the idle inhibitor. Purely for the + /// UI copy, so every failure here is silent. + Future _gnomeInhibitorApp() async { + try { + final manager = DBusRemoteObject( + _session, + name: 'org.gnome.SessionManager', + path: DBusObjectPath('/org/gnome/SessionManager'), + ); + final reply = await manager.callMethod( + 'org.gnome.SessionManager', + 'GetInhibitors', + const [], + replySignature: DBusSignature('ao'), + ); + final paths = (reply.returnValues.first as DBusArray).children + .cast(); + for (final path in paths) { + final inhibitor = DBusRemoteObject( + _session, + name: 'org.gnome.SessionManager', + path: path, + ); + final flags = await inhibitor.callMethod( + 'org.gnome.SessionManager.Inhibitor', + 'GetFlags', + const [], + replySignature: DBusSignature('u'), + ); + if ((flags.returnValues.first as DBusUint32).value & _inhibitIdle == + 0) { + continue; + } + final appId = await inhibitor.callMethod( + 'org.gnome.SessionManager.Inhibitor', + 'GetAppId', + const [], + replySignature: DBusSignature('s'), + ); + final name = (appId.returnValues.first as DBusString).value.trim(); + if (name.isNotEmpty && !_isOurs(name)) return name; + } + } on Exception { + // Name is a nicety; the pause works without it. + } + return null; + } + + Future _logind() async { + if (!_logindAvailable) return PresentationState.idle; + final manager = DBusRemoteObject( + _system, + name: 'org.freedesktop.login1', + path: DBusObjectPath('/org/freedesktop/login1'), + ); + try { + final reply = await manager.callMethod( + 'org.freedesktop.login1.Manager', + 'ListInhibitors', + const [], + replySignature: DBusSignature('a(ssssuu)'), + ); + final rows = (reply.returnValues.first as DBusArray).children + .cast(); + for (final row in rows) { + final fields = row.children.toList(); + final what = (fields[0] as DBusString).value; + final who = (fields[1] as DBusString).value.trim(); + final mode = (fields[3] as DBusString).value; + // `what` is colon-separated ("sleep:idle"); only a *blocking* idle + // inhibitor means "keep the screen alive for me". A `delay` one is + // about shutdown ordering and says nothing about what's on screen. + final blocksIdle = mode == 'block' && what.split(':').contains('idle'); + if (blocksIdle && !_isOurs(who)) { + return PresentationState( + active: true, + byApp: who.isEmpty ? null : who, + ); + } + } + return PresentationState.idle; + } on DBusServiceUnknownException { + _logindAvailable = false; // no systemd-logind on this box + return PresentationState.idle; + } on Exception { + return PresentationState.idle; + } + } + + /// Never let our own process (or a stale inhibitor of ours) pause us. + bool _isOurs(String name) { + final lower = name.toLowerCase(); + return lower == Brand.appId.toLowerCase() || + lower == Brand.appName.toLowerCase(); + } +} diff --git a/lib/platform/linux/sni_tray.dart b/lib/platform/linux/sni_tray.dart index 725f0e9..ab2749b 100644 --- a/lib/platform/linux/sni_tray.dart +++ b/lib/platform/linux/sni_tray.dart @@ -89,6 +89,12 @@ class SniTrayIndicator implements TrayIndicator { @override Future setPaused(bool paused) async => _menu?.setPaused(paused); + @override + Future setIcon( + List icons, { + required String tooltip, + }) async => _item?.update(icons: icons, tooltip: tooltip); + @override Future dispose() async { await _watcherSub?.cancel(); @@ -102,17 +108,30 @@ class SniTrayIndicator implements TrayIndicator { /// org.kde.StatusNotifierItem at /StatusNotifierItem. class _SniItemObject extends DBusObject { - _SniItemObject({required this.icons, required this.onActivate}) + _SniItemObject({required this._icons, required this.onActivate}) : super(DBusObjectPath('/StatusNotifierItem')); static const _iface = 'org.kde.StatusNotifierItem'; - final List icons; + List _icons; + String _tooltip = 'Running — breaks on schedule'; final void Function() onActivate; + /// Swaps the icon and hover text, then tells the host to re-read them. + /// + /// The property change alone is invisible: SNI hosts cache both and only + /// refetch when signalled, so without these emissions the icon would never + /// visibly change. + void update({required List icons, required String tooltip}) { + _icons = icons; + _tooltip = tooltip; + emitSignal(_iface, 'NewIcon'); + emitSignal(_iface, 'NewToolTip'); + } + DBusValue get _pixmaps => DBusArray( DBusSignature('(iiay)'), - icons.map( + _icons.map( (p) => DBusStruct([ DBusInt32(p.width), DBusInt32(p.height), @@ -134,12 +153,13 @@ class _SniItemObject extends DBusObject { 'AttentionIconName': const DBusString(''), 'AttentionIconPixmap': DBusArray(DBusSignature('(iiay)'), const []), 'AttentionMovieName': const DBusString(''), - // Hovering the icon must say plainly which app it is. + // Hovering the icon must say plainly which app it is, and — since the + // icon now carries an expression — what that expression means. 'ToolTip': DBusStruct([ const DBusString(''), DBusArray(DBusSignature('(iiay)'), const []), const DBusString(Brand.appName), - const DBusString('Running — breaks on schedule'), + DBusString(_tooltip), ]), 'ItemIsMenu': const DBusBoolean(false), 'Menu': DBusObjectPath('/MenuBar'), diff --git a/lib/platform/linux/window_takeover.dart b/lib/platform/linux/window_takeover.dart index 024ea97..9c36b9c 100644 --- a/lib/platform/linux/window_takeover.dart +++ b/lib/platform/linux/window_takeover.dart @@ -33,6 +33,15 @@ class WindowTakeover with WindowListener implements OverlayController { /// chaining one closure per tick costs nothing over time. Future _queue = Future.value(); + /// Whether the window was on screen before a break took it over. + /// + /// A break has to show the window; ending one used to just drop fullscreen + /// and always-on-top, leaving the app sitting in the user's face until they + /// dismissed it manually. Every break therefore ended with a chore. The + /// window now goes back to whatever it was doing beforehand — which for the + /// normal case (app closed to tray) means it disappears on its own. + bool _wasVisibleBeforeBreak = true; + @override Future init() async { await windowManager.ensureInitialized(); @@ -69,7 +78,13 @@ class WindowTakeover with WindowListener implements OverlayController { } Future _transition(BreakWindowState desired) async { + final entering = desired.inBreak && !_current.inBreak; + final leaving = !desired.inBreak && _current.inBreak; + if (desired.inBreak) { + // Sampled before `show()`, and only on the way in, so a mid-break + // re-assertion (windowed → fullscreen) cannot overwrite it with `true`. + if (entering) _wasVisibleBeforeBreak = await _isVisible(); await windowManager.show(); // Set these unconditionally in both directions — a previous break may // have left them on, and a windowed break must clear them. @@ -79,10 +94,25 @@ class WindowTakeover with WindowListener implements OverlayController { } else { await windowManager.setAlwaysOnTop(false); await windowManager.setFullScreen(false); + // Put the window back the way we found it. Guarded on `leaving` so + // `forceRestore()` — which exists for when our idea of the state is + // wrong — can never hide a window the user opened themselves. + if (leaving && !_wasVisibleBeforeBreak) await windowManager.hide(); } _current = desired; } + /// Treats an unreadable visibility as "was on screen": the failure mode of + /// guessing wrong is a window left open, which the user can close. Guessing + /// the other way would hide a window they were using. + Future _isVisible() async { + try { + return await windowManager.isVisible(); + } on Object { + return true; + } + } + /// Closing the window hides it — the engine keeps running. Quitting is /// explicit (tray menu, or Ctrl+Q). @override diff --git a/lib/services/app_lifecycle.dart b/lib/services/app_lifecycle.dart index 6a33e3e..bf82338 100644 --- a/lib/services/app_lifecycle.dart +++ b/lib/services/app_lifecycle.dart @@ -2,6 +2,7 @@ import 'dart:io'; import '../platform/interfaces/overlay_controller.dart'; import '../platform/interfaces/tray_indicator.dart'; +import 'mood_service.dart'; import 'engine_service.dart'; /// The single way out of the app, shared by the tray menu and Ctrl+Q. @@ -14,6 +15,7 @@ class AppLifecycle { required this.overlay, required this.service, required this.tray, + this.mood, this.exitProcess = exit, }); @@ -21,6 +23,9 @@ class AppLifecycle { final EngineService service; final TrayIndicator tray; + /// Null in tests and on the paths that never start it. + final MoodService? mood; + /// Injected so tests can quit without killing the test runner. final void Function(int code) exitProcess; @@ -33,6 +38,7 @@ class AppLifecycle { _quitting = true; try { await tray.dispose(); + await mood?.dispose(); await service.dispose(); await overlay.destroyWindow(); } finally { diff --git a/lib/services/context_sampler.dart b/lib/services/context_sampler.dart index aa9f12d..af70280 100644 --- a/lib/services/context_sampler.dart +++ b/lib/services/context_sampler.dart @@ -1,34 +1,49 @@ import '../platform/interfaces/context_signals.dart'; +import '../platform/interfaces/presentation_signals.dart'; +import 'polled_value.dart'; /// Rate-limits busy checks: `pw-dump` is too heavy to run every second, /// and busy state only matters when a break is close. class ContextSampler { - ContextSampler(this._signals, {this.ttl = const Duration(seconds: 5)}); + ContextSampler(ContextSignals signals, {Duration ttl = _defaultTtl}) + : _cache = PolledValue(probe: signals.isBusy, initial: false, ttl: ttl); - final ContextSignals _signals; - final Duration ttl; + static const _defaultTtl = Duration(seconds: 5); - bool _value = false; - DateTime? _sampledAt; - Future? _inFlight; + final PolledValue _cache; - bool get value => _value; + Duration get ttl => _cache.ttl; + bool get value => _cache.value; /// Refreshes when [relevant] and the cached value is older than [ttl]. /// Never runs two probes concurrently. Future refreshIfNeeded({ required bool relevant, required DateTime now, - }) { - if (!relevant) return Future.value(); - final age = _sampledAt == null ? null : now.difference(_sampledAt!); - if (age != null && age < ttl) return Future.value(); - return _inFlight ??= _signals - .isBusy() - .then((busy) { - _value = busy; - _sampledAt = now; - }) - .whenComplete(() => _inFlight = null); - } + }) => _cache.refreshIfNeeded(relevant: relevant, now: now); +} + +/// Rate-limits "is something playing fullscreen" checks. +/// +/// Unlike [ContextSampler] this is polled **unconditionally**, not only when +/// a break is close: the pause has to engage while the film is playing, which +/// is exactly when no break is near. The probe is two D-Bus calls — far +/// cheaper than the process spawn behind the busy check — so the constant +/// cadence is affordable. +class PresentationSampler { + PresentationSampler( + PresentationSignals signals, { + Duration ttl = const Duration(seconds: 5), + }) : _cache = PolledValue( + probe: signals.sample, + initial: PresentationState.idle, + ttl: ttl, + ); + + final PolledValue _cache; + + PresentationState get value => _cache.value; + + Future refresh(DateTime now) => + _cache.refreshIfNeeded(relevant: true, now: now); } diff --git a/lib/services/engine_service.dart b/lib/services/engine_service.dart index 0c13e0a..f500539 100644 --- a/lib/services/engine_service.dart +++ b/lib/services/engine_service.dart @@ -7,6 +7,7 @@ import '../core/models/activity.dart'; import '../data/break_log_repository.dart'; import '../data/settings_repository.dart'; import '../platform/interfaces/idle_monitor.dart'; +import '../platform/interfaces/presentation_signals.dart'; import '../platform/interfaces/session_signals.dart'; import 'activity_recorder.dart'; import 'context_sampler.dart'; @@ -21,11 +22,16 @@ class EngineService { required this._idleMonitor, required this._sessionSignals, required this._sampler, + required this._presentation, required this._breakLog, required this._settings, required this._recorder, }); + /// Whether the media pause is honored. Toggled from Settings; read on + /// every tick so a change takes effect without restarting the engine. + bool pauseDuringMedia = true; + /// How close a break must be before busy-probing is worth its cost. static const _busyRelevanceWindow = Duration(seconds: 90); static const _flushEvery = Duration(minutes: 1); @@ -36,6 +42,7 @@ class EngineService { final IdleMonitor _idleMonitor; final SessionSignals _sessionSignals; final ContextSampler _sampler; + final PresentationSampler _presentation; final BreakLogRepository _breakLog; final SettingsRepository _settings; final ActivityRecorder _recorder; @@ -71,8 +78,20 @@ class EngineService { }; final now = _clock.now(); await _sampler.refreshIfNeeded(relevant: relevant, now: now); + if (pauseDuringMedia) await _presentation.refresh(now); + final presenting = pauseDuringMedia + ? _presentation.value + : PresentationState.idle; - engine.tick(TickInput(idle: idle, locked: _away, busy: _sampler.value)); + engine.tick( + TickInput( + idle: idle, + locked: _away, + busy: _sampler.value, + presenting: presenting.active, + presentingApp: presenting.byApp, + ), + ); _recorder.observe(now, _sliceKind(idle)); if (now.difference(_lastFlush) >= _flushEvery) { diff --git a/lib/services/exercise_picker.dart b/lib/services/exercise_picker.dart index ab55cfc..b0d6621 100644 --- a/lib/services/exercise_picker.dart +++ b/lib/services/exercise_picker.dart @@ -3,12 +3,14 @@ import 'dart:math'; import '../core/models/break_kind.dart'; import '../core/models/exercise.dart'; -/// Picks an exercise for a break: matching tier, honoring opt-outs, and -/// heavily de-prioritizing recently shown ones so the deck feels fresh. +/// Picks an exercise for a break: matching tier, within the user's intensity +/// ceiling, honoring opt-outs, and heavily de-prioritizing recently shown +/// ones so the deck feels fresh. class ExercisePicker { ExercisePicker({ this._deck = exerciseDeck, Set optOuts = const {}, + this.maxIntensity = ExerciseIntensity.medium, Random? random, }) : _optOuts = {...optOuts}, _random = random ?? Random(); @@ -18,6 +20,11 @@ class ExercisePicker { final Random _random; final List _recent = []; + /// Ceiling on how demanding a picked exercise may be. Public and mutable, + /// mirroring [optOuts]: Settings pushes changes into the live picker so a + /// user who lowers it mid-session is not shown squats on the next break. + ExerciseIntensity maxIntensity; + static const _recentWindow = 3; static const _recentWeight = 1; static const _freshWeight = 10; @@ -29,13 +36,24 @@ class ExercisePicker { } Exercise pick(BreakKind tier) { - var candidates = _deck - .where((e) => e.tier == tier && !_optOuts.contains(e.id)) + final inTier = _deck.where((e) => e.tier == tier).toList(); + var candidates = inTier + .where( + (e) => + !_optOuts.contains(e.id) && e.intensity.allowedBy(maxIntensity), + ) .toList(); + if (candidates.isEmpty) { - // Everything opted out — fall back to the full tier rather than crash. - candidates = _deck.where((e) => e.tier == tier).toList(); + // Opt-outs and the ceiling together ruled everything out. Drop the + // opt-outs first: showing an exercise someone muted is a smaller broken + // promise than showing one they cannot do where they are sitting. + candidates = inTier + .where((e) => e.intensity.allowedBy(maxIntensity)) + .toList(); } + // Ceiling below every exercise in the tier — fall back rather than crash. + if (candidates.isEmpty) candidates = inTier; final weights = [ for (final e in candidates) diff --git a/lib/services/mood_service.dart b/lib/services/mood_service.dart new file mode 100644 index 0000000..a4e8301 --- /dev/null +++ b/lib/services/mood_service.dart @@ -0,0 +1,157 @@ +import 'dart:async'; + +import '../core/clock.dart'; +import '../core/engine/engine.dart'; +import '../core/engine/events.dart'; +import '../core/engine/phase.dart'; +import '../core/mood/mood.dart'; +import '../core/mood/mood_rules.dart'; +import '../core/mood/mood_tracker.dart'; +import '../data/activity_repository.dart'; +import '../data/settings_repository.dart'; + +/// Works out how the app should feel about the user's day, and publishes it. +/// +/// Deliberately knows nothing about icons or D-Bus — it produces a [Mood] and +/// stops there, so the rules can be tested without a tray, a compositor, or a +/// rendering pipeline. +class MoodService { + MoodService({ + required this._engine, + required this._clock, + required this._settings, + required this._activity, + this._rules = const MoodRules(), + MoodTracker? tracker, + }) : _tracker = tracker ?? MoodTracker(escalateAfter: _escalateAfter); + + /// Three samples before escalating. Combined with the recompute cadence + /// this means a worsening mood has to persist for minutes, not seconds. + static const _escalateAfter = 3; + + /// Screen time is a database read, so it is refreshed on a slow cadence + /// rather than on every recompute. + static const _screenTimeEvery = Duration(minutes: 1); + + final BreakEngine _engine; + final Clock _clock; + final SettingsRepository _settings; + final ActivityRepository _activity; + final MoodRules _rules; + final MoodTracker _tracker; + + final _moods = StreamController.broadcast(); + final List _recent = []; + + StreamSubscription? _eventSub; + StreamSubscription? _phaseSub; + Timer? _timer; + + DateTime? _lastRestAt; + Duration _screenTime = Duration.zero; + bool _inBreak = false; + bool _paused = false; + Mood? _published; + + Stream get moods => _moods.stream; + Mood get current => _tracker.current; + + Future start() async { + _recent.addAll(await _loadRecent()); + + _eventSub = _engine.events.listen(_onEvent); + + // The phase stream ticks at 1 Hz; only genuine transitions matter, or the + // mood would be recomputed sixty times a minute to no purpose. + _phaseSub = _engine.phases.listen((phase) { + final inBreak = phase is InBreak; + final paused = phase is Paused; + if (inBreak == _inBreak && paused == _paused) return; + _inBreak = inBreak; + _paused = paused; + _recompute(); + }); + + await _refreshScreenTime(); + _timer = Timer.periodic(_screenTimeEvery, (_) async { + await _refreshScreenTime(); + _recompute(); + }); + _recompute(); + } + + void _onEvent(EngineEvent event) { + final response = switch (event) { + BreakCompleted() || BreakCredited() => BreakResponse.honored, + BreakSnoozed() => BreakResponse.snoozed, + BreakSkipped() => BreakResponse.skipped, + BreakEscaped() => BreakResponse.escaped, + _ => null, + }; + if (response == null) return; + + if (response == BreakResponse.honored) _lastRestAt = _clock.now(); + _recent.add(response); + // Only the window is ever consulted, so only the window is kept — this + // list must not grow for the lifetime of the process. + while (_recent.length > _rules.window) { + _recent.removeAt(0); + } + unawaited(_saveRecent()); + _recompute(); + } + + Future _refreshScreenTime() async { + try { + final stats = await _activity.sliceStatsFor(_clock.now()); + _screenTime = stats.screenTime; + } on Object { + // A stats failure must never take the tray icon down with it. + } + } + + void _recompute() { + final now = _clock.now(); + final raw = computeMood( + MoodInputs( + recent: List.unmodifiable(_recent), + screenTime: _screenTime, + // Before the first break of the session there is no "last rest" to + // measure from, so fatigue is judged on screen time alone rather + // than on an invented age. + sinceLastRest: _lastRestAt == null + ? Duration.zero + : now.difference(_lastRestAt!), + inBreak: _inBreak, + paused: _paused, + ), + rules: _rules, + ); + final mood = _tracker.update(raw); + if (mood == _published) return; // only publish real changes + _published = mood; + if (!_moods.isClosed) _moods.add(mood); + } + + /// The window survives a restart: a mood that resets to cheerful on every + /// launch would mean the icon never says anything true after a reboot, and + /// would make restarting a way to clear a warning. + Future> _loadRecent() async { + final raw = await _settings.readValue(SettingsRepository.keyMoodRecent); + if (raw == null || raw.isEmpty) return const []; + final byName = {for (final r in BreakResponse.values) r.name: r}; + return [for (final name in raw.split(',')) ?byName[name]]; + } + + Future _saveRecent() => _settings.writeValue( + SettingsRepository.keyMoodRecent, + _recent.map((r) => r.name).join(','), + ); + + Future dispose() async { + _timer?.cancel(); + await _eventSub?.cancel(); + await _phaseSub?.cancel(); + await _moods.close(); + } +} diff --git a/lib/services/polled_value.dart b/lib/services/polled_value.dart new file mode 100644 index 0000000..faa6096 --- /dev/null +++ b/lib/services/polled_value.dart @@ -0,0 +1,40 @@ +/// A TTL-cached value refreshed from an async probe, with in-flight +/// de-duplication. +/// +/// Platform probes here are expensive in different ways — `pw-dump` spawns a +/// process, D-Bus calls cross a socket — and the 1 Hz engine tick must never +/// wait on either. Everything reads the last known value while at most one +/// refresh runs behind it. +class PolledValue { + PolledValue({ + required this._probe, + required T initial, + this.ttl = const Duration(seconds: 5), + }) : _value = initial; + + final Future Function() _probe; + final Duration ttl; + + T _value; + DateTime? _sampledAt; + Future? _inFlight; + + T get value => _value; + + /// Refreshes when [relevant] and the cached value is older than [ttl]. + /// Never runs two probes concurrently. + Future refreshIfNeeded({ + required bool relevant, + required DateTime now, + }) { + if (!relevant) return Future.value(); + final age = _sampledAt == null ? null : now.difference(_sampledAt!); + if (age != null && age < ttl) return Future.value(); + return _inFlight ??= _probe() + .then((result) { + _value = result; + _sampledAt = now; + }) + .whenComplete(() => _inFlight = null); + } +} diff --git a/lib/services/providers.dart b/lib/services/providers.dart index bfa6cad..315c968 100644 --- a/lib/services/providers.dart +++ b/lib/services/providers.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/engine/phase.dart'; import '../core/models/activity.dart'; import '../core/models/break_config.dart'; +import '../core/models/exercise.dart'; import '../data/activity_repository.dart'; import '../data/break_log_repository.dart'; import '../data/database.dart'; @@ -18,6 +19,7 @@ import '../platform/interfaces/tray_support.dart'; import 'advice_engine.dart'; import 'app_lifecycle.dart'; import 'engine_service.dart'; +import 'tray_mood_presenter.dart'; import 'exercise_picker.dart'; /// Concrete instances are created in the bootstrap (or in tests) and @@ -307,6 +309,9 @@ class GeneralSettings { required this.updateCheck, required this.fullscreenOverlay, required this.sounds, + required this.pauseDuringMedia, + required this.moodIndicator, + required this.maxIntensity, }); final bool autostart; @@ -314,16 +319,31 @@ class GeneralSettings { final bool fullscreenOverlay; final bool sounds; + /// Hold breaks while a fullscreen video or presentation is running. + final bool pauseDuringMedia; + + /// Let the tray icon's expression reflect how the day is going. + final bool moodIndicator; + + /// The most demanding exercise the user wants to be offered. + final ExerciseIntensity maxIntensity; + GeneralSettings copyWith({ bool? autostart, bool? updateCheck, bool? fullscreenOverlay, bool? sounds, + bool? pauseDuringMedia, + bool? moodIndicator, + ExerciseIntensity? maxIntensity, }) => GeneralSettings( autostart: autostart ?? this.autostart, updateCheck: updateCheck ?? this.updateCheck, fullscreenOverlay: fullscreenOverlay ?? this.fullscreenOverlay, sounds: sounds ?? this.sounds, + pauseDuringMedia: pauseDuringMedia ?? this.pauseDuringMedia, + moodIndicator: moodIndicator ?? this.moodIndicator, + maxIntensity: maxIntensity ?? this.maxIntensity, ); } @@ -350,8 +370,21 @@ class GeneralSettingsNotifier extends AsyncNotifier { SettingsRepository.flagSounds, fallback: true, ), + pauseDuringMedia: await settings.getFlag( + SettingsRepository.flagPauseDuringMedia, + fallback: true, + ), + moodIndicator: await settings.getFlag( + SettingsRepository.flagMoodIndicator, + fallback: true, + ), + maxIntensity: _intensityFrom( + await settings.readValue(SettingsRepository.keyMaxIntensity), + ), ); ref.read(soundPlayerProvider).enabled = general.sounds; + ref.read(exercisePickerProvider).maxIntensity = general.maxIntensity; + ref.read(engineServiceProvider).pauseDuringMedia = general.pauseDuringMedia; return general; } @@ -381,6 +414,36 @@ class GeneralSettingsNotifier extends AsyncNotifier { ); } + Future setPauseDuringMedia(bool enabled) async { + ref.read(engineServiceProvider).pauseDuringMedia = enabled; + await _persist( + SettingsRepository.flagPauseDuringMedia, + enabled, + (s) => s.copyWith(pauseDuringMedia: enabled), + ); + } + + Future setMoodIndicator(bool enabled) async { + final presenter = ref.read(trayMoodPresenterProvider).presenter; + if (presenter != null) { + presenter.enabled = enabled; + await presenter.refresh(); + } + await _persist( + SettingsRepository.flagMoodIndicator, + enabled, + (s) => s.copyWith(moodIndicator: enabled), + ); + } + + Future setMaxIntensity(ExerciseIntensity value) async { + ref.read(exercisePickerProvider).maxIntensity = value; + await ref + .read(settingsRepositoryProvider) + .writeValue(SettingsRepository.keyMaxIntensity, value.name); + await _update((s) => s.copyWith(maxIntensity: value)); + } + Future _persist( String flag, bool value, @@ -394,3 +457,22 @@ class GeneralSettingsNotifier extends AsyncNotifier { state = AsyncData(apply(await future)); } } + +/// Holds the live tray presenter, which only exists once a tray host has +/// been found. Null on desktops without a status area, so every consumer +/// must cope with its absence. +/// +/// A mutable holder rather than a StateProvider: Riverpod 3 dropped +/// StateProvider, and nothing rebuilds on this changing — Settings just needs +/// a handle to push the toggle through to. +/// Medium by default: standing stretches suit most desks, while the heavy +/// exercises are opt-in because nobody should be shown squats in an office +/// they cannot do them in. +ExerciseIntensity _intensityFrom(String? raw) => ExerciseIntensity.values + .firstWhere((v) => v.name == raw, orElse: () => ExerciseIntensity.medium); + +class TrayMoodPresenterHolder { + TrayMoodPresenter? presenter; +} + +final trayMoodPresenterProvider = Provider((ref) => TrayMoodPresenterHolder()); diff --git a/lib/services/tray_mood_presenter.dart b/lib/services/tray_mood_presenter.dart new file mode 100644 index 0000000..24c7014 --- /dev/null +++ b/lib/services/tray_mood_presenter.dart @@ -0,0 +1,80 @@ +import 'dart:async'; + +import '../app/tray_face.dart'; +import '../core/mood/mood.dart'; +import '../platform/interfaces/tray_indicator.dart'; + +/// Paints the current mood onto the tray icon. +/// +/// Separate from `MoodService` on purpose: that decides *what* the app feels, +/// this decides how it looks. Only this half needs a rendering pipeline, so +/// only this half is hard to test. +class TrayMoodPresenter { + TrayMoodPresenter({ + required this._tray, + required this._moods, + this.enabled = true, + Mood initial = Mood.good, + }) : _mood = initial; + + /// A short acknowledgement, not an idle animation. + /// + /// Every frame is a D-Bus signal plus the host re-reading two icon + /// properties, so a continuous loop would be a genuine background cost for + /// something nobody is looking at. Four frames on a change is enough to + /// catch the eye, and a mood change is exactly when the eye should be + /// caught. It is also the "your break is over" cue that replaced popping + /// the window back up. + static const _pulse = [1.0, 1.06, 0.97, 1.0]; + static const _frameGap = Duration(milliseconds: 220); + + final TrayIndicator _tray; + final Stream _moods; + + /// When off, the icon stays on the neutral brand face. + bool enabled; + + StreamSubscription? _sub; + + /// Seeded from the service rather than defaulting to cheerful: the mood is + /// computed before a tray host is found, so starting at `good` would show a + /// smile to someone the app had already concluded was skipping breaks. + Mood _mood; + + /// Guards against two pulses overlapping — the second would otherwise race + /// the first and leave the icon on whichever frame happened to land last. + int _pulseToken = 0; + + Future start() async { + _sub = _moods.listen((mood) => unawaited(show(mood))); + await _apply(_mood); + } + + /// Renders [mood] and plays the acknowledgement pulse. + Future show(Mood mood) async { + _mood = mood; + if (!enabled) return _apply(Mood.good); + + final token = ++_pulseToken; + for (final scale in _pulse) { + if (token != _pulseToken) return; // superseded by a newer mood + await _apply(mood, scale: scale); + if (scale != _pulse.last) await Future.delayed(_frameGap); + } + } + + /// Re-asserts the current mood — used when the setting is toggled. + Future refresh() => enabled ? _apply(_mood) : _apply(Mood.good); + + Future _apply(Mood mood, {double scale = 1}) async { + try { + final icons = await renderTrayFace(mood, scale: scale); + await _tray.setIcon(icons, tooltip: MoodFace.of(mood).tooltip); + } on Object { + // No tray host, or a host that went away mid-update. The icon is a + // nicety; nothing here may be allowed to disturb the engine. + } + } + + Future dispose() async => _sub?.cancel(); +} diff --git a/pubspec.yaml b/pubspec.yaml index 683b7cb..9f6f18b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,9 +68,6 @@ flutter: # the material Icons class. uses-material-design: true - assets: - - assets/icons/ - # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/test/app/tray_face_test.dart b/test/app/tray_face_test.dart new file mode 100644 index 0000000..dc81b8c --- /dev/null +++ b/test/app/tray_face_test.dart @@ -0,0 +1,26 @@ +// Guards the tray rendering pipeline: every mood must produce well-formed +// ARGB32 pixmaps at both sizes with something actually drawn in them. +// +// The faces themselves were checked by eye during development by rendering a +// contact sheet; see docs/knowledge_graph.md for how to regenerate one. +import 'package:flutter_test/flutter_test.dart'; +import 'package:restifeye/app/tray_face.dart'; +import 'package:restifeye/core/mood/mood.dart'; + +void main() { + test('every mood renders valid pixmaps at both sizes', () async { + for (final mood in Mood.values) { + final pixmaps = await renderTrayFace(mood); + expect(pixmaps.length, 2, reason: '$mood should render 24 and 48 px'); + for (final p in pixmaps) { + expect(p.argb32.length, p.width * p.height * 4); + // A face must actually be drawn: some pixel has to be opaque. + expect( + p.argb32.asMap().entries.any((e) => e.key % 4 == 0 && e.value > 0), + isTrue, + reason: '$mood at ${p.width}px rendered fully transparent', + ); + } + } + }); +} diff --git a/test/core/engine_test.dart b/test/core/engine_test.dart index 25dcc49..5a36bf4 100644 --- a/test/core/engine_test.dart +++ b/test/core/engine_test.dart @@ -168,6 +168,80 @@ void main() { expect((rig.engine.phase as InBreak).snoozesLeft, 3); }); + // Regression: the budget used to be one global counter shared by two + // independently-scheduled cycles. Snoozing a long break pushes it past + // `_microDue + mergeWindow`, so the *micro* break (already due) fires + // next; completing that micro cycle refilled the shared counter, handing + // the still-pending long break a fresh budget on top of what it had + // already spent. Users reported snoozing a 3-budget long break 5 times. + test('a micro cycle does not refill a pending long break\'s budget', () { + // micro 20 / long 21 — the long lands inside the 2 min merge window, + // so it is the kind that comes due first and absorbs the micro. + final rig = Rig( + config: const BreakConfig( + microInterval: Duration(minutes: 20), + longInterval: Duration(minutes: 21), + ), + ); + rig.run(const Duration(minutes: 21, seconds: 5)); + final started = rig.engine.phase as InBreak; + expect(started.kind, BreakKind.long, reason: 'long absorbs the micro'); + + // Snooze #1 of 3 on the long break. + expect(rig.engine.snooze(), isTrue); + + // The long is now at +2 min, past micro's merge window, so the overdue + // micro takes over and runs to completion. + rig.run(const Duration(seconds: 5)); + expect((rig.engine.phase as InBreak).kind, BreakKind.micro); + rig.run(const Duration(seconds: 25)); // micro completes (20 s) + + // Back to the long break. It must remember it already spent one snooze. + rig.run(const Duration(minutes: 2, seconds: 5)); + final resumed = rig.engine.phase as InBreak; + expect(resumed.kind, BreakKind.long); + expect( + resumed.snoozesLeft, + 2, + reason: 'the long break spent one snooze before the micro interrupted', + ); + + // Only two snoozes may remain — the third attempt must fail. + expect(rig.engine.snooze(), isTrue); + rig.run(const Duration(minutes: 2, seconds: 5)); + expect(rig.engine.snooze(), isTrue); + rig.run(const Duration(minutes: 2, seconds: 5)); + expect( + rig.engine.snooze(), + isFalse, + reason: 'budget of 3 is spent; a 4th snooze must be refused', + ); + expect((rig.engine.phase as InBreak).strict, isTrue); + }); + + test('each break kind carries its own snooze budget', () { + final rig = Rig( + config: const BreakConfig( + microInterval: Duration(minutes: 20), + longInterval: Duration(minutes: 60), + ), + ); + // Spend two snoozes on a micro break, then let it complete. + rig.run(const Duration(minutes: 20, seconds: 5)); + expect(rig.engine.snooze(), isTrue); + rig.run(const Duration(minutes: 2, seconds: 5)); + expect(rig.engine.snooze(), isTrue); + rig.run(const Duration(minutes: 2, seconds: 5)); + expect((rig.engine.phase as InBreak).snoozesLeft, 1); + rig.run(const Duration(seconds: 25)); // completes + + // The long break is untouched by the micro's spending. + rig.run(const Duration(minutes: 36)); + final long = rig.engine.phase as InBreak; + expect(long.kind, BreakKind.long); + expect(long.snoozesLeft, 3); + }); + test('skip from the warning cancels the cycle and reschedules', () { final rig = Rig(); rig.run(const Duration(minutes: 19, seconds: 40)); @@ -271,6 +345,115 @@ void main() { ); }); + group('media pause', () { + const playing = TickInput(presenting: true, presentingApp: 'mpv'); + + test('a fullscreen video pauses scheduling outright', () { + final rig = Rig(); + rig.run(const Duration(minutes: 5)); + rig.run(const Duration(minutes: 1), playing); + + final phase = rig.engine.phase; + expect(phase, isA()); + expect((phase as Paused).reason, PauseReason.media); + expect(phase.byApp, 'mpv'); + expect(phase.byUser, isFalse, reason: 'must not light up the UI toggle'); + expect(rig.eventsOf().single.byApp, 'mpv'); + }); + + test('no break fires during a two-hour film', () { + final rig = Rig(); + rig.run(const Duration(minutes: 5)); + rig.run(const Duration(hours: 2), playing); + + expect(rig.eventsOf(), isEmpty); + expect(rig.engine.phase, isA()); + }); + + // The whole point of the deferral cap is that a break can only be pushed + // so far. Media pause deliberately has no cap — a cap would guarantee the + // interruption the feature exists to prevent. + test('the media pause is not subject to the deferral cap', () { + final rig = Rig(); + rig.run(const Duration(minutes: 19), playing); + rig.run(const Duration(minutes: 30), playing); + expect(rig.eventsOf(), isEmpty); + expect(rig.eventsOf(), isEmpty); + }); + + test('timers are not reset when the video ends', () { + final rig = Rig(); + // 15 min of work, then a 40 min film. 5 min of the interval remain. + rig.run(const Duration(minutes: 15)); + rig.run(const Duration(minutes: 40), playing); + expect(rig.engine.phase, isA()); + + // Resuming must not restart the 20 min interval: the break is already + // overdue, so it arrives after the short re-warn lead, not 20 min later. + rig.run(const Duration(seconds: 1)); + expect(rig.engine.phase, isA()); + rig.run(const Duration(seconds: 20)); + expect( + rig.engine.phase, + isA(), + reason: 'an overdue break resumes promptly, not on a fresh interval', + ); + }); + + // Watching counts as screen time: the interval keeps running down during + // the film, it just never fires. So a short video does not push the next + // break back, and a long one leaves it overdue (covered above). What must + // never happen is the interval *restarting*, which would let a film buy + // the user a fresh 20 minutes. + test('a film consumes interval time rather than resetting it', () { + final rig = Rig(); + rig.run(const Duration(minutes: 5)); + rig.run(const Duration(minutes: 10), playing); + rig.run(const Duration(seconds: 1)); + + final phase = rig.engine.phase; + expect(phase, isA()); + // 15 min of the 20 min interval have elapsed; ~5 remain. + expect((phase as Monitoring).microIn.inSeconds, closeTo(299, 3)); + }); + + test('film time is never credited as a natural break', () { + final rig = Rig(); + rig.run(const Duration(minutes: 5)); + // Watching a film without touching the keyboard looks exactly like + // being idle. It is not rest, and must not earn a credited break. + var idle = Duration.zero; + for (var i = 0; i < 60 * 30; i++) { + idle += const Duration(seconds: 1); + rig.clock.advance(const Duration(seconds: 1)); + rig.engine.tick( + TickInput(idle: idle, presenting: true, presentingApp: 'mpv'), + ); + } + rig.run(const Duration(seconds: 1)); + expect(rig.eventsOf(), isEmpty); + }); + + test('a break already on screen is left to finish', () { + final rig = Rig(); + rig.run(const Duration(minutes: 20, seconds: 5)); + expect(rig.engine.phase, isA()); + + // Going fullscreen mid-break must not abandon it — that would log a + // BreakEscaped the user never earned. + rig.run(const Duration(seconds: 5), playing); + expect(rig.engine.phase, isA()); + expect(rig.eventsOf(), isEmpty); + }); + + test('a user pause outranks a media pause', () { + final rig = Rig(); + rig.engine.setPausedByUser(true); + rig.run(const Duration(minutes: 1), playing); + expect((rig.engine.phase as Paused).reason, PauseReason.user); + }); + }); + group('busy deferral', () { test('a due break defers while the mic is in use', () { final rig = Rig(); diff --git a/test/core/mood_test.dart b/test/core/mood_test.dart new file mode 100644 index 0000000..cce35df --- /dev/null +++ b/test/core/mood_test.dart @@ -0,0 +1,187 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restifeye/core/mood/mood.dart'; +import 'package:restifeye/core/mood/mood_rules.dart'; +import 'package:restifeye/core/mood/mood_tracker.dart'; + +void main() { + group('computeMood', () { + const honored = BreakResponse.honored; + const skipped = BreakResponse.skipped; + const snoozed = BreakResponse.snoozed; + + test('a paused engine has no opinion', () { + expect( + computeMood( + const MoodInputs(recent: [skipped, skipped, skipped], paused: true), + ), + Mood.paused, + ); + }); + + test('a break in progress shows rest, whatever the history', () { + expect( + computeMood( + const MoodInputs(recent: [skipped, skipped, skipped], inBreak: true), + ), + Mood.resting, + ); + }); + + test('no history withholds judgement', () { + expect(computeMood(const MoodInputs()), Mood.good); + }); + + test('taking breaks earns praise', () { + expect( + computeMood( + const MoodInputs(recent: [honored, honored, honored, honored]), + ), + Mood.great, + ); + }); + + test('a couple of skips reads as slipping', () { + expect( + computeMood(const MoodInputs(recent: [honored, skipped, skipped])), + Mood.slipping, + ); + }); + + test('persistent skipping reads as ignoring', () { + expect( + computeMood(const MoodInputs(recent: [skipped, skipped, skipped])), + Mood.ignoring, + ); + }); + + // A snooze is a deferral the app itself offers. Treating one like a skip + // would punish users for using a feature as intended. + test('one snooze is not held against the user', () { + expect( + computeMood( + const MoodInputs(recent: [honored, honored, honored, snoozed]), + ), + Mood.good, + ); + }); + + test('but habitual snoozing counts as slipping', () { + expect( + computeMood(const MoodInputs(recent: [snoozed, snoozed, snoozed])), + Mood.slipping, + ); + }); + + test('a long stretch without rest reads as tired', () { + expect( + computeMood( + const MoodInputs( + recent: [honored, honored], + sinceLastRest: Duration(hours: 2), + ), + ), + Mood.tired, + ); + }); + + test('a long day reads as tired even when fully compliant', () { + expect( + computeMood( + const MoodInputs( + recent: [honored, honored, honored, honored], + screenTime: Duration(hours: 7), + ), + ), + Mood.tired, + ); + }); + + // Being told breaks are being skipped is more actionable than being told + // the day is long, so it must not be masked by fatigue. + test('skipping outranks tiredness', () { + expect( + computeMood( + const MoodInputs( + recent: [skipped, skipped, skipped], + screenTime: Duration(hours: 9), + sinceLastRest: Duration(hours: 3), + ), + ), + Mood.ignoring, + ); + }); + + test('only the most recent responses count', () { + // Three old skips have rolled out of a five-wide window. + expect( + computeMood( + const MoodInputs( + recent: [ + skipped, + skipped, + skipped, + honored, + honored, + honored, + honored, + honored, + ], + ), + ), + Mood.great, + reason: 'a bad morning must not colour the icon all day', + ); + }); + }); + + group('MoodTracker hysteresis', () { + test('improvement is adopted immediately', () { + final tracker = MoodTracker(initial: Mood.ignoring); + expect(tracker.update(Mood.great), Mood.great); + }); + + test('worsening requires persistence', () { + final tracker = MoodTracker(escalateAfter: 3); + expect(tracker.update(Mood.ignoring), Mood.good); + expect(tracker.update(Mood.ignoring), Mood.good); + expect( + tracker.update(Mood.ignoring), + Mood.ignoring, + reason: 'only after the third consecutive sample', + ); + }); + + test('a single bad sample never escalates', () { + final tracker = MoodTracker(escalateAfter: 3); + tracker.update(Mood.slipping); + expect(tracker.update(Mood.good), Mood.good); + tracker.update(Mood.slipping); + expect( + tracker.current, + Mood.good, + reason: 'the pending count resets when the mood recovers', + ); + }); + + test('a different worse mood restarts the count', () { + final tracker = MoodTracker(escalateAfter: 3); + tracker.update(Mood.slipping); + tracker.update(Mood.slipping); + tracker.update(Mood.ignoring); // restarts at 1 + expect(tracker.current, Mood.good); + tracker.update(Mood.ignoring); + expect(tracker.update(Mood.ignoring), Mood.ignoring); + }); + + test('transient moods pass through without disturbing the steady one', () { + final tracker = MoodTracker(initial: Mood.slipping); + expect(tracker.update(Mood.resting), Mood.resting); + expect(tracker.update(Mood.paused), Mood.paused); + expect( + tracker.current, + Mood.slipping, + reason: 'a break must not erase what the day established', + ); + }); + }); +} diff --git a/test/helpers/overrides.dart b/test/helpers/overrides.dart index be28f4d..77a1868 100644 --- a/test/helpers/overrides.dart +++ b/test/helpers/overrides.dart @@ -33,7 +33,8 @@ class TestHarness { overlay = FakeOverlayController(), idle = FakeIdleMonitor(), session = FakeSessionSignals(), - context = FakeContextSignals() { + context = FakeContextSignals(), + presentation = FakePresentationSignals() { engine = BreakEngine( clock: clock, config: config, @@ -45,6 +46,7 @@ class TestHarness { idleMonitor: idle, sessionSignals: session, sampler: ContextSampler(context), + presentation: PresentationSampler(presentation), breakLog: BreakLogRepository(db), settings: SettingsRepository(db), recorder: ActivityRecorder(ActivityRepository(db).insertSlice), @@ -65,6 +67,7 @@ class TestHarness { final FakeIdleMonitor idle; final FakeSessionSignals session; final FakeContextSignals context; + final FakePresentationSignals presentation; final FakeTrayIndicator tray = FakeTrayIndicator(); final SilentSoundPlayer sounds = SilentSoundPlayer(); late final BreakEngine engine; diff --git a/test/services/exercise_picker_test.dart b/test/services/exercise_picker_test.dart index 376c9bc..3dda421 100644 --- a/test/services/exercise_picker_test.dart +++ b/test/services/exercise_picker_test.dart @@ -53,4 +53,54 @@ void main() { // 8 long exercises with recent-penalty: immediate repeats should be rare. expect(repeats, lessThan(20)); }); + test('never exceeds the intensity ceiling', () { + final picker = ExercisePicker( + maxIntensity: ExerciseIntensity.soft, + random: Random(7), + ); + for (var i = 0; i < 200; i++) { + expect(picker.pick(BreakKind.long).intensity, ExerciseIntensity.soft); + } + }); + + test('a raised ceiling unlocks the heavier exercises', () { + final picker = ExercisePicker( + maxIntensity: ExerciseIntensity.heavy, + random: Random(3), + ); + final seen = { + for (var i = 0; i < 400; i++) picker.pick(BreakKind.long).intensity, + }; + expect(seen, contains(ExerciseIntensity.heavy)); + }); + + // The ceiling is a physical constraint; an opt-out is a preference. When + // they collide the preference has to yield, or there is nothing to show. + test('the ceiling outranks opt-outs when they leave nothing', () { + final softIds = exerciseDeck + .where( + (e) => + e.tier == BreakKind.long && e.intensity == ExerciseIntensity.soft, + ) + .map((e) => e.id) + .toSet(); + final picker = ExercisePicker( + optOuts: softIds, + maxIntensity: ExerciseIntensity.soft, + random: Random(11), + ); + expect(picker.pick(BreakKind.long).intensity, ExerciseIntensity.soft); + }); + + test('lowering the ceiling mid-session takes effect immediately', () { + final picker = ExercisePicker( + maxIntensity: ExerciseIntensity.heavy, + random: Random(5), + ); + picker.pick(BreakKind.long); + picker.maxIntensity = ExerciseIntensity.soft; + for (var i = 0; i < 100; i++) { + expect(picker.pick(BreakKind.long).intensity, ExerciseIntensity.soft); + } + }); } diff --git a/test/services/overlay_reconciler_test.dart b/test/services/overlay_reconciler_test.dart index 2a6b724..8c63a59 100644 --- a/test/services/overlay_reconciler_test.dart +++ b/test/services/overlay_reconciler_test.dart @@ -26,8 +26,9 @@ void main() { monitoring, Warning(kind: BreakKind.micro, startsIn: Duration(seconds: 30)), Deferred(kind: BreakKind.long, recheckIn: Duration(minutes: 5)), - Paused(byUser: true), - Paused(byUser: false), + Paused(reason: PauseReason.user), + Paused(reason: PauseReason.workHours), + Paused(reason: PauseReason.media), ]; for (final phase in phases) { expect(