mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-16 08:14:03 -04:00
wellbeing: add Digital Wellbeing, with a built-in dash plugin
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 : [];
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -257,6 +257,7 @@ Item {
|
||||
TrashService.count;
|
||||
WallpaperCyclingService.cyclingActive;
|
||||
ThemeAutoService.active;
|
||||
WellbeingService.tracking;
|
||||
}
|
||||
|
||||
Loader {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user