|
|
|
@@ -1,62 +1,219 @@
|
|
|
|
|
// The notifications tab: what interrupts, how long it stays, and which
|
|
|
|
|
// applications are allowed to do it.
|
|
|
|
|
//
|
|
|
|
|
// The application list used to be every application that had ever notified,
|
|
|
|
|
// rendered in full, forever. On a machine that has been running for a year that
|
|
|
|
|
// is a wall nobody reads. It is now bounded: recent senders and applications
|
|
|
|
|
// whose rule has been changed sit up top, and everything else waits behind a
|
|
|
|
|
// searchable expander. Rows unfold in place into the rule itself.
|
|
|
|
|
//
|
|
|
|
|
// Focus modes moved to their own tab. What stays here is the one line saying a
|
|
|
|
|
// mode is currently quieting things, because that is the question this page is
|
|
|
|
|
// opened to answer.
|
|
|
|
|
|
|
|
|
|
import QtQuick
|
|
|
|
|
import qs.config
|
|
|
|
|
import qs.services
|
|
|
|
|
import qs.modules.clipboard
|
|
|
|
|
|
|
|
|
|
SettingsPage {
|
|
|
|
|
id: root
|
|
|
|
|
|
|
|
|
|
// Only one application is expanded at a time; the point is a card you
|
|
|
|
|
// can read, not twenty open at once.
|
|
|
|
|
// One application is unfolded at a time; the point is a card you can read,
|
|
|
|
|
// not twenty open at once.
|
|
|
|
|
property string expandedApp: ""
|
|
|
|
|
property bool allAppsOpen: false
|
|
|
|
|
|
|
|
|
|
// Which focus mode is open for editing. One at a time, like the app rules.
|
|
|
|
|
property string expandedMode: ""
|
|
|
|
|
readonly property int weekMs: 7 * 24 * 60 * 60 * 1000
|
|
|
|
|
|
|
|
|
|
// Which mode's exception list is open. Separate from expandedMode so the
|
|
|
|
|
// list does not unfold every time a mode is opened to change a switch.
|
|
|
|
|
property string expandedAllow: ""
|
|
|
|
|
|
|
|
|
|
function toggleAllowed(mode: var, appId: string, allowed: bool): void {
|
|
|
|
|
const current = Array.isArray(mode.allow) ? mode.allow.map(String) : [];
|
|
|
|
|
const at = current.indexOf(appId);
|
|
|
|
|
if (allowed && at < 0)
|
|
|
|
|
current.push(appId);
|
|
|
|
|
else if (!allowed && at >= 0)
|
|
|
|
|
current.splice(at, 1);
|
|
|
|
|
FocusModes.update(String(mode.id), { allow: current });
|
|
|
|
|
// Every rule write leaves through here, so a change made in a row goes down
|
|
|
|
|
// exactly the same path as one made anywhere else.
|
|
|
|
|
function editRule(appId: string, patch: var): void {
|
|
|
|
|
Notifs.setAppRule(appId, patch);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Schedule edits go through the mode's trigger list rather than replacing
|
|
|
|
|
// it, so a mode that also has a game or fullscreen trigger keeps it.
|
|
|
|
|
function reschedule(mode: var, changes: var): void {
|
|
|
|
|
FocusModes.update(String(mode.id), {
|
|
|
|
|
triggers: (mode.triggers ?? []).map(trigger =>
|
|
|
|
|
trigger.kind === "schedule" ? Object.assign({}, trigger, changes) : trigger)
|
|
|
|
|
});
|
|
|
|
|
function forgetRule(appId: string): void {
|
|
|
|
|
if (root.expandedApp === appId)
|
|
|
|
|
root.expandedApp = "";
|
|
|
|
|
Notifs.forgetApp(appId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleDay(mode: var, day: int): void {
|
|
|
|
|
const schedule = (mode.triggers ?? []).find(trigger => trigger.kind === "schedule");
|
|
|
|
|
// A rule that differs from the defaults in any field. This is what earns an
|
|
|
|
|
// application a place above the fold even when it has not notified lately.
|
|
|
|
|
function customized(rule: var): bool {
|
|
|
|
|
return rule.enabled === false
|
|
|
|
|
|| rule.sound === false
|
|
|
|
|
|| String(rule.display ?? "banners") !== "banners"
|
|
|
|
|
|| String(rule.urgency ?? "auto") !== "auto";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Relative rather than a timestamp: "three days ago" is the question being
|
|
|
|
|
// asked, and a clock time from last March answers a different one.
|
|
|
|
|
function lastSeenText(lastSeenMs: var): string {
|
|
|
|
|
const at = Number(lastSeenMs ?? 0);
|
|
|
|
|
if (!at)
|
|
|
|
|
return "";
|
|
|
|
|
const days = Math.floor((Date.now() - at) / 86400000);
|
|
|
|
|
if (days <= 0)
|
|
|
|
|
return "Today";
|
|
|
|
|
if (days === 1)
|
|
|
|
|
return "Yesterday";
|
|
|
|
|
if (days < 7)
|
|
|
|
|
return days + " days ago";
|
|
|
|
|
return Qt.formatDateTime(new Date(at), "d MMM");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Spelled the same way the Focus tab spells it, so the two pages describe
|
|
|
|
|
// one schedule in one voice.
|
|
|
|
|
function daysText(days: var): string {
|
|
|
|
|
const list = Array.isArray(days) ? days.map(Number).slice().sort((a, b) => a - b) : [];
|
|
|
|
|
if (list.length === 0)
|
|
|
|
|
return "no days";
|
|
|
|
|
if (list.length === 7)
|
|
|
|
|
return "every day";
|
|
|
|
|
if (list.join(",") === "1,2,3,4,5")
|
|
|
|
|
return "weekdays";
|
|
|
|
|
if (list.join(",") === "0,6")
|
|
|
|
|
return "weekends";
|
|
|
|
|
const names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
|
|
|
return list.map(day => names[day]).join(", ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Quiet hours are the Sleep mode's schedule. Rather than inventing a second
|
|
|
|
|
// place to set them, the row states what Sleep is actually doing and hands
|
|
|
|
|
// over to the tab that owns it.
|
|
|
|
|
function quietHoursDetail(): string {
|
|
|
|
|
const sleep = FocusModes.modes.find(mode => String(mode.id) === "sleep");
|
|
|
|
|
if (!sleep)
|
|
|
|
|
return "There is no Sleep focus mode, so nothing is scheduled";
|
|
|
|
|
const schedule = (sleep.triggers ?? []).find(trigger => trigger.kind === "schedule");
|
|
|
|
|
if (!schedule)
|
|
|
|
|
return;
|
|
|
|
|
const days = Array.isArray(schedule.days) ? schedule.days.slice() : [];
|
|
|
|
|
const at = days.indexOf(day);
|
|
|
|
|
if (at >= 0)
|
|
|
|
|
days.splice(at, 1);
|
|
|
|
|
else
|
|
|
|
|
days.push(day);
|
|
|
|
|
days.sort((a, b) => a - b);
|
|
|
|
|
root.reschedule(mode, { days: days });
|
|
|
|
|
return "The Sleep focus mode has no schedule, so quiet hours are not scheduled";
|
|
|
|
|
const when = String(schedule.start ?? "") + " to " + String(schedule.end ?? "")
|
|
|
|
|
+ ", " + root.daysText(schedule.days);
|
|
|
|
|
return sleep.enabled === true
|
|
|
|
|
? "Scheduled through the Sleep focus mode — " + when
|
|
|
|
|
: "The Sleep focus mode is off; it would run " + when;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
title: "Notifications & Focus"
|
|
|
|
|
readonly property var recentApps: {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
return Notifs.applications
|
|
|
|
|
.filter(app => Number(app.lastSeenMs ?? 0) > 0
|
|
|
|
|
&& now - Number(app.lastSeenMs) < root.weekMs)
|
|
|
|
|
.sort((a, b) => Number(b.lastSeenMs) - Number(a.lastSeenMs));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sections are partitioned by id rather than by object identity: these are
|
|
|
|
|
// plain objects rebuilt by a binding, and an application appearing in two
|
|
|
|
|
// sections at once is the failure that would cause.
|
|
|
|
|
readonly property var recentIds: root.recentApps.map(app => String(app.id))
|
|
|
|
|
|
|
|
|
|
readonly property var customizedApps: Notifs.applications.filter(app =>
|
|
|
|
|
root.recentIds.indexOf(String(app.id)) < 0 && root.customized(app.rule))
|
|
|
|
|
|
|
|
|
|
readonly property var customizedIds: root.customizedApps.map(app => String(app.id))
|
|
|
|
|
|
|
|
|
|
// Everything not already on screen. Notifs.applications arrives sorted by
|
|
|
|
|
// name, so this stays alphabetical for free.
|
|
|
|
|
readonly property var otherApps: Notifs.applications.filter(app =>
|
|
|
|
|
root.recentIds.indexOf(String(app.id)) < 0
|
|
|
|
|
&& root.customizedIds.indexOf(String(app.id)) < 0)
|
|
|
|
|
|
|
|
|
|
// With nothing typed the expander holds what is not shown above. A typed
|
|
|
|
|
// query searches the whole universe instead -- a search box that cannot
|
|
|
|
|
// find an application you can see is worse than a duplicated row.
|
|
|
|
|
readonly property var expanderApps: {
|
|
|
|
|
const needle = appSearch.text.trim().toLowerCase();
|
|
|
|
|
if (needle === "")
|
|
|
|
|
return root.otherApps;
|
|
|
|
|
return Notifs.applications.filter(app =>
|
|
|
|
|
(String(app.name) + " " + String(app.id)).toLowerCase().indexOf(needle) >= 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
readonly property string allowSummary: {
|
|
|
|
|
const allow = FocusModes.allowedApps;
|
|
|
|
|
if (allow.length === 0)
|
|
|
|
|
return "nothing may interrupt";
|
|
|
|
|
const names = allow.map(id => {
|
|
|
|
|
const app = Notifs.applications.find(entry => String(entry.id) === id);
|
|
|
|
|
return app ? String(app.name) : id;
|
|
|
|
|
});
|
|
|
|
|
if (names.length <= 2)
|
|
|
|
|
return names.join(" and ") + " may interrupt";
|
|
|
|
|
return names.slice(0, 2).join(", ") + " and " + (names.length - 2)
|
|
|
|
|
+ " more may interrupt";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
title: "Notifications"
|
|
|
|
|
lede: "Control interruptions without losing useful history."
|
|
|
|
|
|
|
|
|
|
// Why the machine is quiet, at the top, where the question is asked.
|
|
|
|
|
Rectangle {
|
|
|
|
|
id: activeBanner
|
|
|
|
|
|
|
|
|
|
width: parent.width
|
|
|
|
|
visible: FocusModes.activeMode !== null
|
|
|
|
|
height: visible ? Math.max(58, bannerCopy.implicitHeight + 24) : 0
|
|
|
|
|
radius: Theme.cardRadius + 1
|
|
|
|
|
color: Theme.alpha(Theme.ok, 0.08)
|
|
|
|
|
border.width: 1
|
|
|
|
|
border.color: Theme.alpha(Theme.ok, 0.3)
|
|
|
|
|
|
|
|
|
|
Column {
|
|
|
|
|
id: bannerCopy
|
|
|
|
|
|
|
|
|
|
anchors.left: parent.left
|
|
|
|
|
anchors.leftMargin: 15
|
|
|
|
|
anchors.right: bannerButton.left
|
|
|
|
|
anchors.rightMargin: 14
|
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
|
spacing: 3
|
|
|
|
|
|
|
|
|
|
Text {
|
|
|
|
|
width: parent.width
|
|
|
|
|
text: FocusModes.activeMode?.silence === true
|
|
|
|
|
? FocusModes.activeName + " is quieting notifications"
|
|
|
|
|
: FocusModes.activeName + " is on"
|
|
|
|
|
color: Theme.fg
|
|
|
|
|
font.family: Theme.fontFamily
|
|
|
|
|
font.pixelSize: Theme.fontSize
|
|
|
|
|
font.weight: Font.DemiBold
|
|
|
|
|
elide: Text.ElideRight
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Text {
|
|
|
|
|
width: parent.width
|
|
|
|
|
text: {
|
|
|
|
|
const why = "Because " + FocusModes.activeReason;
|
|
|
|
|
return FocusModes.activeMode?.silence === true
|
|
|
|
|
? why + " · " + root.allowSummary
|
|
|
|
|
: why + " · notifications are unaffected";
|
|
|
|
|
}
|
|
|
|
|
color: Theme.fgDim
|
|
|
|
|
font.family: Theme.fontFamily
|
|
|
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
|
|
|
wrapMode: Text.WordWrap
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SettingsButton {
|
|
|
|
|
id: bannerButton
|
|
|
|
|
|
|
|
|
|
anchors.right: parent.right
|
|
|
|
|
anchors.rightMargin: 14
|
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
|
text: "Open Focus"
|
|
|
|
|
onClicked: ShellState.openSettings("focus")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SettingsCard {
|
|
|
|
|
title: "Notifications"
|
|
|
|
|
title: "Quiet"
|
|
|
|
|
|
|
|
|
|
SettingRow {
|
|
|
|
|
label: "Do Not Disturb"
|
|
|
|
|
detail: "Keep notifications in the center but suppress banners"
|
|
|
|
|
detail: "Banners stop; everything still lands in the notification center"
|
|
|
|
|
controlWidth: 48
|
|
|
|
|
|
|
|
|
|
SettingsToggle {
|
|
|
|
@@ -67,37 +224,43 @@ SettingsPage {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TextRow {
|
|
|
|
|
label: "Notification history"
|
|
|
|
|
detail: "Live notifications retained by the shell"
|
|
|
|
|
value: Notifs.history.length === 1 ? "1 item" : `${Notifs.history.length} items`
|
|
|
|
|
}
|
|
|
|
|
ToggleRow { setting: "criticalBreaksThrough" }
|
|
|
|
|
|
|
|
|
|
ActionRow {
|
|
|
|
|
label: "Clear notification history"
|
|
|
|
|
detail: "Dismiss every item currently in the notification center"
|
|
|
|
|
label: "Quiet hours"
|
|
|
|
|
detail: root.quietHoursDetail()
|
|
|
|
|
action: "Open Focus"
|
|
|
|
|
divider: false
|
|
|
|
|
action: "Clear all"
|
|
|
|
|
enabled: Notifs.history.length > 0
|
|
|
|
|
onTriggered: Notifs.dismissAll()
|
|
|
|
|
onTriggered: ShellState.openSettings("focus")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SettingsCard {
|
|
|
|
|
title: "Banner behavior"
|
|
|
|
|
title: "Banners & history"
|
|
|
|
|
|
|
|
|
|
SliderRow { setting: "notificationTimeoutMs" }
|
|
|
|
|
SliderRow {
|
|
|
|
|
setting: "notificationTimeoutCriticalMs"
|
|
|
|
|
zeroLabel: "Never"
|
|
|
|
|
}
|
|
|
|
|
SliderRow { setting: "maxVisibleToasts" }
|
|
|
|
|
SliderRow { setting: "notificationHistoryLimit" }
|
|
|
|
|
SliderRow { setting: "maxVisibleToasts"; divider: false }
|
|
|
|
|
|
|
|
|
|
ActionRow {
|
|
|
|
|
label: "Notification history"
|
|
|
|
|
detail: Notifs.history.length === 1
|
|
|
|
|
? "1 kept now · the oldest are dropped past the limit"
|
|
|
|
|
: `${Notifs.history.length} kept now · the oldest are dropped past the limit`
|
|
|
|
|
action: "Clear"
|
|
|
|
|
enabled: Notifs.history.length > 0
|
|
|
|
|
divider: false
|
|
|
|
|
onTriggered: Notifs.dismissAll()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SettingsCard {
|
|
|
|
|
title: "Application rules"
|
|
|
|
|
subtitle: "Apps appear here after they send a notification."
|
|
|
|
|
title: "Applications"
|
|
|
|
|
subtitle: "Apps appear after their first notification. Recent senders and apps you have customized stay up top; the rest wait in All apps."
|
|
|
|
|
|
|
|
|
|
TextRow {
|
|
|
|
|
visible: Notifs.applications.length === 0
|
|
|
|
@@ -106,332 +269,155 @@ SettingsPage {
|
|
|
|
|
divider: false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Repeater {
|
|
|
|
|
model: Notifs.applications
|
|
|
|
|
Item {
|
|
|
|
|
width: parent.width
|
|
|
|
|
height: root.recentApps.length > 0 ? 28 : 0
|
|
|
|
|
visible: height > 0
|
|
|
|
|
|
|
|
|
|
Column {
|
|
|
|
|
id: appEntry
|
|
|
|
|
Text {
|
|
|
|
|
anchors.left: parent.left
|
|
|
|
|
anchors.bottom: parent.bottom
|
|
|
|
|
anchors.bottomMargin: 4
|
|
|
|
|
text: "Recent"
|
|
|
|
|
color: Theme.fgMuted
|
|
|
|
|
font.family: Theme.fontFamily
|
|
|
|
|
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
|
|
|
|
font.weight: Font.Bold
|
|
|
|
|
font.capitalization: Font.AllUppercase
|
|
|
|
|
font.letterSpacing: 0.8
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Repeater {
|
|
|
|
|
model: root.recentApps
|
|
|
|
|
|
|
|
|
|
delegate: NotificationAppRow {
|
|
|
|
|
id: recentRow
|
|
|
|
|
|
|
|
|
|
required property var modelData
|
|
|
|
|
|
|
|
|
|
readonly property var app: appEntry.modelData
|
|
|
|
|
readonly property var rule: Notifs.appRule(appEntry.app.id)
|
|
|
|
|
|
|
|
|
|
width: parent.width
|
|
|
|
|
|
|
|
|
|
SettingRow {
|
|
|
|
|
width: parent.width
|
|
|
|
|
label: appEntry.app.name
|
|
|
|
|
detail: appEntry.rule.enabled ? String(appEntry.app.id) : "Notifications off"
|
|
|
|
|
divider: true
|
|
|
|
|
controlWidth: 48
|
|
|
|
|
|
|
|
|
|
SettingsToggle {
|
|
|
|
|
anchors.right: parent.right
|
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
|
checked: appEntry.rule.enabled
|
|
|
|
|
onToggled: value => Notifs.setAppRule(appEntry.app.id, { enabled: value })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
appId: String(recentRow.modelData.id)
|
|
|
|
|
appName: String(recentRow.modelData.name)
|
|
|
|
|
iconName: String(recentRow.modelData.icon ?? "")
|
|
|
|
|
rule: Notifs.appRule(recentRow.modelData.id)
|
|
|
|
|
lastSeen: root.lastSeenText(recentRow.modelData.lastSeenMs)
|
|
|
|
|
expanded: root.expandedApp === String(recentRow.modelData.id)
|
|
|
|
|
onActivated: root.expandedApp = recentRow.expanded
|
|
|
|
|
? ""
|
|
|
|
|
: String(recentRow.modelData.id)
|
|
|
|
|
onEdited: patch => root.editRule(String(recentRow.modelData.id), patch)
|
|
|
|
|
onForgotten: root.forgetRule(String(recentRow.modelData.id))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SettingsCard {
|
|
|
|
|
title: "Focus modes"
|
|
|
|
|
subtitle: FocusModes.active
|
|
|
|
|
? FocusModes.activeName + " is on because " + FocusModes.activeReason + "."
|
|
|
|
|
: "What quiets this machine, and what turns it on. The first mode whose condition is true wins, so order is priority."
|
|
|
|
|
Item {
|
|
|
|
|
width: parent.width
|
|
|
|
|
height: root.customizedApps.length > 0 ? 28 : 0
|
|
|
|
|
visible: height > 0
|
|
|
|
|
|
|
|
|
|
Text {
|
|
|
|
|
anchors.left: parent.left
|
|
|
|
|
anchors.bottom: parent.bottom
|
|
|
|
|
anchors.bottomMargin: 4
|
|
|
|
|
text: "Customized"
|
|
|
|
|
color: Theme.fgMuted
|
|
|
|
|
font.family: Theme.fontFamily
|
|
|
|
|
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
|
|
|
|
font.weight: Font.Bold
|
|
|
|
|
font.capitalization: Font.AllUppercase
|
|
|
|
|
font.letterSpacing: 0.8
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Repeater {
|
|
|
|
|
model: FocusModes.modes
|
|
|
|
|
model: root.customizedApps
|
|
|
|
|
|
|
|
|
|
delegate: Column {
|
|
|
|
|
id: modeEntry
|
|
|
|
|
delegate: NotificationAppRow {
|
|
|
|
|
id: customRow
|
|
|
|
|
|
|
|
|
|
required property var modelData
|
|
|
|
|
|
|
|
|
|
readonly property var mode: modeEntry.modelData
|
|
|
|
|
readonly property string modeId: String(modeEntry.mode.id ?? "")
|
|
|
|
|
readonly property bool open: root.expandedMode === modeEntry.modeId
|
|
|
|
|
readonly property bool running: FocusModes.activeMode?.id === modeEntry.modeId
|
|
|
|
|
readonly property var schedule: (modeEntry.mode.triggers ?? [])
|
|
|
|
|
.find(trigger => trigger.kind === "schedule") ?? null
|
|
|
|
|
width: parent.width
|
|
|
|
|
appId: String(customRow.modelData.id)
|
|
|
|
|
appName: String(customRow.modelData.name)
|
|
|
|
|
iconName: String(customRow.modelData.icon ?? "")
|
|
|
|
|
rule: Notifs.appRule(customRow.modelData.id)
|
|
|
|
|
lastSeen: root.lastSeenText(customRow.modelData.lastSeenMs)
|
|
|
|
|
expanded: root.expandedApp === String(customRow.modelData.id)
|
|
|
|
|
onActivated: root.expandedApp = customRow.expanded
|
|
|
|
|
? ""
|
|
|
|
|
: String(customRow.modelData.id)
|
|
|
|
|
onEdited: patch => root.editRule(String(customRow.modelData.id), patch)
|
|
|
|
|
onForgotten: root.forgetRule(String(customRow.modelData.id))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SettingRow {
|
|
|
|
|
visible: root.otherApps.length > 0
|
|
|
|
|
label: `All apps (${root.otherApps.length})`
|
|
|
|
|
detail: "The rest of what has notified, alphabetically — search matches a name or an id"
|
|
|
|
|
activatable: true
|
|
|
|
|
divider: !root.allAppsOpen
|
|
|
|
|
controlWidth: 40
|
|
|
|
|
onActivated: root.allAppsOpen = !root.allAppsOpen
|
|
|
|
|
|
|
|
|
|
Text {
|
|
|
|
|
anchors.right: parent.right
|
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
|
text: root.allAppsOpen ? "▴" : "▾"
|
|
|
|
|
color: Theme.fgMuted
|
|
|
|
|
font.family: Theme.fontFamily
|
|
|
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Item {
|
|
|
|
|
width: parent.width
|
|
|
|
|
height: root.allAppsOpen ? 42 : 0
|
|
|
|
|
visible: root.allAppsOpen
|
|
|
|
|
|
|
|
|
|
SearchField {
|
|
|
|
|
id: appSearch
|
|
|
|
|
anchors.left: parent.left
|
|
|
|
|
anchors.right: parent.right
|
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
|
placeholder: "Search apps"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Repeater {
|
|
|
|
|
model: root.allAppsOpen ? root.expanderApps : []
|
|
|
|
|
|
|
|
|
|
delegate: NotificationAppRow {
|
|
|
|
|
id: expanderRow
|
|
|
|
|
|
|
|
|
|
required property var modelData
|
|
|
|
|
required property int index
|
|
|
|
|
|
|
|
|
|
width: parent.width
|
|
|
|
|
|
|
|
|
|
SettingRow {
|
|
|
|
|
width: parent.width
|
|
|
|
|
label: String(modeEntry.mode.name ?? "")
|
|
|
|
|
detail: modeEntry.running
|
|
|
|
|
? "On now — " + FocusModes.activeReason
|
|
|
|
|
: FocusModes.summary(modeEntry.mode)
|
|
|
|
|
activatable: true
|
|
|
|
|
divider: !modeEntry.open
|
|
|
|
|
controlWidth: 92
|
|
|
|
|
onActivated: root.expandedMode = modeEntry.open ? "" : modeEntry.modeId
|
|
|
|
|
|
|
|
|
|
Row {
|
|
|
|
|
anchors.right: parent.right
|
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
|
spacing: 11
|
|
|
|
|
|
|
|
|
|
SettingsToggle {
|
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
|
checked: modeEntry.mode.enabled === true
|
|
|
|
|
onToggled: value => FocusModes.setEnabled(modeEntry.modeId, value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Text {
|
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
|
text: modeEntry.open ? "\u25B4" : "\u25BE"
|
|
|
|
|
color: Theme.fgMuted
|
|
|
|
|
font.family: Theme.fontFamily
|
|
|
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SettingRow {
|
|
|
|
|
width: parent.width
|
|
|
|
|
visible: modeEntry.open
|
|
|
|
|
label: "Silence notifications"
|
|
|
|
|
detail: "Banners are held until the mode ends"
|
|
|
|
|
controlWidth: 48
|
|
|
|
|
|
|
|
|
|
SettingsToggle {
|
|
|
|
|
anchors.right: parent.right
|
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
|
checked: modeEntry.mode.silence === true
|
|
|
|
|
onToggled: value => FocusModes.update(modeEntry.modeId, { silence: value })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SettingRow {
|
|
|
|
|
width: parent.width
|
|
|
|
|
visible: modeEntry.open
|
|
|
|
|
label: "Keep the screen awake"
|
|
|
|
|
detail: "Caffeine, for as long as the mode is on"
|
|
|
|
|
controlWidth: 48
|
|
|
|
|
divider: modeEntry.schedule !== null
|
|
|
|
|
|
|
|
|
|
SettingsToggle {
|
|
|
|
|
anchors.right: parent.right
|
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
|
checked: modeEntry.mode.keepAwake === true
|
|
|
|
|
onToggled: value => FocusModes.update(modeEntry.modeId, { keepAwake: value })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only modes that actually have a schedule get the schedule
|
|
|
|
|
// controls; a game trigger has no times to set.
|
|
|
|
|
TextFieldRow {
|
|
|
|
|
width: parent.width
|
|
|
|
|
visible: modeEntry.open && modeEntry.schedule !== null
|
|
|
|
|
label: "From"
|
|
|
|
|
detail: "24-hour, such as 23:30"
|
|
|
|
|
text: String(modeEntry.schedule?.start ?? "")
|
|
|
|
|
placeholder: "23:30"
|
|
|
|
|
onAccepted: value => root.reschedule(modeEntry.mode, { start: value })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TextFieldRow {
|
|
|
|
|
width: parent.width
|
|
|
|
|
visible: modeEntry.open && modeEntry.schedule !== null
|
|
|
|
|
label: "Until"
|
|
|
|
|
detail: "A time earlier than the start means the window crosses midnight"
|
|
|
|
|
text: String(modeEntry.schedule?.end ?? "")
|
|
|
|
|
placeholder: "07:00"
|
|
|
|
|
onAccepted: value => root.reschedule(modeEntry.mode, { end: value })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SettingRow {
|
|
|
|
|
width: parent.width
|
|
|
|
|
visible: modeEntry.open && modeEntry.mode.silence === true
|
|
|
|
|
label: "May interrupt"
|
|
|
|
|
detail: (modeEntry.mode.allow ?? []).length === 0
|
|
|
|
|
? "Nothing gets through while this mode is on"
|
|
|
|
|
: "Everything else is held until the mode ends"
|
|
|
|
|
activatable: true
|
|
|
|
|
controlWidth: 150
|
|
|
|
|
onActivated: root.expandedAllow =
|
|
|
|
|
root.expandedAllow === modeEntry.modeId ? "" : modeEntry.modeId
|
|
|
|
|
|
|
|
|
|
Row {
|
|
|
|
|
anchors.right: parent.right
|
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
|
spacing: 9
|
|
|
|
|
|
|
|
|
|
Text {
|
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
|
text: (modeEntry.mode.allow ?? []).length === 0
|
|
|
|
|
? "Nothing"
|
|
|
|
|
: (modeEntry.mode.allow ?? []).length + " apps"
|
|
|
|
|
color: Theme.fgDim
|
|
|
|
|
font.family: Theme.fontFamily
|
|
|
|
|
font.pixelSize: Theme.fontSize
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Text {
|
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
|
text: root.expandedAllow === modeEntry.modeId ? "\u25B4" : "\u25BE"
|
|
|
|
|
color: Theme.fgMuted
|
|
|
|
|
font.family: Theme.fontFamily
|
|
|
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Drawn from the applications that have actually sent a
|
|
|
|
|
// notification, which is the same list the rules above use --
|
|
|
|
|
// an exception for something that never notifies is not a
|
|
|
|
|
// choice worth offering.
|
|
|
|
|
Repeater {
|
|
|
|
|
model: (modeEntry.open && root.expandedAllow === modeEntry.modeId)
|
|
|
|
|
? Notifs.applications
|
|
|
|
|
: []
|
|
|
|
|
|
|
|
|
|
delegate: SettingRow {
|
|
|
|
|
required property var modelData
|
|
|
|
|
width: parent.width
|
|
|
|
|
label: String(modelData.name ?? "")
|
|
|
|
|
detail: String(modelData.id ?? "")
|
|
|
|
|
controlWidth: 48
|
|
|
|
|
|
|
|
|
|
SettingsToggle {
|
|
|
|
|
anchors.right: parent.right
|
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
|
checked: (modeEntry.mode.allow ?? []).indexOf(String(modelData.id)) >= 0
|
|
|
|
|
onToggled: value => root.toggleAllowed(modeEntry.mode, String(modelData.id), value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TextRow {
|
|
|
|
|
width: parent.width
|
|
|
|
|
visible: modeEntry.open && root.expandedAllow === modeEntry.modeId
|
|
|
|
|
&& Notifs.applications.length === 0
|
|
|
|
|
label: "No applications yet"
|
|
|
|
|
detail: "They appear here once they have sent a notification."
|
|
|
|
|
value: ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SettingRow {
|
|
|
|
|
width: parent.width
|
|
|
|
|
visible: modeEntry.open && modeEntry.schedule !== null
|
|
|
|
|
label: "On these days"
|
|
|
|
|
detail: "A window that crosses midnight belongs to the day it starts on"
|
|
|
|
|
controlWidth: 250
|
|
|
|
|
divider: false
|
|
|
|
|
|
|
|
|
|
Row {
|
|
|
|
|
anchors.right: parent.right
|
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
|
spacing: 5
|
|
|
|
|
|
|
|
|
|
Repeater {
|
|
|
|
|
model: [
|
|
|
|
|
{ value: 1, label: "M" }, { value: 2, label: "T" },
|
|
|
|
|
{ value: 3, label: "W" }, { value: 4, label: "T" },
|
|
|
|
|
{ value: 5, label: "F" }, { value: 6, label: "S" },
|
|
|
|
|
{ value: 0, label: "S" }
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
delegate: Rectangle {
|
|
|
|
|
id: dayPill
|
|
|
|
|
|
|
|
|
|
required property var modelData
|
|
|
|
|
|
|
|
|
|
readonly property bool on:
|
|
|
|
|
(modeEntry.schedule?.days ?? []).indexOf(dayPill.modelData.value) >= 0
|
|
|
|
|
|
|
|
|
|
width: 30
|
|
|
|
|
height: 28
|
|
|
|
|
radius: 8
|
|
|
|
|
color: dayPill.on ? Theme.alpha(Theme.accent, 0.22)
|
|
|
|
|
: Theme.alpha(Theme.fg, 0.06)
|
|
|
|
|
border.width: dayPill.on ? 1 : 0
|
|
|
|
|
border.color: Theme.alpha(Theme.accent, 0.5)
|
|
|
|
|
|
|
|
|
|
Text {
|
|
|
|
|
anchors.centerIn: parent
|
|
|
|
|
text: String(dayPill.modelData.label)
|
|
|
|
|
color: dayPill.on ? Theme.fg : Theme.fgDim
|
|
|
|
|
font.family: Theme.fontFamily
|
|
|
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
|
|
|
font.weight: dayPill.on ? Font.DemiBold : Font.Medium
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
HoverHandler { cursorShape: Qt.PointingHandCursor }
|
|
|
|
|
TapHandler {
|
|
|
|
|
onTapped: root.toggleDay(modeEntry.mode, dayPill.modelData.value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SettingsCard {
|
|
|
|
|
title: "Focus sessions"
|
|
|
|
|
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
|
|
|
|
|
|
|
|
|
|
SettingRow {
|
|
|
|
|
label: "Keep the screen awake"
|
|
|
|
|
detail: Caffeine.enabled
|
|
|
|
|
? "The display will not blank or lock while this is on"
|
|
|
|
|
: "Idle timings on Power & Lock apply normally"
|
|
|
|
|
controlWidth: 48
|
|
|
|
|
|
|
|
|
|
SettingsToggle {
|
|
|
|
|
anchors.right: parent.right
|
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
|
checked: Caffeine.enabled
|
|
|
|
|
onToggled: value => Caffeine.enabled = value
|
|
|
|
|
appId: String(expanderRow.modelData.id)
|
|
|
|
|
appName: String(expanderRow.modelData.name)
|
|
|
|
|
iconName: String(expanderRow.modelData.icon ?? "")
|
|
|
|
|
rule: Notifs.appRule(expanderRow.modelData.id)
|
|
|
|
|
lastSeen: root.lastSeenText(expanderRow.modelData.lastSeenMs)
|
|
|
|
|
expanded: root.expandedApp === String(expanderRow.modelData.id)
|
|
|
|
|
divider: expanderRow.index < root.expanderApps.length - 1
|
|
|
|
|
onActivated: root.expandedApp = expanderRow.expanded
|
|
|
|
|
? ""
|
|
|
|
|
: String(expanderRow.modelData.id)
|
|
|
|
|
onEdited: patch => root.editRule(String(expanderRow.modelData.id), patch)
|
|
|
|
|
onForgotten: root.forgetRule(String(expanderRow.modelData.id))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SettingRow {
|
|
|
|
|
id: durationRow
|
|
|
|
|
|
|
|
|
|
label: "Default duration"
|
|
|
|
|
detail: "Used by Super+Shift+F and Quick Settings"
|
|
|
|
|
controlWidth: 264
|
|
|
|
|
|
|
|
|
|
Row {
|
|
|
|
|
anchors.right: parent.right
|
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
|
spacing: 6
|
|
|
|
|
|
|
|
|
|
Repeater {
|
|
|
|
|
model: [25, 45, 60, 90]
|
|
|
|
|
|
|
|
|
|
SettingsButton {
|
|
|
|
|
required property int modelData
|
|
|
|
|
|
|
|
|
|
text: `${modelData}m`
|
|
|
|
|
tone: DesktopPreferences.get("focusDurationMinutes") === modelData ? "accent" : "normal"
|
|
|
|
|
onClicked: SystemSettings.commitPreference("focusDurationMinutes", modelData)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SettingRow {
|
|
|
|
|
label: FocusSession.active ? `Active on ${FocusSession.workspaceLabel}` : "No active focus session"
|
|
|
|
|
detail: FocusSession.active ? `${FocusSession.remainingText} remaining` : "Start one without leaving Settings"
|
|
|
|
|
TextRow {
|
|
|
|
|
visible: root.allAppsOpen && root.expanderApps.length === 0
|
|
|
|
|
label: appSearch.text.trim() === "" ? "Nothing else remembered" : "No matching applications"
|
|
|
|
|
detail: appSearch.text.trim() === ""
|
|
|
|
|
? "Every application that has notified is already listed above."
|
|
|
|
|
: "Search matches an application's name or its id."
|
|
|
|
|
divider: false
|
|
|
|
|
controlWidth: 120
|
|
|
|
|
|
|
|
|
|
SettingsButton {
|
|
|
|
|
anchors.right: parent.right
|
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
|
text: FocusSession.active ? "Show controls" : "Start focus"
|
|
|
|
|
tone: FocusSession.active ? "normal" : "accent"
|
|
|
|
|
onClicked: FocusSession.reveal()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|