wellbeing: add Digital Wellbeing, with a built-in dash plugin

This commit is contained in:
bbedward committed 2026-10-01 15:15:37 -04:00
1 parent a348d8bc5e
commit d77c4f6298
31 files changed
+2439 -8

No files matched your search

@@ -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 = [];
}
})
}
}
}
}
}