From d77c4f62980fb580537ea9696b005ee2a52236bb Mon Sep 17 00:00:00 2001 From: bbedward Date: Thu, 1 Oct 2026 15:15:37 -0400 Subject: [PATCH] wellbeing: add Digital Wellbeing, with a built-in dash plugin --- core/internal/server/router.go | 9 + core/internal/server/router_test.go | 1 + core/internal/server/server.go | 40 +- core/internal/server/wellbeing/handlers.go | 53 +++ core/internal/server/wellbeing/manager.go | 351 ++++++++++++++++++ .../internal/server/wellbeing/manager_test.go | 196 ++++++++++ core/internal/server/wellbeing/store.go | 156 ++++++++ core/internal/server/wellbeing/types.go | 68 ++++ quickshell/Common/SettingsData.qml | 7 + quickshell/Common/SettingsTabs.qml | 8 + quickshell/Common/settings/SettingsSpec.js | 13 + quickshell/DMSShell.qml | 1 + .../Modals/Settings/SettingsContent.qml | 1 + quickshell/Modules/DankDash/DashRegistry.qml | 29 ++ .../DankDash/Overview/DashOptionsSheet.qml | 11 +- .../Overview/WellbeingOverviewCard.qml | 122 ++++++ .../DankDash/Wellbeing/AppLimitSheet.qml | 41 ++ .../DankDash/Wellbeing/AppUsageList.qml | 223 +++++++++++ .../DankDash/Wellbeing/DurationSteppers.qml | 36 ++ .../DankDash/Wellbeing/WeeklyChart.qml | 234 ++++++++++++ .../Modules/DankDash/Wellbeing/Wellbeing.js | 113 ++++++ .../DankDash/Wellbeing/WellbeingMetrics.qml | 19 + quickshell/Modules/DankDash/WellbeingTab.qml | 95 +++++ .../Notifications/Popup/NotificationPopup.qml | 16 +- quickshell/Modules/Settings/DankDashTab.qml | 13 + .../Modules/Settings/DigitalWellbeingTab.qml | 198 ++++++++++ quickshell/Services/DMSService.qml | 3 + quickshell/Services/WellbeingService.qml | 191 ++++++++++ quickshell/tests/wellbeing-model.test.mjs | 50 +++ .../translations/extract_settings_index.py | 1 + .../translations/settings_search_index.json | 148 ++++++++ 31 files changed, 2439 insertions(+), 8 deletions(-) create mode 100644 core/internal/server/wellbeing/handlers.go create mode 100644 core/internal/server/wellbeing/manager.go create mode 100644 core/internal/server/wellbeing/manager_test.go create mode 100644 core/internal/server/wellbeing/store.go create mode 100644 core/internal/server/wellbeing/types.go create mode 100644 quickshell/Modules/DankDash/Overview/WellbeingOverviewCard.qml create mode 100644 quickshell/Modules/DankDash/Wellbeing/AppLimitSheet.qml create mode 100644 quickshell/Modules/DankDash/Wellbeing/AppUsageList.qml create mode 100644 quickshell/Modules/DankDash/Wellbeing/DurationSteppers.qml create mode 100644 quickshell/Modules/DankDash/Wellbeing/WeeklyChart.qml create mode 100644 quickshell/Modules/DankDash/Wellbeing/Wellbeing.js create mode 100644 quickshell/Modules/DankDash/Wellbeing/WellbeingMetrics.qml create mode 100644 quickshell/Modules/DankDash/WellbeingTab.qml create mode 100644 quickshell/Modules/Settings/DigitalWellbeingTab.qml create mode 100644 quickshell/Services/WellbeingService.qml create mode 100644 quickshell/tests/wellbeing-model.test.mjs diff --git a/core/internal/server/router.go b/core/internal/server/router.go index 95554ffd3..534044368 100644 --- a/core/internal/server/router.go +++ b/core/internal/server/router.go @@ -28,6 +28,7 @@ import ( serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wallpaper" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wellbeing" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" "github.com/AvengeMedia/dankgo/ipc" ) @@ -213,6 +214,14 @@ func newRequestMux() *ipc.Mux { location.HandleRequest(conn, req, locationManager) })) + mux.HandlePrefix("wellbeing.", requestHandler(func(conn *ipc.ConnWriter, req ipc.Request) { + if wellbeingManager == nil { + models.RespondError(conn, req.ID, "wellbeing manager not initialized") + return + } + wellbeing.HandleRequest(conn, req, wellbeingManager) + })) + mux.HandlePrefix("notify.", requestHandler(func(conn *ipc.ConnWriter, req ipc.Request) { if notifyActionsManager == nil { models.RespondError(conn, req.ID, "notification action manager not initialized") diff --git a/core/internal/server/router_test.go b/core/internal/server/router_test.go index 24e12b82e..20b379b43 100644 --- a/core/internal/server/router_test.go +++ b/core/internal/server/router_test.go @@ -37,6 +37,7 @@ func TestRouteRequestUnavailableManagers(t *testing.T) { {"dbus.subscribe", "dbus manager not initialized"}, {"clipboard.getState", "clipboard manager not initialized"}, {"location.getState", "location manager not initialized"}, + {"wellbeing.getState", "wellbeing manager not initialized"}, {"notify.invoke", "notification action manager not initialized"}, {"sysupdate.getState", "sysupdate manager not initialized"}, {"files.list", "files service not initialized"}, diff --git a/core/internal/server/server.go b/core/internal/server/server.go index e3e51fe0e..438a4e9de 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -31,6 +31,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wallpaper" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wellbeing" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" @@ -38,7 +39,7 @@ import ( "github.com/AvengeMedia/dankgo/syncmap" ) -const APIVersion = 36 +const APIVersion = 37 var CLIVersion = "dev" @@ -77,6 +78,7 @@ var trayRecoveryManager *trayrecovery.Manager var locationManager *location.Manager var sysUpdateManager *sysupdate.Manager var notifyActionsManager *notifyactions.Manager +var wellbeingManager *wellbeing.Manager var geoClientInstance geolocation.Client const dbusClientID = "dms-dbus-client" @@ -349,6 +351,16 @@ func InitializeTrayRecoveryManager() error { return nil } +func InitializeWellbeingManager() error { + manager, err := wellbeing.NewManager() + if err != nil { + return err + } + wellbeingManager = manager + log.Info("Wellbeing manager initialized") + return nil +} + func InitializeLocationManager(geoClient geolocation.Client) error { manager, err := location.NewManager(geoClient) if err != nil { @@ -472,6 +484,10 @@ func getCapabilities() Capabilities { caps = append(caps, "location") } + if wellbeingManager != nil { + caps = append(caps, "wellbeing") + } + if dbusManager != nil { caps = append(caps, "dbus") } @@ -730,6 +746,15 @@ func handleSubscribe(ctx context.Context, conn *ipc.ConnWriter, req ipc.Request) }, mgr.GetState) } + if shouldSubscribe("wellbeing") && wellbeingManager != nil { + mgr := wellbeingManager + id := clientID + "-wellbeing" + source := mgr.Subscribe(id) + forwardSubscription(&wg, eventChan, stopChan, "wellbeing", source, func() { + mgr.Unsubscribe(id) + }, mgr.Snapshot) + } + if shouldSubscribe("sysupdate") && sysUpdateManager != nil { mgr := sysUpdateManager id := clientID + "-sysupdate" @@ -873,6 +898,9 @@ func cleanupManagers() { if sysUpdateManager != nil { sysUpdateManager.Close() } + if wellbeingManager != nil { + wellbeingManager.Close() + } if geoClientInstance != nil { geoClientInstance.Close() } @@ -1088,6 +1116,12 @@ func (s *Server) Serve(printDocs bool) error { log.Info("Location:") log.Info(" location.getState - Get current location state") log.Info(" location.subscribe - Subscribe to location changes (streaming)") + log.Info("Wellbeing:") + log.Info(" wellbeing.getState - Today's screen time per app") + log.Info(" wellbeing.setState - Report the focused app and whether the session is active (params: appId?, active, seq?)") + log.Info(" wellbeing.setLimits - Set daily and per-app limits in seconds (params: daily?, apps?)") + log.Info(" wellbeing.summary - Per-day screen time ending today (params: days?)") + log.Info(" wellbeing.clear - Delete the screen time history") log.Info("") } log.Info("Initializing managers...") @@ -1190,6 +1224,10 @@ func (s *Server) Serve(printDocs bool) error { }() } + if err := InitializeWellbeingManager(); err != nil { + log.Warnf("Wellbeing manager unavailable: %v", err) + } + if err := InitializeWallpaperManager(); err != nil { log.Warnf("Wallpaper scheduler unavailable: %v", err) } else { diff --git a/core/internal/server/wellbeing/handlers.go b/core/internal/server/wellbeing/handlers.go new file mode 100644 index 000000000..2dbade00a --- /dev/null +++ b/core/internal/server/wellbeing/handlers.go @@ -0,0 +1,53 @@ +package wellbeing + +import ( + "fmt" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/dankgo/ipc" + "github.com/AvengeMedia/dankgo/ipc/params" +) + +func HandleRequest(conn *ipc.ConnWriter, req ipc.Request, manager *Manager) { + switch req.Method { + case "wellbeing.getState": + models.Respond(conn, req.ID, manager.GetState()) + case "wellbeing.setState": + handleSetState(conn, req, manager) + case "wellbeing.setLimits": + handleSetLimits(conn, req, manager) + case "wellbeing.summary": + days := min(max(params.IntOpt(req.Params, "days", 7), 1), retentionDays) + models.Respond(conn, req.ID, map[string]any{"days": manager.Summary(days)}) + case "wellbeing.clear": + manager.Clear() + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "wellbeing history cleared"}) + default: + models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) + } +} + +func handleSetState(conn *ipc.ConnWriter, req ipc.Request, manager *Manager) { + active, err := params.Bool(req.Params, "active") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + manager.SetState(params.StringOpt(req.Params, "appId", ""), active, int64(params.FloatOpt(req.Params, "seq", 0))) + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "wellbeing state set"}) +} + +func handleSetLimits(conn *ipc.ConnWriter, req ipc.Request, manager *Manager) { + limits := Limits{Daily: int64(params.FloatOpt(req.Params, "daily", 0)), Apps: map[string]int64{}} + if apps, ok := params.AnyMap(req.Params, "apps"); ok { + for appID, raw := range apps { + seconds, ok := raw.(float64) + if !ok || seconds <= 0 || appID == "" { + continue + } + limits.Apps[appID] = int64(seconds) + } + } + manager.SetLimits(limits) + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "wellbeing limits set"}) +} diff --git a/core/internal/server/wellbeing/manager.go b/core/internal/server/wellbeing/manager.go new file mode 100644 index 000000000..ab2061c7b --- /dev/null +++ b/core/internal/server/wellbeing/manager.go @@ -0,0 +1,351 @@ +package wellbeing + +import ( + "maps" + "slices" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + bolt "go.etcd.io/bbolt" +) + +func NewManager() (*Manager, error) { + return newManager(DBPath(), time.Now) +} + +func newManager(path string, now func() time.Time) (*Manager, error) { + db, err := openDB(path) + if err != nil { + return nil, err + } + m := &Manager{ + db: db, + now: now, + wake: make(chan struct{}, 1), + stopChan: make(chan struct{}), + limits: Limits{Apps: map[string]int64{}}, + } + m.cur = segment{Since: now().Unix()} + err = m.dbView(func(tx *bolt.Tx) error { + readMeta(tx.Bucket(bucketMeta), keyAlerted, &m.alerted) + return nil + }) + if err != nil { + db.Close() + return nil, err + } + m.wg.Go(m.run) + return m, nil +} + +func (m *Manager) Close() { + close(m.stopChan) + m.wg.Wait() + m.subscribers.Range(func(key string, ch chan State) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) + m.db.Close() +} + +func (m *Manager) Subscribe(id string) chan State { + ch := make(chan State, 64) + m.subscribers.Store(id, ch) + return ch +} + +// The shell is the only source of focus and idle state, so when its last +// subscription drops the open segment ends instead of counting forever. +func (m *Manager) Unsubscribe(id string) { + ch, ok := m.subscribers.LoadAndDelete(id) + if !ok { + return + } + close(ch) + if m.subscriberCount() > 0 { + return + } + m.mu.Lock() + defer m.mu.Unlock() + m.setSegmentLocked("", false) +} + +func (m *Manager) subscriberCount() int { + count := 0 + m.subscribers.Range(func(string, chan State) bool { + count++ + return true + }) + return count +} + +// Snapshot opens every subscription; Resync asks the shell to push its state again. +func (m *Manager) Snapshot() State { + state := m.GetState() + state.Resync = true + return state +} + +func (m *Manager) GetState() State { + m.mu.Lock() + defer m.mu.Unlock() + return State{Today: m.summaryLocked(1)[0]} +} + +func (m *Manager) Summary(days int) []DayUsage { + m.mu.Lock() + defer m.mu.Unlock() + return m.summaryLocked(days) +} + +func (m *Manager) SetState(appID string, active bool, seq int64) { + m.mu.Lock() + defer m.mu.Unlock() + if seq != 0 && seq <= m.seq { + return + } + if seq != 0 { + m.seq = seq + } + m.setSegmentLocked(appID, active) +} + +func (m *Manager) setSegmentLocked(appID string, active bool) { + if m.cur.AppID == appID && m.cur.Active == active { + return + } + now := m.now() + m.creditLocked(now) + m.cur = segment{AppID: appID, Active: active, Since: now.Unix()} + select { + case m.wake <- struct{}{}: + default: + } +} + +func (m *Manager) SetLimits(limits Limits) { + m.mu.Lock() + defer m.mu.Unlock() + if limits.Apps == nil { + limits.Apps = map[string]int64{} + } + previous, hadLimits := m.limits, m.limitsSet + m.limits, m.limitsSet = limits, true + if !hadLimits { + return + } + var keep []string + for _, key := range m.alerted.Keys { + if limitFor(key, previous) == limitFor(key, limits) { + keep = append(keep, key) + } + } + if len(keep) == len(m.alerted.Keys) { + return + } + m.alerted.Keys = keep + m.writeAlertedLocked() +} + +func limitFor(key string, limits Limits) int64 { + if key == "daily" { + return limits.Daily + } + return limits.Apps[key[len("app:"):]] +} + +func (m *Manager) Clear() { + m.mu.Lock() + defer m.mu.Unlock() + m.cur.Since = m.now().Unix() + m.alerted = alertRecord{} + err := m.dbUpdate(func(tx *bolt.Tx) error { + if err := tx.DeleteBucket(bucketDays); err != nil { + return err + } + if _, err := tx.CreateBucket(bucketDays); err != nil { + return err + } + return tx.Bucket(bucketMeta).Delete(keyAlerted) + }) + if err != nil { + log.Warnf("Wellbeing: clear failed: %v", err) + } + m.publish(State{Today: m.summaryLocked(1)[0]}) +} + +func (m *Manager) Flush() { + m.mu.Lock() + defer m.mu.Unlock() + m.creditLocked(m.now()) +} + +func (m *Manager) run() { + timer := time.NewTimer(flushInterval) + timer.Stop() + running := false + for { + m.mu.Lock() + active := m.cur.Active + m.mu.Unlock() + switch { + case active && !running: + timer.Reset(flushInterval) + running = true + case !active && running: + timer.Stop() + running = false + } + select { + case <-m.stopChan: + timer.Stop() + m.Flush() + return + case <-m.wake: + case <-timer.C: + running = false + m.Flush() + } + } +} + +// splitCredit hands out the segment's seconds per local calendar day. +func splitCredit(seg segment, now time.Time, fn func(date string, seconds int64)) { + if !seg.Active { + return + } + gap := now.Unix() - seg.Since + if gap <= 0 || gap > int64(maxCreditGap/time.Second) { + return + } + cursor := time.Unix(seg.Since, 0).In(now.Location()) + for { + dayEnd := time.Date(cursor.Year(), cursor.Month(), cursor.Day()+1, 0, 0, 0, 0, cursor.Location()) + if !dayEnd.Before(now) { + fn(cursor.Format(dateLayout), now.Unix()-cursor.Unix()) + return + } + fn(cursor.Format(dateLayout), dayEnd.Unix()-cursor.Unix()) + cursor = dayEnd + } +} + +func (m *Manager) creditLocked(now time.Time) { + seg := m.cur + m.cur.Since = now.Unix() + var today DayUsage + err := m.dbUpdate(func(tx *bolt.Tx) error { + days := tx.Bucket(bucketDays) + var err error + splitCredit(seg, now, func(date string, seconds int64) { + if err != nil { + return + } + day := readDay(days, date) + day.Active += seconds + if seg.AppID != "" { + day.Apps[seg.AppID] += seconds + } + err = writeDay(days, day) + today = day + }) + if err != nil || today.Date == "" { + return err + } + return pruneDays(days, now.AddDate(0, 0, -retentionDays).Format(dateLayout)) + }) + if err != nil { + log.Warnf("Wellbeing: credit failed: %v", err) + return + } + if today.Date == "" { + return + } + events := m.crossedLimitsLocked(today) + if len(events) == 0 { + m.publish(State{Today: today}) + return + } + for i := range events { + m.publish(State{Today: today, Limit: &events[i]}) + } +} + +func (m *Manager) crossedLimitsLocked(today DayUsage) []LimitEvent { + if m.alerted.Date != today.Date { + m.alerted = alertRecord{Date: today.Date} + } + var events []LimitEvent + if m.limits.Daily > 0 && today.Active >= m.limits.Daily && !slices.Contains(m.alerted.Keys, "daily") { + m.alerted.Keys = append(m.alerted.Keys, "daily") + events = append(events, LimitEvent{Kind: "daily", Used: today.Active, Limit: m.limits.Daily}) + } + for _, appID := range slices.Sorted(maps.Keys(m.limits.Apps)) { + limit := m.limits.Apps[appID] + used := today.Apps[appID] + key := "app:" + appID + if limit <= 0 || used < limit || slices.Contains(m.alerted.Keys, key) { + continue + } + m.alerted.Keys = append(m.alerted.Keys, key) + events = append(events, LimitEvent{Kind: "app", AppID: appID, Used: used, Limit: limit}) + } + if len(events) > 0 { + m.writeAlertedLocked() + } + return events +} + +func (m *Manager) writeAlertedLocked() { + err := m.dbUpdate(func(tx *bolt.Tx) error { + return writeMeta(tx.Bucket(bucketMeta), keyAlerted, m.alerted) + }) + if err != nil { + log.Warnf("Wellbeing: alert record write failed: %v", err) + } +} + +func (m *Manager) summaryLocked(days int) []DayUsage { + now := m.now() + days = max(1, days) + result := make([]DayUsage, days) + index := map[string]int{} + for i := range days { + date := now.AddDate(0, 0, i-days+1).Format(dateLayout) + result[i] = DayUsage{Date: date, Apps: map[string]int64{}} + index[date] = i + } + err := m.dbView(func(tx *bolt.Tx) error { + bucket := tx.Bucket(bucketDays) + for date, i := range index { + result[i] = readDay(bucket, date) + } + return nil + }) + if err != nil { + log.Warnf("Wellbeing: summary read failed: %v", err) + } + splitCredit(m.cur, now, func(date string, seconds int64) { + i, ok := index[date] + if !ok { + return + } + result[i].Active += seconds + if m.cur.AppID != "" { + result[i].Apps[m.cur.AppID] += seconds + } + }) + return result +} + +func (m *Manager) publish(state State) { + m.subscribers.Range(func(key string, ch chan State) bool { + select { + case ch <- state: + default: + log.Warn("Wellbeing: subscriber channel full, dropping update") + } + return true + }) +} diff --git a/core/internal/server/wellbeing/manager_test.go b/core/internal/server/wellbeing/manager_test.go new file mode 100644 index 000000000..b55d13b50 --- /dev/null +++ b/core/internal/server/wellbeing/manager_test.go @@ -0,0 +1,196 @@ +package wellbeing + +import ( + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type clock struct{ now time.Time } + +func (c *clock) advance(d time.Duration) { c.now = c.now.Add(d) } + +func newTestManager(t *testing.T, start time.Time) (*Manager, *clock) { + t.Helper() + c := &clock{now: start} + m, err := newManager(filepath.Join(t.TempDir(), "db"), func() time.Time { return c.now }) + require.NoError(t, err) + t.Cleanup(m.Close) + return m, c +} + +func drain(ch chan State) []State { + var out []State + for { + select { + case s := <-ch: + out = append(out, s) + default: + return out + } + } +} + +func TestFocusTimeSplitsAtMidnightPerApp(t *testing.T) { + start := time.Date(2026, 10, 1, 23, 59, 30, 0, time.UTC) + m, c := newTestManager(t, start) + + m.SetState("firefox", true, 1) + c.advance(50 * time.Second) + m.SetState("kitty", true, 2) + c.advance(10 * time.Second) + m.SetState("", false, 3) + + days := m.Summary(2) + assert.Equal(t, "2026-10-01", days[0].Date) + assert.Equal(t, int64(30), days[0].Active) + assert.Equal(t, map[string]int64{"firefox": 30}, days[0].Apps) + assert.Equal(t, "2026-10-02", days[1].Date) + assert.Equal(t, int64(30), days[1].Active) + assert.Equal(t, map[string]int64{"firefox": 20, "kitty": 10}, days[1].Apps) +} + +func TestInactiveAndFrozenGapsCountNothing(t *testing.T) { + start := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + m, c := newTestManager(t, start) + + m.SetState("firefox", false, 1) + c.advance(40 * time.Second) + m.SetState("firefox", true, 2) + c.advance(maxCreditGap + time.Second) + m.Flush() + c.advance(20 * time.Second) + m.SetState("", false, 3) + + today := m.Summary(1)[0] + assert.Equal(t, int64(20), today.Active) + assert.Equal(t, map[string]int64{"firefox": 20}, today.Apps) +} + +func TestStaleSequenceIsIgnored(t *testing.T) { + m, c := newTestManager(t, time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)) + + m.SetState("kitty", true, 10) + m.SetState("firefox", true, 9) + c.advance(15 * time.Second) + m.SetState("", false, 11) + + assert.Equal(t, map[string]int64{"kitty": 15}, m.Summary(1)[0].Apps) +} + +func TestOpenSegmentShowsInSummaryWithoutBeingStored(t *testing.T) { + m, c := newTestManager(t, time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)) + + m.SetState("firefox", true, 1) + c.advance(25 * time.Second) + + assert.Equal(t, int64(25), m.Summary(1)[0].Apps["firefox"]) + assert.Equal(t, int64(25), m.GetState().Today.Active) + assert.Equal(t, int64(25), m.Summary(1)[0].Apps["firefox"], "a read must not credit twice") +} + +func TestLimitsAlertOncePerDayAndAgainAfterRaise(t *testing.T) { + m, c := newTestManager(t, time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)) + ch := m.Subscribe("test") + m.SetLimits(Limits{Daily: 60, Apps: map[string]int64{"firefox": 30}}) + + m.SetState("firefox", true, 1) + c.advance(30 * time.Second) + m.Flush() + c.advance(30 * time.Second) + m.Flush() + c.advance(30 * time.Second) + m.Flush() + + var kinds []string + for _, s := range drain(ch) { + if s.Limit != nil { + kinds = append(kinds, s.Limit.Kind+":"+s.Limit.AppID) + } + } + assert.Equal(t, []string{"app:firefox", "daily:"}, kinds) + + m.SetLimits(Limits{Daily: 60, Apps: map[string]int64{"firefox": 80}}) + c.advance(30 * time.Second) + m.Flush() + limits := 0 + for _, s := range drain(ch) { + if s.Limit != nil { + limits++ + assert.Equal(t, int64(120), s.Limit.Used) + } + } + assert.Equal(t, 1, limits, "a raised app limit alerts again, the unchanged daily limit does not") + + c.advance(12 * time.Hour) + m.Flush() + c.advance(30 * time.Second) + m.Flush() + for _, s := range drain(ch) { + assert.Nil(t, s.Limit, "nothing crosses on a frozen gap or a fresh day under the limits") + } + assert.Equal(t, int64(30), m.Summary(1)[0].Active) +} + +func TestLastUnsubscribeEndsTheOpenSegment(t *testing.T) { + m, c := newTestManager(t, time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)) + m.Subscribe("shell") + m.SetState("firefox", true, 1) + c.advance(10 * time.Second) + m.Unsubscribe("shell") + c.advance(50 * time.Second) + + assert.Equal(t, int64(10), m.Summary(1)[0].Active) + assert.True(t, m.Snapshot().Resync) +} + +func TestClearDropsHistoryAndRearmsAlerts(t *testing.T) { + m, c := newTestManager(t, time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)) + ch := m.Subscribe("test") + m.SetLimits(Limits{Daily: 10}) + m.SetState("firefox", true, 1) + c.advance(10 * time.Second) + m.Flush() + require.NotNil(t, drain(ch)[0].Limit) + + m.Clear() + assert.Equal(t, int64(0), m.Summary(1)[0].Active) + c.advance(10 * time.Second) + m.Flush() + alerts := 0 + for _, s := range drain(ch) { + if s.Limit != nil { + alerts++ + } + } + assert.Equal(t, 1, alerts) +} + +func TestAlertRecordSurvivesRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "db") + c := &clock{now: time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)} + now := func() time.Time { return c.now } + + m, err := newManager(path, now) + require.NoError(t, err) + m.SetLimits(Limits{Daily: 10}) + m.SetState("firefox", true, 1) + c.advance(10 * time.Second) + m.Close() + + m, err = newManager(path, now) + require.NoError(t, err) + defer m.Close() + ch := m.Subscribe("test") + m.SetLimits(Limits{Daily: 10}) + m.SetState("firefox", true, 2) + c.advance(10 * time.Second) + m.Flush() + assert.Equal(t, int64(20), m.Summary(1)[0].Active) + for _, s := range drain(ch) { + assert.Nil(t, s.Limit, "the alert already fired before the restart") + } +} diff --git a/core/internal/server/wellbeing/store.go b/core/internal/server/wellbeing/store.go new file mode 100644 index 000000000..eecedfbe4 --- /dev/null +++ b/core/internal/server/wellbeing/store.go @@ -0,0 +1,156 @@ +package wellbeing + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime/debug" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + bolt "go.etcd.io/bbolt" +) + +var ( + bucketDays = []byte("days") + bucketMeta = []byte("meta") + keyAlerted = []byte("alerted") +) + +func DBPath() string { + return filepath.Join(utils.XDGStateHome(), "DankMaterialShell", "wellbeing", "db") +} + +func openDB(path string) (*bolt.DB, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, err + } + db, err := tryOpenDB(path) + if err == nil { + return db, nil + } + log.Errorf("Wellbeing db unreadable, starting over: %v", err) + corruptPath := path + ".corrupt" + os.Remove(corruptPath) + if renameErr := os.Rename(path, corruptPath); renameErr != nil { + return nil, err + } + return tryOpenDB(path) +} + +// bbolt reports corrupted pages by panicking (internal/common/verify.go), not +// by returning errors, so every db touchpoint recovers and converts to error. +func recoverDBPanic(err *error) { + r := recover() + if r == nil { + return + } + *err = fmt.Errorf("wellbeing db panic: %v", r) +} + +// a pgid past the mmap end faults instead of panicking; only recoverable while armed +func armDBFaultPanics() func() { + prev := debug.SetPanicOnFault(true) + return func() { debug.SetPanicOnFault(prev) } +} + +func tryOpenDB(path string) (db *bolt.DB, err error) { + defer func() { + r := recover() + if r == nil { + return + } + if db != nil { + db.Close() + } + db, err = nil, fmt.Errorf("wellbeing db panic: %v", r) + }() + defer armDBFaultPanics()() + + db, err = bolt.Open(path, 0o600, &bolt.Options{Timeout: time.Second}) + if err != nil { + return nil, err + } + err = db.Update(func(tx *bolt.Tx) error { + if _, err := tx.CreateBucketIfNotExists(bucketDays); err != nil { + return err + } + _, err := tx.CreateBucketIfNotExists(bucketMeta) + return err + }) + if err == nil { + err = db.View(func(tx *bolt.Tx) error { + return tx.Bucket(bucketDays).ForEach(func(k, v []byte) error { return nil }) + }) + } + if err != nil { + db.Close() + return nil, err + } + return db, nil +} + +func (m *Manager) dbUpdate(fn func(tx *bolt.Tx) error) (err error) { + defer recoverDBPanic(&err) + defer armDBFaultPanics()() + return m.db.Update(fn) +} + +func (m *Manager) dbView(fn func(tx *bolt.Tx) error) (err error) { + defer recoverDBPanic(&err) + defer armDBFaultPanics()() + return m.db.View(fn) +} + +func readDay(b *bolt.Bucket, date string) DayUsage { + day := DayUsage{Date: date, Apps: map[string]int64{}} + raw := b.Get([]byte(date)) + if raw == nil { + return day + } + if err := json.Unmarshal(raw, &day); err != nil || day.Apps == nil { + day.Apps = map[string]int64{} + } + day.Date = date + return day +} + +func writeDay(b *bolt.Bucket, day DayUsage) error { + raw, err := json.Marshal(day) + if err != nil { + return err + } + return b.Put([]byte(day.Date), raw) +} + +func readMeta(b *bolt.Bucket, key []byte, out any) bool { + raw := b.Get(key) + if raw == nil { + return false + } + return json.Unmarshal(raw, out) == nil +} + +func writeMeta(b *bolt.Bucket, key []byte, value any) error { + raw, err := json.Marshal(value) + if err != nil { + return err + } + return b.Put(key, raw) +} + +func pruneDays(b *bolt.Bucket, oldest string) error { + c := b.Cursor() + var stale [][]byte + for k, _ := c.First(); k != nil && string(k) < oldest; k, _ = c.Next() { + stale = append(stale, append([]byte(nil), k...)) + } + for _, k := range stale { + if err := b.Delete(k); err != nil { + return err + } + } + return nil +} diff --git a/core/internal/server/wellbeing/types.go b/core/internal/server/wellbeing/types.go new file mode 100644 index 000000000..1c97d332e --- /dev/null +++ b/core/internal/server/wellbeing/types.go @@ -0,0 +1,68 @@ +package wellbeing + +import ( + "sync" + "time" + + "github.com/AvengeMedia/dankgo/syncmap" + bolt "go.etcd.io/bbolt" +) + +const ( + flushInterval = time.Minute + // a gap longer than this between credits means the process was frozen (suspend); nothing is counted for it + maxCreditGap = 2 * flushInterval + retentionDays = 92 + dateLayout = "2006-01-02" +) + +type DayUsage struct { + Date string `json:"date"` + Active int64 `json:"active"` + Apps map[string]int64 `json:"apps"` +} + +type Limits struct { + Daily int64 `json:"daily"` + Apps map[string]int64 `json:"apps"` +} + +type LimitEvent struct { + Kind string `json:"kind"` + AppID string `json:"appId,omitempty"` + Used int64 `json:"used"` + Limit int64 `json:"limit"` +} + +type State struct { + Today DayUsage `json:"today"` + Limit *LimitEvent `json:"limit,omitempty"` + Resync bool `json:"resync,omitempty"` +} + +type segment struct { + AppID string `json:"appId"` + Active bool `json:"active"` + Since int64 `json:"since"` +} + +type alertRecord struct { + Date string `json:"date"` + Keys []string `json:"keys"` +} + +type Manager struct { + mu sync.Mutex + db *bolt.DB + now func() time.Time + cur segment + seq int64 + limits Limits + limitsSet bool + alerted alertRecord + + subscribers syncmap.Map[string, chan State] + wake chan struct{} + stopChan chan struct{} + wg sync.WaitGroup +} diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 0b037d90a..296259a7a 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -487,6 +487,10 @@ Singleton { "id": "weather", "enabled": true }, + { + "id": "wellbeing", + "enabled": true + }, { "id": "notifications", "enabled": false @@ -528,6 +532,9 @@ Singleton { onDashCardsChanged: saveSettings() property var dashOptions: Spec.SPEC.dashOptions.def onDashOptionsChanged: saveSettings() + property bool wellbeingEnabled: Spec.SPEC.wellbeingEnabled.def + property int wellbeingDailyLimit: Spec.SPEC.wellbeingDailyLimit.def + property var wellbeingAppLimits: Spec.SPEC.wellbeingAppLimits.def function getDashTabs() { const stored = Array.isArray(dashTabs) ? dashTabs : []; diff --git a/quickshell/Common/SettingsTabs.qml b/quickshell/Common/SettingsTabs.qml index f9e2254b9..748feb49b 100644 --- a/quickshell/Common/SettingsTabs.qml +++ b/quickshell/Common/SettingsTabs.qml @@ -248,6 +248,14 @@ Singleton { "tabIndex": 18, "hint": I18n.tr("Volume, brightness, caps lock, position") }, + { + "id": "wellbeing", + "text": I18n.tr("Digital wellbeing"), + "icon": "digital_wellbeing", + "tabIndex": 67, + "aliases": ["screen_time"], + "hint": I18n.tr("Screen time and app limits") + }, { "id": "sound_media", "text": I18n.tr("Sound & media"), diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 8ece20b78..b61bebd34 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -735,6 +735,10 @@ var LOCAL_SPEC = { id: "weather", enabled: true }, + { + id: "wellbeing", + enabled: true + }, { id: "notifications", enabled: false @@ -773,6 +777,15 @@ var LOCAL_SPEC = { dashOptions: { def: {} }, + wellbeingEnabled: { + def: true + }, + wellbeingDailyLimit: { + def: 0 + }, + wellbeingAppLimits: { + def: {} + }, networkPreference: { def: "auto" }, diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index 9868be072..b556a12a6 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -257,6 +257,7 @@ Item { TrashService.count; WallpaperCyclingService.cyclingActive; ThemeAutoService.active; + WellbeingService.tracking; } Loader { diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index 4a0bc6883..f7916651c 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -243,6 +243,7 @@ FocusScope { "autostart": "AutoStartTab.qml", "battery": "BatteryTab.qml", "dank_dash": "DankDashTab.qml", + "wellbeing": "DigitalWellbeingTab.qml", "mouse_touchpad": "MouseTouchpadTab.qml", "keyboard": "KeyboardTab.qml", "plugins_manage": "PluginsManageTab.qml" diff --git a/quickshell/Modules/DankDash/DashRegistry.qml b/quickshell/Modules/DankDash/DashRegistry.qml index 8a67be034..5ab6d0f2d 100644 --- a/quickshell/Modules/DankDash/DashRegistry.qml +++ b/quickshell/Modules/DankDash/DashRegistry.qml @@ -172,6 +172,25 @@ Singleton { }, "options": [cardOnly(toggle("city", I18n.tr("Show city"), false)), cardOnly(toggle("readings", I18n.tr("Show readings"), true)), cardOnly(toneOption())] }, + { + "id": "wellbeing", + "text": I18n.tr("Digital wellbeing"), + "icon": "digital_wellbeing", + "description": SettingsData.wellbeingEnabled ? I18n.tr("Screen time and app limits") : I18n.tr("Hidden until screen time tracking is enabled"), + "available": SettingsData.wellbeingEnabled, + "tab": { + "component": wellbeingTab, + "async": true + }, + "card": { + "component": wellbeingCard, + "w": 2, + "h": 1, + "minW": 1, + "minH": 1, + "maxH": 3 + } + }, { "id": "notifications", "text": I18n.tr("Notifications"), @@ -716,6 +735,16 @@ Singleton { NotificationsOverviewCard {} } + Component { + id: wellbeingTab + WellbeingTab {} + } + + Component { + id: wellbeingCard + WellbeingOverviewCard {} + } + Component { id: clockCard ClockCard {} diff --git a/quickshell/Modules/DankDash/Overview/DashOptionsSheet.qml b/quickshell/Modules/DankDash/Overview/DashOptionsSheet.qml index 4f9082751..367ee84df 100644 --- a/quickshell/Modules/DankDash/Overview/DashOptionsSheet.qml +++ b/quickshell/Modules/DankDash/Overview/DashOptionsSheet.qml @@ -13,6 +13,11 @@ CcSheetDialog { property bool tabScope: false readonly property var entry: DashRegistry.entry(entryId) + readonly property var settingsPages: ({ + "weather": "weather", + "media": "media_player", + "wellbeing": "wellbeing" + }) readonly property var specs: DashRegistry.sheetOptionSpecs(entryId, tabScope) function presentFor(id) { @@ -44,14 +49,14 @@ CcSheetDialog { } SettingsNavRow { - visible: root.entryId === "weather" || root.entryId === "media" + visible: root.entryId in root.settingsPages width: parent.width - title: root.entryId === "media" ? I18n.tr("Media player") : I18n.tr("Weather") + title: root.entryId === "media" ? I18n.tr("Media player") : root.entry?.text ?? "" iconName: "settings" onClicked: { root.dismiss(); PopoutService.closeDankDash(); - PopoutService.openSettingsWithTab(root.entryId === "media" ? "media_player" : "weather"); + PopoutService.openSettingsWithTab(root.settingsPages[root.entryId]); } } diff --git a/quickshell/Modules/DankDash/Overview/WellbeingOverviewCard.qml b/quickshell/Modules/DankDash/Overview/WellbeingOverviewCard.qml new file mode 100644 index 000000000..53d44ee33 --- /dev/null +++ b/quickshell/Modules/DankDash/Overview/WellbeingOverviewCard.qml @@ -0,0 +1,122 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modules.DankDash +import qs.Modules.DankDash.Wellbeing +import qs.Modules.ControlCenter.Widgets +import "../Wellbeing/Wellbeing.js" as Wellbeing + +Card { + id: root + + property bool live: Window.window?.visible ?? false + + readonly property bool wide: width >= DashMetrics.gridRowUnit * 2 + readonly property bool tall: height >= DashMetrics.gridRowUnit * 2 + readonly property var today: WellbeingService.today + readonly property real appRowHeight: Theme.iconSizeMedium + Theme.spacingXS + readonly property int appRows: tall ? Math.min(WellbeingMetrics.cardApps, Math.floor((height - pad * 2 - header.height - value.height - Theme.spacingS * 2) / (appRowHeight + Theme.spacingXS))) : 0 + readonly property var apps: appRows > 0 ? Wellbeing.topApps([today], appRows) : [] + readonly property real limitSeconds: SettingsData.wellbeingDailyLimit * 60 + readonly property bool overLimit: limitSeconds > 0 && today.active >= limitSeconds + + entryId: "wellbeing" + clipContent: true + pad: Theme.spacingM + Accessible.name: I18n.tr("Screen time") + + Row { + id: header + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: Theme.spacingS + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: "digital_wellbeing" + size: Theme.iconSizeMedium + color: root.accentColor + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - Theme.iconSizeMedium - parent.spacing + text: I18n.tr("Screen time") + font.pixelSize: Theme.fontSizeMedium + font.weight: Theme.fontWeightMedium + color: root.contentColor + elide: Text.ElideRight + } + } + + StyledText { + id: value + anchors.left: parent.left + anchors.right: parent.right + y: root.apps.length > 0 ? header.height + Theme.spacingXS : parent.height - height + text: WellbeingService.formatDuration(root.today.active) + font.pixelSize: root.wide ? Theme.fontSizeXXLarge : DashMetrics.tileValueSizeCompact + font.weight: Theme.fontWeightMedium + font.features: ({ + "tnum": 1 + }) + color: root.overLimit ? Theme.error : root.contentColor + elide: Text.ElideRight + } + + Column { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: value.bottom + anchors.topMargin: Theme.spacingS + spacing: Theme.spacingXS + visible: root.apps.length > 0 + + Repeater { + model: root.apps + + Item { + id: appRow + required property var modelData + width: parent.width + height: root.appRowHeight + + CcAppIcon { + id: icon + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + appId: appRow.modelData.appId + iconSize: Theme.iconSizeMedium + } + + StyledText { + anchors.left: icon.right + anchors.leftMargin: Theme.spacingS + anchors.right: time.left + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + text: WellbeingService.appName(appRow.modelData.appId) + font.pixelSize: Theme.fontSizeSmall + color: root.contentColor + elide: Text.ElideRight + } + + StyledText { + id: time + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: WellbeingService.formatDuration(appRow.modelData.seconds) + font.pixelSize: Theme.fontSizeSmall + font.features: ({ + "tnum": 1 + }) + color: root.mutedColor + } + } + } + } +} diff --git a/quickshell/Modules/DankDash/Wellbeing/AppLimitSheet.qml b/quickshell/Modules/DankDash/Wellbeing/AppLimitSheet.qml new file mode 100644 index 000000000..141c30781 --- /dev/null +++ b/quickshell/Modules/DankDash/Wellbeing/AppLimitSheet.qml @@ -0,0 +1,41 @@ +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modules.Settings.Widgets +import qs.Modules.ControlCenter.Widgets +import qs.Modules.DankDash + +CcSheetDialog { + id: root + + property string appId: "" + readonly property int minutes: WellbeingService.appLimitMinutes(appId) + + function presentFor(id) { + appId = id; + present(); + } + + panelWidth: DashMetrics.optionSheetWidth + iconName: "timer" + title: WellbeingService.appName(appId) + subtitle: I18n.tr("Daily limit") + showScrollBar: false + + SettingsGroup { + width: parent.width + slotColor: Theme.chipSurface + + SettingsRow { + title: I18n.tr("Daily limit") + subtitle: root.minutes > 0 ? I18n.tr("Notifies when today's use of this app passes the limit") : I18n.tr("Off") + + DurationSteppers { + anchors.verticalCenter: parent.verticalCenter + minutes: root.minutes + onCommitted: next => WellbeingService.setAppLimit(root.appId, next) + } + } + } +} diff --git a/quickshell/Modules/DankDash/Wellbeing/AppUsageList.qml b/quickshell/Modules/DankDash/Wellbeing/AppUsageList.qml new file mode 100644 index 000000000..1db6fdc09 --- /dev/null +++ b/quickshell/Modules/DankDash/Wellbeing/AppUsageList.qml @@ -0,0 +1,223 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modules.DankDash +import qs.Modules.ControlCenter.Widgets +import "Wellbeing.js" as Wellbeing + +DankCard { + id: root + + property var days: [] + property int firstDayOfWeek: 1 + property string period: "week" + property bool showTitle: true + property bool fillHeight: true + + readonly property var periods: [ + { + "value": "day", + "text": I18n.tr("Day") + }, + { + "value": "week", + "text": I18n.tr("Week") + }, + { + "value": "month", + "text": I18n.tr("Month") + } + ] + readonly property var apps: Wellbeing.topApps(Wellbeing.periodDays(days, new Date(), firstDayOfWeek, period), WellbeingMetrics.listedApps) + readonly property real longest: apps.length > 0 ? apps[0].seconds : 0 + readonly property real listHeight: Math.max(1, apps.length) * WellbeingMetrics.appRowHeight + Math.max(0, apps.length - 1) * Theme.groupedListGap + + signal limitRequested(string appId) + + restRadius: DashMetrics.cardRadius + pad: Theme.spacingL + implicitHeight: pad * 2 + header.height + Theme.spacingM + listHeight + + Item { + id: header + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: Theme.buttonHeightXS + + StyledText { + anchors.left: parent.left + anchors.right: periodGroup.left + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + visible: root.showTitle + text: I18n.tr("Most used apps") + font.pixelSize: Theme.fontSizeLarge + font.weight: Theme.fontWeightMedium + color: root.contentColor + elide: Text.ElideRight + } + + DankButtonGroup { + id: periodGroup + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + size: "small" + checkEnabled: false + model: root.periods.map(p => p.text) + currentIndex: root.periods.findIndex(p => p.value === root.period) + onSelectionChanged: (index, selected) => { + if (selected) + root.period = root.periods[index].value; + } + } + } + + StyledText { + anchors.centerIn: listHost + width: listHost.width - Theme.spacingL * 2 + visible: root.apps.length === 0 + text: I18n.tr("Nothing tracked yet", "empty state of the screen time app list") + font.pixelSize: Theme.fontSizeMedium + color: root.mutedColor + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + + Loader { + id: listHost + anchors.left: parent.left + anchors.right: parent.right + anchors.top: header.bottom + anchors.topMargin: Theme.spacingM + height: root.fillHeight ? parent.height - y : root.listHeight + sourceComponent: root.fillHeight ? scrolling : stacked + } + + // a nested list view would take the wheel from the settings page + Component { + id: stacked + + Column { + spacing: Theme.groupedListGap + + Repeater { + model: root.apps + + AppRow { + width: parent.width + } + } + } + } + + Component { + id: scrolling + + DankListView { + clip: true + spacing: Theme.groupedListGap + showScrollBar: false + model: root.apps + + delegate: AppRow { + width: ListView.view.width + } + } + } + + component AppRow: DankListItem { + id: row + + required property var modelData + required property int index + + readonly property string appId: modelData.appId + readonly property int limitMinutes: WellbeingService.appLimitMinutes(appId) + readonly property bool overLimit: limitMinutes > 0 && modelData.seconds >= limitMinutes * 60 + + height: WellbeingMetrics.appRowHeight + surfaceColor: root.chipColor + firstInGroup: index === 0 + lastInGroup: index === root.apps.length - 1 + Accessible.name: WellbeingService.appName(appId) + onClicked: root.limitRequested(appId) + + CcAppIcon { + id: icon + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + appId: row.appId + iconSize: Theme.iconSizeLarge + } + + StyledText { + id: name + anchors.left: icon.right + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + width: WellbeingMetrics.appNameWidth + text: WellbeingService.appName(row.appId) + font.pixelSize: Theme.fontSizeMedium + font.weight: Theme.fontWeightMedium + color: row.contentColor + elide: Text.ElideRight + } + + Rectangle { + anchors.left: name.right + anchors.right: duration.left + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + height: DashMetrics.meterBarThickness + radius: Theme.fullRadius(width, height) + color: Theme.withAlpha(row.contentColor, Theme.stateLayerFocus) + + Rectangle { + width: root.longest > 0 ? parent.width * row.modelData.seconds / root.longest : 0 + height: parent.height + radius: parent.radius + color: row.overLimit ? Theme.error : Theme.primary + + Behavior on width { + enabled: DashMetrics.animationsEnabled + NumberAnimation { + duration: DashMetrics.meterDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.standard + } + } + } + } + + NumericText { + id: duration + anchors.right: limitButton.left + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + width: reservedWidth + horizontalAlignment: Text.AlignRight + reserveText: WellbeingService.formatDuration(10 * Wellbeing.secondsPerHour) + text: WellbeingService.formatDuration(row.modelData.seconds) + font.pixelSize: Theme.fontSizeMedium + color: row.overLimit ? Theme.error : row.contentColor + } + + DankActionButton { + id: limitButton + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + buttonSize: Theme.buttonHeightXS + iconName: row.limitMinutes > 0 ? "timer" : "timer_off" + iconColor: row.limitMinutes > 0 ? Theme.primary : row.supportingContentColor + tooltipText: row.limitMinutes > 0 ? I18n.tr("%1 min").arg(row.limitMinutes) : I18n.tr("Set limit", "button that opens the per-app screen time limit editor") + onClicked: root.limitRequested(row.appId) + } + } +} diff --git a/quickshell/Modules/DankDash/Wellbeing/DurationSteppers.qml b/quickshell/Modules/DankDash/Wellbeing/DurationSteppers.qml new file mode 100644 index 000000000..d992315aa --- /dev/null +++ b/quickshell/Modules/DankDash/Wellbeing/DurationSteppers.qml @@ -0,0 +1,36 @@ +import QtQuick +import qs.Common +import qs.Widgets + +Row { + id: root + + property int minutes: 0 + readonly property int hours: Math.floor(minutes / 60) + readonly property int remainder: minutes % 60 + readonly property int maxMinutes: 24 * 60 - 1 + + signal committed(int minutes) + + function commit(next) { + committed(Math.max(0, Math.min(maxMinutes, next))); + } + + spacing: Theme.spacingS + + DankNumberStepper { + text: I18n.tr("%1h").arg(root.hours) + incrementEnabled: root.minutes + 60 <= root.maxMinutes + decrementEnabled: root.hours > 0 + onIncrement: () => root.commit(root.minutes + 60) + onDecrement: () => root.commit(root.minutes - 60) + } + + DankNumberStepper { + text: I18n.tr("%1m").arg(root.remainder) + incrementEnabled: root.minutes < root.maxMinutes + decrementEnabled: root.minutes > 0 + onIncrement: () => root.commit(root.minutes + 1) + onDecrement: () => root.commit(root.minutes - 1) + } +} diff --git a/quickshell/Modules/DankDash/Wellbeing/WeeklyChart.qml b/quickshell/Modules/DankDash/Wellbeing/WeeklyChart.qml new file mode 100644 index 000000000..8f5d039da --- /dev/null +++ b/quickshell/Modules/DankDash/Wellbeing/WeeklyChart.qml @@ -0,0 +1,234 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Shapes +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modules.DankDash +import "Wellbeing.js" as Wellbeing + +DankCard { + id: root + + property var week: [] + property real limitSeconds: 0 + property bool showTitle: true + property int hoverIndex: -1 + + readonly property real total: Wellbeing.totalSeconds(week) + readonly property real peak: Math.max(limitSeconds, ...week.map(day => day.active)) + readonly property var axis: Wellbeing.axis(peak) + readonly property var hoverDay: hoverIndex >= 0 && hoverIndex < week.length ? week[hoverIndex] : null + readonly property real columnWidth: week.length > 0 ? plot.width / week.length : 0 + readonly property real barWidth: columnWidth * WellbeingMetrics.barWidthRatio + readonly property color pastColor: Theme.primaryContainer + readonly property color todayColor: Theme.primary + readonly property color overColor: Theme.error + + function barColor(day) { + if (limitSeconds > 0 && day.active > limitSeconds) + return overColor; + return day.today ? todayColor : pastColor; + } + + function yFor(seconds) { + return plot.height * (1 - Math.min(1, seconds / axis.top)); + } + + function dayLabel(day) { + return Qt.locale().dayName(day.weekday, Locale.ShortFormat); + } + + restRadius: DashMetrics.cardRadius + pad: Theme.spacingL + Accessible.role: Accessible.Chart + Accessible.name: I18n.tr("Weekly screen time") + + Column { + anchors.fill: parent + spacing: Theme.spacingS + + StyledText { + width: parent.width + visible: root.showTitle + text: I18n.tr("Weekly screen time") + font.pixelSize: Theme.fontSizeLarge + font.weight: Theme.fontWeightMedium + color: root.contentColor + elide: Text.ElideRight + } + + Row { + spacing: Theme.spacingS + + StyledText { + anchors.baseline: caption.baseline + text: WellbeingService.formatDuration(root.hoverDay ? root.hoverDay.active : root.total) + font.pixelSize: Theme.fontSizeXLarge + font.weight: Theme.fontWeightBold + font.features: ({ + "tnum": 1 + }) + color: root.accentColor + } + + StyledText { + id: caption + anchors.bottom: parent.bottom + anchors.bottomMargin: Theme.spacingXXS + text: root.hoverDay ? root.dayLabel(root.hoverDay) : I18n.tr("Total") + font.pixelSize: Theme.fontSizeSmall + font.weight: Theme.fontWeightMedium + font.capitalization: Font.AllUppercase + color: root.mutedColor + } + } + + Item { + width: parent.width + height: parent.height - y + LayoutMirroring.enabled: false + LayoutMirroring.childrenInherit: true + + Item { + id: plot + anchors.left: parent.left + anchors.right: axisLabels.left + anchors.rightMargin: Theme.spacingS + anchors.top: parent.top + anchors.topMargin: Theme.spacingS + anchors.bottom: dayLabels.top + anchors.bottomMargin: Theme.spacingXS + + Repeater { + model: root.axis.ticks + + Rectangle { + required property int modelData + width: plot.width + height: Theme.dividerWidth + y: root.yFor(modelData * Wellbeing.secondsPerHour) - height / 2 + color: Theme.outlineVariant + } + } + + Repeater { + model: root.week + + Rectangle { + id: bar + required property var modelData + required property int index + readonly property real targetHeight: Math.max(0, plot.height - root.yFor(modelData.active)) + x: index * root.columnWidth + (root.columnWidth - width) / 2 + y: plot.height - height + width: root.barWidth + height: targetHeight + visible: !modelData.future && modelData.active > 0 + topLeftRadius: Theme.cornerRadiusS + topRightRadius: Theme.cornerRadiusS + color: root.barColor(modelData) + opacity: root.hoverIndex === -1 || root.hoverIndex === index ? 1 : WellbeingMetrics.dimmedBarOpacity + + Behavior on height { + enabled: DashMetrics.animationsEnabled + NumberAnimation { + duration: DashMetrics.meterDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.standard + } + } + } + } + + Shape { + anchors.fill: parent + visible: root.limitSeconds > 0 + preferredRendererType: Shape.CurveRenderer + + ShapePath { + strokeColor: root.overColor + strokeWidth: Theme.outlineWidthFocused + strokeStyle: ShapePath.DashLine + dashPattern: [3, 3] + fillColor: "transparent" + capStyle: ShapePath.FlatCap + startX: 0 + startY: root.yFor(root.limitSeconds) + + PathLine { + x: plot.width + y: root.yFor(root.limitSeconds) + } + } + } + + StyledText { + anchors.right: parent.right + y: root.yFor(root.limitSeconds) - height - Theme.spacingXXS + visible: root.limitSeconds > 0 + text: I18n.tr("Limit", "label on the daily screen time limit line of a chart") + font.pixelSize: Theme.fontSizeSmall + font.weight: Theme.fontWeightBold + font.capitalization: Font.AllUppercase + color: root.overColor + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + hoverEnabled: true + onPositionChanged: mouse => root.hoverIndex = root.columnWidth > 0 ? Math.floor(mouse.x / root.columnWidth) : -1 + onExited: root.hoverIndex = -1 + } + } + + Item { + id: axisLabels + anchors.right: parent.right + anchors.top: plot.top + anchors.bottom: plot.bottom + width: WellbeingMetrics.axisLabelWidth + + Repeater { + model: root.axis.ticks + + StyledText { + required property int modelData + anchors.right: axisLabels.right + y: root.yFor(modelData * Wellbeing.secondsPerHour) - height / 2 + text: I18n.tr("%1h").arg(modelData) + font.pixelSize: Theme.fontSizeSmall + font.features: ({ + "tnum": 1 + }) + color: root.mutedColor + } + } + } + + Row { + id: dayLabels + anchors.left: plot.left + anchors.right: plot.right + anchors.bottom: parent.bottom + height: Theme.fontSizeSmall + Theme.spacingXS + + Repeater { + model: root.week + + StyledText { + required property var modelData + width: root.columnWidth + horizontalAlignment: Text.AlignHCenter + text: root.dayLabel(modelData) + font.pixelSize: Theme.fontSizeSmall + font.weight: modelData.today ? Theme.fontWeightBold : Theme.fontWeightMedium + color: modelData.today ? root.accentColor : root.mutedColor + } + } + } + } + } +} diff --git a/quickshell/Modules/DankDash/Wellbeing/Wellbeing.js b/quickshell/Modules/DankDash/Wellbeing/Wellbeing.js new file mode 100644 index 000000000..0ff87e50a --- /dev/null +++ b/quickshell/Modules/DankDash/Wellbeing/Wellbeing.js @@ -0,0 +1,113 @@ +.pragma library + +const secondsPerHour = 3600; +const weekLength = 7; +const monthLength = 30; + +function dateKey(date) { + const pad = n => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +function parseKey(key) { + const [year, month, day] = String(key).split("-").map(Number); + return new Date(year, month - 1, day); +} + +function emptyDay(key) { + return { + date: key, + active: 0, + apps: {} + }; +} + +function byDate(days) { + const map = {}; + for (const day of Array.isArray(days) ? days : []) { + if (day && typeof day.date === "string") + map[day.date] = day; + } + return map; +} + +function weekDays(days, today, firstDayOfWeek) { + const map = byDate(days); + const todayKey = dateKey(today); + const offset = (today.getDay() - firstDayOfWeek + weekLength) % weekLength; + const result = []; + for (let i = 0; i < weekLength; i++) { + const date = new Date(today.getFullYear(), today.getMonth(), today.getDate() - offset + i); + const key = dateKey(date); + result.push({ + date: key, + weekday: date.getDay(), + active: map[key]?.active ?? 0, + apps: map[key]?.apps ?? {}, + today: key === todayKey, + future: key > todayKey + }); + } + return result; +} + +function periodDays(days, today, firstDayOfWeek, period) { + switch (period) { + case "day": + return weekDays(days, today, firstDayOfWeek).filter(day => day.today); + case "week": + return weekDays(days, today, firstDayOfWeek).filter(day => !day.future); + default: + return Array.isArray(days) ? days.slice(-monthLength) : []; + } +} + +function totalSeconds(days) { + return days.reduce((sum, day) => sum + (day?.active ?? 0), 0); +} + +function topApps(days, limit) { + const totals = {}; + for (const day of days) { + const apps = day?.apps ?? {}; + for (const appId in apps) { + if (apps[appId] > 0) + totals[appId] = (totals[appId] ?? 0) + apps[appId]; + } + } + return Object.keys(totals).map(appId => ({ + appId, + seconds: totals[appId] + })).sort((a, b) => b.seconds - a.seconds || a.appId.localeCompare(b.appId)).slice(0, limit ?? Infinity); +} + +function axisStepHours(hours) { + if (hours <= 4) + return 1; + if (hours <= 8) + return 2; + if (hours <= 16) + return 4; + return 6; +} + +function axis(peakSeconds) { + const hours = Math.max(1, Math.ceil(peakSeconds / secondsPerHour)); + const step = axisStepHours(hours); + const top = Math.ceil(hours / step) * step; + const ticks = []; + for (let hour = 0; hour <= top; hour += step) + ticks.push(hour); + return { + top: top * secondsPerHour, + ticks + }; +} + +function splitDuration(seconds) { + const total = Math.max(0, Math.round(seconds)); + return { + hours: Math.floor(total / secondsPerHour), + minutes: Math.floor((total % secondsPerHour) / 60) + }; +} diff --git a/quickshell/Modules/DankDash/Wellbeing/WellbeingMetrics.qml b/quickshell/Modules/DankDash/Wellbeing/WellbeingMetrics.qml new file mode 100644 index 000000000..38a59e883 --- /dev/null +++ b/quickshell/Modules/DankDash/Wellbeing/WellbeingMetrics.qml @@ -0,0 +1,19 @@ +pragma Singleton + +import QtQuick +import Quickshell +import qs.Common +import qs.Modules.DankDash + +Singleton { + readonly property real chartHeight: DashMetrics.gridRowUnit * 2.5 + readonly property real barWidthRatio: 0.72 + readonly property real dimmedBarOpacity: 0.55 + readonly property real axisLabelWidth: 28 + readonly property real appNameWidth: 120 + readonly property real appRowHeight: Theme.listItemHeight + readonly property int listedApps: 12 + readonly property int cardApps: 3 + readonly property int defaultLimitMinutes: 60 + readonly property int retentionDays: 92 +} diff --git a/quickshell/Modules/DankDash/WellbeingTab.qml b/quickshell/Modules/DankDash/WellbeingTab.qml new file mode 100644 index 000000000..7c4ab0003 --- /dev/null +++ b/quickshell/Modules/DankDash/WellbeingTab.qml @@ -0,0 +1,95 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modules.DankDash +import qs.Modules.DankDash.Wellbeing +import qs.Modules.ControlCenter.Widgets +import "Wellbeing/Wellbeing.js" as Wellbeing + +FocusScope { + id: root + + property string entryId: "wellbeing" + property bool live: Window.window?.visible ?? false + property var transientSurfaceTracker: null + property var days: [] + + readonly property int firstDayOfWeek: SettingsData.firstDayOfWeek >= 7 || SettingsData.firstDayOfWeek < 0 ? Qt.locale().firstDayOfWeek : SettingsData.firstDayOfWeek + readonly property var week: Wellbeing.weekDays(days, new Date(), firstDayOfWeek) + readonly property int revision: WellbeingService.revision + readonly property Item focusTarget: root + readonly property var menuActions: [ + { + label: I18n.tr("Settings"), + iconName: "settings", + action: () => { + PopoutService.closeDankDash(); + PopoutService.openSettingsWithTab("wellbeing"); + } + } + ] + + implicitHeight: DashMetrics.tabMinHeight + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + onLiveChanged: { + if (live) + refresh(); + else + limitSheet.dismiss(); + } + onRevisionChanged: { + if (live) + refresh(); + } + + function refresh() { + WellbeingService.summary(result => root.days = result); + } + + function handleKeyEvent(event) { + if (event.key !== Qt.Key_Escape || !limitSheet.shown) + return false; + limitSheet.dismiss(); + return true; + } + + CcEmptyState { + anchors.centerIn: parent + visible: !WellbeingService.available + iconName: "digital_wellbeing" + title: I18n.tr("DMS out of date") + subtitle: I18n.tr("Update the dms package with your package manager, then restart the shell.") + } + + Column { + id: content + anchors.fill: parent + spacing: DashMetrics.contentGap + visible: WellbeingService.available + + WeeklyChart { + width: parent.width + height: WellbeingMetrics.chartHeight + week: root.week + limitSeconds: SettingsData.wellbeingDailyLimit * 60 + } + + AppUsageList { + width: parent.width + height: parent.height - y + days: root.days + firstDayOfWeek: root.firstDayOfWeek + onLimitRequested: appId => limitSheet.presentFor(appId) + } + } + + AppLimitSheet { + id: limitSheet + backdrop: content + } +} diff --git a/quickshell/Modules/Notifications/Popup/NotificationPopup.qml b/quickshell/Modules/Notifications/Popup/NotificationPopup.qml index fe3a7f82b..5c854fd97 100644 --- a/quickshell/Modules/Notifications/Popup/NotificationPopup.qml +++ b/quickshell/Modules/Notifications/Popup/NotificationPopup.qml @@ -213,7 +213,7 @@ PanelWindow { readonly property bool contentAnchorsTop: isTopCenter || SettingsData.notificationPopupPosition === SettingsData.Position.Top || SettingsData.notificationPopupPosition === SettingsData.Position.Left readonly property real renderedContentOffsetY: contentAnchorsTop ? 0 : Math.max(0, allocatedAlignedHeight - renderedAlignedHeight) readonly property real contentTop: Theme.snap(windowShadowPad + renderedContentOffsetY, dpr) - implicitWidth: contentImplicitWidth + (windowShadowPad * 2) + implicitWidth: contentImplicitWidth + (windowShadowPad * 2) + edgeBleed implicitHeight: allocatedAlignedHeight + (windowShadowPad * 2) function syncInlineTargetHeight() { @@ -330,6 +330,14 @@ PanelWindow { readonly property real maxPopupShadowOffsetYPx: Math.max(Math.abs(Theme.elevationOffsetY(Theme.elevationLevel3, 6)), Math.abs(Theme.elevationOffsetY(Theme.elevationLevel4, 8))) readonly property bool popupWindowShadowActive: Theme.elevationEnabled && SettingsData.notificationPopupShadowEnabled && !connectedFrameMode readonly property real windowShadowPad: popupWindowShadowActive ? Theme.snap(Math.max(16, maxPopupShadowBlurPx + Math.max(maxPopupShadowOffsetXPx, maxPopupShadowOffsetYPx) + 8), dpr) : 0 + readonly property bool bleedsLeft: _frameEdgeSwipeDirection() < 0 + readonly property real edgeBleed: { + if (isCenterPosition || !CompositorService.frameWindowVisibleForScreen(screen)) + return 0; + const sideMargin = bleedsLeft ? getLeftMargin() : getRightMargin(); + return Math.max(0, Theme.snap(sideMargin, dpr) - windowShadowPad); + } + readonly property real contentWindowX: Theme.snap(windowShadowPad + (bleedsLeft ? edgeBleed : 0), dpr) anchors.top: true anchors.left: true @@ -492,7 +500,7 @@ PanelWindow { function getWindowLeftMargin() { if (!screen) return 0; - return Theme.snap(getContentX() - windowShadowPad, dpr); + return Theme.snap(getContentX() - contentWindowX, dpr); } function getWindowTopMargin() { @@ -526,7 +534,7 @@ PanelWindow { readonly property bool screenValid: win.screen && !_isDestroying readonly property real dpr: screenValid ? CompositorService.getScreenScale(win.screen) : 1 - readonly property real alignedWidth: Theme.px(Math.max(0, implicitWidth - (windowShadowPad * 2)), dpr) + readonly property real alignedWidth: Theme.px(Math.max(0, implicitWidth - (windowShadowPad * 2) - edgeBleed), dpr) readonly property real alignedHeight: renderedAlignedHeight onScreenYChanged: if (connectedFrameMode) popupChromeGeometryChanged() @@ -564,7 +572,7 @@ PanelWindow { id: content parent: slideClip - x: Theme.snap(windowShadowPad, dpr) + x: win.contentWindowX y: win.contentTop - slideClip.y width: alignedWidth height: alignedHeight diff --git a/quickshell/Modules/Settings/DankDashTab.qml b/quickshell/Modules/Settings/DankDashTab.qml index 9fb7deb53..5fdbeb015 100644 --- a/quickshell/Modules/Settings/DankDashTab.qml +++ b/quickshell/Modules/Settings/DankDashTab.qml @@ -44,6 +44,19 @@ FocusScope { } } + SettingsCard { + title: I18n.tr("Digital wellbeing") + + SettingsNavRow { + tab: "dank_dash" + settingKey: "dashWellbeingSettings" + title: I18n.tr("Digital wellbeing") + hint: I18n.tr("Screen time and app limits") + iconName: "digital_wellbeing" + onClicked: root.parentModal?.navigateTo("wellbeing") + } + } + SettingsCard { title: I18n.tr("Tabs", "noun, card title for dashboard tabs") settingKey: "dashTabs" diff --git a/quickshell/Modules/Settings/DigitalWellbeingTab.qml b/quickshell/Modules/Settings/DigitalWellbeingTab.qml new file mode 100644 index 000000000..c43d1d176 --- /dev/null +++ b/quickshell/Modules/Settings/DigitalWellbeingTab.qml @@ -0,0 +1,198 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modals.Common +import qs.Modules.Settings.Widgets +import qs.Modules.ControlCenter.Widgets +import qs.Modules.DankDash.Wellbeing +import "../DankDash/Wellbeing/Wellbeing.js" as Wellbeing + +Item { + id: root + + property var parentModal: null + property var days: [] + + readonly property var limits: SettingsData.wellbeingAppLimits ?? {} + readonly property var limitedApps: Object.keys(limits).filter(appId => Number(limits[appId]) > 0).sort((a, b) => WellbeingService.appName(a).localeCompare(WellbeingService.appName(b))) + readonly property bool available: WellbeingService.available + readonly property int revision: WellbeingService.revision + readonly property int firstDayOfWeek: SettingsData.firstDayOfWeek >= 7 || SettingsData.firstDayOfWeek < 0 ? Qt.locale().firstDayOfWeek : SettingsData.firstDayOfWeek + + onAvailableChanged: refresh() + onRevisionChanged: refresh() + Component.onCompleted: refresh() + + function toggleLimit(appId) { + WellbeingService.setAppLimit(appId, WellbeingService.appLimitMinutes(appId) > 0 ? 0 : WellbeingMetrics.defaultLimitMinutes); + } + + function refresh() { + if (!available) + return; + WellbeingService.summary(result => root.days = result); + } + + ConfirmModal { + id: clearConfirm + } + + SettingsPage { + SettingsCard { + width: parent.width + visible: root.available && SettingsData.wellbeingEnabled + iconName: "show_chart" + title: I18n.tr("Weekly screen time") + settingKey: "wellbeingChart" + tab: "wellbeing" + tags: ["wellbeing", "screen time", "chart", "week"] + + WeeklyChart { + height: WellbeingMetrics.chartHeight + color: "transparent" + pad: 0 + showTitle: false + week: Wellbeing.weekDays(root.days, new Date(), root.firstDayOfWeek) + limitSeconds: SettingsData.wellbeingDailyLimit * 60 + } + } + + SettingsCard { + width: parent.width + visible: root.available && SettingsData.wellbeingEnabled + iconName: "apps" + title: I18n.tr("Most used apps") + settingKey: "wellbeingApps" + tab: "wellbeing" + tags: ["wellbeing", "screen time", "apps", "usage"] + + AppUsageList { + height: implicitHeight + color: "transparent" + pad: 0 + showTitle: false + fillHeight: false + days: root.days + firstDayOfWeek: root.firstDayOfWeek + onLimitRequested: appId => root.toggleLimit(appId) + } + } + + SettingsCard { + width: parent.width + iconName: "digital_wellbeing" + title: I18n.tr("Screen time") + settingKey: "wellbeing" + tab: "wellbeing" + tags: ["wellbeing", "screen time", "usage", "limit"] + + SettingsToggleRow { + settingKey: "wellbeingEnabled" + tab: "wellbeing" + tags: ["wellbeing", "screen time", "tracking"] + text: I18n.tr("Track screen time") + description: I18n.tr("Counts time in the focused app while the session is active. Stays on this device.") + checked: SettingsData.wellbeingEnabled + onToggled: checked => SettingsData.set("wellbeingEnabled", checked) + } + + SettingsRow { + settingKey: "wellbeingDailyLimit" + tab: "wellbeing" + tags: ["wellbeing", "screen time", "limit", "daily"] + title: I18n.tr("Daily limit") + subtitle: SettingsData.wellbeingDailyLimit > 0 ? I18n.tr("Notifies when today's screen time passes the limit") : I18n.tr("Off") + + DurationSteppers { + anchors.verticalCenter: parent.verticalCenter + minutes: SettingsData.wellbeingDailyLimit + onCommitted: next => SettingsData.set("wellbeingDailyLimit", next) + } + } + } + + SettingsCard { + width: parent.width + iconName: "timer" + title: I18n.tr("App limits") + settingKey: "wellbeingAppLimits" + tab: "wellbeing" + tags: ["wellbeing", "screen time", "limit", "app"] + + SettingsRow { + visible: root.limitedApps.length === 0 + title: I18n.tr("No app limits") + subtitle: I18n.tr("Pick an app from the list above or the dashboard tab") + } + + Repeater { + model: root.limitedApps + + SettingsRow { + id: limitRow + + required property string modelData + + settingKey: "wellbeingAppLimits:" + modelData + resetKeys: [] + title: WellbeingService.appName(modelData) + + leading: CcAppIcon { + appId: limitRow.modelData + iconSize: Theme.iconSizeLarge + } + + DurationSteppers { + anchors.verticalCenter: parent.verticalCenter + minutes: WellbeingService.appLimitMinutes(limitRow.modelData) + onCommitted: next => WellbeingService.setAppLimit(limitRow.modelData, next) + } + + DankActionButton { + anchors.verticalCenter: parent.verticalCenter + iconName: "close" + iconColor: Theme.surfaceVariantText + Accessible.name: I18n.tr("Remove") + onClicked: WellbeingService.setAppLimit(limitRow.modelData, 0) + } + } + } + } + + SettingsCard { + width: parent.width + iconName: "history" + title: I18n.tr("History") + settingKey: "wellbeingHistory" + tab: "wellbeing" + tags: ["wellbeing", "screen time", "history", "clear"] + + SettingsRow { + title: I18n.tr("Screen time history") + subtitle: I18n.tr("Kept for %1 days on this device").arg(WellbeingMetrics.retentionDays) + + DankButton { + anchors.verticalCenter: parent.verticalCenter + buttonHeight: Theme.buttonHeightXS + text: I18n.tr("Clear") + backgroundColor: Theme.errorContainer + textColor: Theme.error + enabled: root.available + onClicked: clearConfirm.showWithOptions({ + "title": I18n.tr("Clear History?"), + "message": I18n.tr("Deletes all stored screen time"), + "confirmText": I18n.tr("Clear"), + "confirmColor": Theme.error, + "onConfirm": () => { + WellbeingService.clearHistory(); + root.days = []; + } + }) + } + } + } + } +} diff --git a/quickshell/Services/DMSService.qml b/quickshell/Services/DMSService.qml index b4b57a8a9..0f0bc9c52 100644 --- a/quickshell/Services/DMSService.qml +++ b/quickshell/Services/DMSService.qml @@ -74,6 +74,7 @@ Singleton { signal locationStateUpdate(var data) signal sysupdateStateUpdate(var data) signal tailscaleStateUpdate(var data) + signal wellbeingStateUpdate(var data) signal filesEvent(var data) property bool capsLockState: false @@ -351,6 +352,8 @@ Singleton { sysupdateStateUpdate(data); } else if (service === "tailscale") { tailscaleStateUpdate(data); + } else if (service === "wellbeing") { + wellbeingStateUpdate(data); } else if (service === "files") { filesEvent(data); } diff --git a/quickshell/Services/WellbeingService.qml b/quickshell/Services/WellbeingService.qml new file mode 100644 index 000000000..4c2812576 --- /dev/null +++ b/quickshell/Services/WellbeingService.qml @@ -0,0 +1,191 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Common +import qs.Services +import "../Modules/DankDash/Wellbeing/Wellbeing.js" as Wellbeing + +Singleton { + id: root + + readonly property int idleTimeoutSeconds: 300 + readonly property int summaryDays: Wellbeing.monthLength + readonly property var ownAppIds: ["com.danklinux.dms", "org.quickshell", "quickshell"] + readonly property bool available: DMSService.isConnected && DMSService.capabilities.includes("wellbeing") + readonly property bool enabled: SettingsData.wellbeingEnabled + readonly property bool tracking: available && enabled + readonly property bool sessionActive: !idleMonitor.isIdle && !IdleService.isShellLocked && !SessionService.locked + readonly property string pushedState: tracking ? focusedAppId + "\n" + sessionActive : "" + readonly property var limitParams: { + const apps = {}; + const stored = SettingsData.wellbeingAppLimits ?? {}; + for (const appId in stored) { + const minutes = Number(stored[appId]); + if (minutes > 0) + apps[appId] = minutes * 60; + } + return { + "daily": Math.max(0, Number(SettingsData.wellbeingDailyLimit) || 0) * 60, + "apps": apps + }; + } + readonly property Toplevel managerActiveToplevel: ToplevelManager.activeToplevel + + property string focusedAppId: "" + property var today: Wellbeing.emptyDay("") + property int revision: 0 + property real seq: 0 + + IdleMonitor { + id: idleMonitor + timeout: root.idleTimeoutSeconds + respectInhibitors: true + enabled: root.tracking + } + + onManagerActiveToplevelChanged: updateFocusedApp() + onPushedStateChanged: pushState() + onTrackingChanged: ensureSubscription() + onLimitParamsChanged: pushLimits() + + Component.onCompleted: { + updateFocusedApp(); + ensureSubscription(); + } + + Connections { + target: CompositorService + + function onToplevelsChanged() { + root.updateFocusedApp(); + } + + function onWorkspaceStateChanged() { + root.updateFocusedApp(); + } + } + + Connections { + target: DMSService + + function onConnectionStateChanged() { + if (!DMSService.isConnected) + return; + root.ensureSubscription(); + root.pushLimits(); + root.pushState(); + } + + function onWellbeingStateUpdate(data) { + root.handleStateUpdate(data); + } + } + + function updateFocusedApp() { + const window = CompositorService.activeWindowForScreen(null, null, false); + const appId = window?.appId ?? ""; + focusedAppId = ownAppIds.includes(appId) ? "" : appId; + } + + function ensureSubscription() { + const subscribed = DMSService.activeSubscriptions.includes("wellbeing"); + if (!enabled) { + if (subscribed) + DMSService.removeSubscription("wellbeing"); + return; + } + if (!available || subscribed || DMSService.activeSubscriptions.includes("all")) + return; + DMSService.addSubscription("wellbeing"); + } + + function pushState() { + if (!available) + return; + seq = Math.max(Date.now(), seq + 1); + DMSService.sendRequest("wellbeing.setState", { + "appId": tracking ? focusedAppId : "", + "active": tracking && sessionActive, + "seq": seq + }, null); + } + + function pushLimits() { + if (!available) + return; + DMSService.sendRequest("wellbeing.setLimits", limitParams, null); + } + + function handleStateUpdate(data) { + if (!data) + return; + if (data.today) { + today = data.today; + revision++; + } + if (data.resync) { + pushLimits(); + pushState(); + } + if (data.limit) + notifyLimit(data.limit); + } + + function notifyLimit(limit) { + const summary = limit.kind === "daily" ? I18n.tr("Daily screen time limit reached") : I18n.tr("%1 limit reached", "screen time notification title, %1 is an app name").arg(appName(limit.appId)); + DMSService.notifySend({ + "summary": summary, + "body": formatDuration(limit.used) + " ยท " + I18n.tr("Today"), + "icon": "preferences-system-time" + }, null); + } + + function summary(callback) { + if (!available) { + callback([]); + return; + } + DMSService.sendRequest("wellbeing.summary", { + "days": summaryDays + }, response => callback(response?.result?.days ?? [])); + } + + function clearHistory() { + if (!available) + return; + DMSService.sendRequest("wellbeing.clear", null, null); + } + + function appLimitMinutes(appId) { + return Math.max(0, Number((SettingsData.wellbeingAppLimits ?? {})[appId]) || 0); + } + + function setAppLimit(appId, minutes) { + if (!appId) + return; + const limits = Object.assign({}, SettingsData.wellbeingAppLimits ?? {}); + if (minutes > 0) + limits[appId] = minutes; + else + delete limits[appId]; + SettingsData.set("wellbeingAppLimits", limits); + } + + function desktopEntry(appId) { + return appId ? DesktopEntries.heuristicLookup(Paths.moddedAppId(appId)) : null; + } + + function appName(appId) { + return Paths.getAppName(appId, desktopEntry(appId)); + } + + function formatDuration(seconds) { + const split = Wellbeing.splitDuration(seconds); + if (split.hours === 0) + return I18n.tr("%1m").arg(split.minutes); + return I18n.tr("%1h %2m").arg(split.hours).arg(split.minutes); + } +} diff --git a/quickshell/tests/wellbeing-model.test.mjs b/quickshell/tests/wellbeing-model.test.mjs new file mode 100644 index 000000000..84effb1b3 --- /dev/null +++ b/quickshell/tests/wellbeing-model.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { loadScript } from "./qml-script.mjs"; + +const model = loadScript(new URL("../Modules/DankDash/Wellbeing/Wellbeing.js", import.meta.url)); +const plain = value => JSON.parse(JSON.stringify(value)); + +const days = [ + { date: "2026-09-27", active: 100, apps: { firefox: 100 } }, + { date: "2026-09-29", active: 200, apps: { firefox: 50, kitty: 150 } }, + { date: "2026-10-01", active: 300, apps: { kitty: 300 } } +]; +const thursday = new Date(2026, 9, 1, 15, 0, 0); + +test("week starts on the configured weekday and marks today and future days", () => { + const week = plain(model.weekDays(days, thursday, 1)); + assert.deepEqual(week.map(day => day.date), ["2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01", "2026-10-02", "2026-10-03", "2026-10-04"]); + assert.deepEqual(week.map(day => day.active), [0, 200, 0, 300, 0, 0, 0]); + assert.deepEqual(week.map(day => day.today), [false, false, false, true, false, false, false]); + assert.deepEqual(week.map(day => day.future), [false, false, false, false, true, true, true]); + + const sundayStart = plain(model.weekDays(days, thursday, 0)); + assert.equal(sundayStart[0].date, "2026-09-27"); + assert.equal(sundayStart[0].active, 100); +}); + +test("periods pick today, the week so far, or the whole month", () => { + assert.deepEqual(plain(model.periodDays(days, thursday, 1, "day")).map(day => day.date), ["2026-10-01"]); + assert.deepEqual(plain(model.periodDays(days, thursday, 1, "week")).map(day => day.date), ["2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01"]); + assert.deepEqual(plain(model.periodDays(days, thursday, 1, "month")).map(day => day.date), ["2026-09-27", "2026-09-29", "2026-10-01"]); +}); + +test("top apps aggregate across days, sort by time, and honour the limit", () => { + assert.deepEqual(plain(model.topApps(days)), [{ appId: "kitty", seconds: 450 }, { appId: "firefox", seconds: 150 }]); + assert.deepEqual(plain(model.topApps(days, 1)), [{ appId: "kitty", seconds: 450 }]); + assert.deepEqual(plain(model.topApps([{ date: "2026-10-01", active: 5, apps: { idle: 0 } }])), []); +}); + +test("axis rounds the peak up to whole-hour ticks", () => { + assert.deepEqual(plain(model.axis(0)), { top: 3600, ticks: [0, 1] }); + assert.deepEqual(plain(model.axis(3 * 3600 + 1)), { top: 4 * 3600, ticks: [0, 1, 2, 3, 4] }); + assert.deepEqual(plain(model.axis(5 * 3600)), { top: 6 * 3600, ticks: [0, 2, 4, 6] }); + assert.deepEqual(plain(model.axis(13 * 3600)), { top: 16 * 3600, ticks: [0, 4, 8, 12, 16] }); +}); + +test("durations split into hours and whole minutes", () => { + assert.deepEqual(plain(model.splitDuration(3725)), { hours: 1, minutes: 2 }); + assert.deepEqual(plain(model.splitDuration(59)), { hours: 0, minutes: 0 }); + assert.deepEqual(plain(model.splitDuration(-5)), { hours: 0, minutes: 0 }); +}); diff --git a/quickshell/translations/extract_settings_index.py b/quickshell/translations/extract_settings_index.py index 4a38ccd1d..9df6c5c52 100644 --- a/quickshell/translations/extract_settings_index.py +++ b/quickshell/translations/extract_settings_index.py @@ -101,6 +101,7 @@ TAB_INDEX_MAP = { "KeybindsTab.qml": 2, "DankBarTab.qml": 3, "DankDashTab.qml": 43, + "DigitalWellbeingTab.qml": 67, "CompositorLayoutTab.qml": 37, "WindowRulesTab.qml": 38, "DockGeneralTab.qml": 5, diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 5a3d2457b..085e59708 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -13458,6 +13458,18 @@ "icon": "space_dashboard", "description": "Tabs and weather" }, + { + "section": "dashWellbeingSettings", + "label": "Digital wellbeing", + "tabIndex": 43, + "category": "Dashboard", + "keywords": [ + "dashboard", + "digital", + "wellbeing" + ], + "icon": "digital_wellbeing" + }, { "section": "dashTabsEvenlySpaced", "label": "Evenly space tabs", @@ -17401,5 +17413,141 @@ ], "parentLabel": "System & integrations", "icon": "cloud_off" + }, + { + "section": "wellbeingAppLimits", + "label": "App limits", + "tabIndex": 67, + "category": "Digital wellbeing", + "keywords": [ + "app", + "digital", + "limit", + "limits", + "screen time", + "wellbeing" + ], + "icon": "timer" + }, + { + "section": "wellbeingDailyLimit", + "label": "Daily limit", + "tabIndex": 67, + "category": "Digital wellbeing", + "keywords": [ + "daily", + "digital", + "limit", + "screen time", + "wellbeing" + ] + }, + { + "section": "_tab_67", + "label": "Digital wellbeing", + "tabIndex": 67, + "category": "Digital wellbeing", + "keywords": [ + "digital", + "limits", + "screen", + "time", + "wellbeing" + ], + "icon": "digital_wellbeing", + "description": "Screen time and app limits" + }, + { + "section": "wellbeingHistory", + "label": "History", + "tabIndex": 67, + "category": "Digital wellbeing", + "keywords": [ + "clear", + "digital", + "history", + "screen time", + "wellbeing" + ], + "icon": "history" + }, + { + "section": "wellbeingApps", + "label": "Most used apps", + "tabIndex": 67, + "category": "Digital wellbeing", + "keywords": [ + "apps", + "digital", + "most", + "screen time", + "usage", + "wellbeing" + ], + "icon": "apps" + }, + { + "section": "wellbeing", + "label": "Screen time", + "tabIndex": 67, + "category": "Digital wellbeing", + "keywords": [ + "active", + "counts", + "device", + "digital", + "focused", + "limit", + "screen", + "screen time", + "session", + "stays", + "time", + "usage", + "wellbeing", + "while" + ], + "icon": "digital_wellbeing", + "description": "Counts time in the focused app while the session is active. Stays on this device." + }, + { + "section": "wellbeingEnabled", + "label": "Track screen time", + "tabIndex": 67, + "category": "Digital wellbeing", + "keywords": [ + "active", + "counts", + "device", + "digital", + "focused", + "screen", + "screen time", + "session", + "stays", + "time", + "track", + "tracking", + "wellbeing", + "while" + ], + "description": "Counts time in the focused app while the session is active. Stays on this device." + }, + { + "section": "wellbeingChart", + "label": "Weekly screen time", + "tabIndex": 67, + "category": "Digital wellbeing", + "keywords": [ + "chart", + "digital", + "screen", + "screen time", + "time", + "week", + "weekly", + "wellbeing" + ], + "icon": "show_chart" } ]