Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed assets/icons/tray_24.png
Binary file not shown.
Binary file removed assets/icons/tray_48.png
Binary file not shown.
8 changes: 7 additions & 1 deletion docs/knowledge_graph.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<BreakKind,int>`, 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<T>`; 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).
55 changes: 47 additions & 8 deletions lib/app/bootstrap.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -79,6 +84,9 @@ Future<BootResult> 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(
Expand Down Expand Up @@ -150,27 +158,58 @@ Future<BootResult> 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,
),
);
}

Expand Down
Loading
Loading