Files
Panama/config/dot/quickshell/modules/settings/HomePage.qml
T

428 lines
17 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Home — the page you open to do something, not to read a report.
//
// The Overview tab of the Home category. Quick actions sit under the greeting
// as tiles; findings follow; then two glance cards into the sibling tabs (My
// Home, Phone), the next calendar event, and weather. The old Focus and
// containers cards retired into the tiles and the System category; the
// reclaim-space prompt is gone on purpose — stopped development containers
// are not clutter, and prompting their deletion was a racket.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
readonly property int openedHour: new Date().getHours()
readonly property string greeting: openedHour < 12
? "Good morning"
: (openedHour < 18 ? "Good afternoon" : "Good evening")
// Findings, in the order they deserve attention. Each names the page that
// can actually resolve it, because a home page that reports a problem it
// cannot help with is just an alarm.
readonly property var findings: {
const found = [];
const exposed = Firewall.exposedDataStores ?? [];
if (exposed.length > 0) {
found.push({
label: exposed.length === 1
? "A database is reachable from your network"
: "Databases are reachable from your network",
detail: exposed.map(entry => String(entry.name)).join(" and ")
+ ", published on every interface",
page: "firewall",
action: "Review"
});
}
if (Updates.securityCount > 0) {
found.push({
label: Updates.securityCount + " update"
+ (Updates.securityCount === 1 ? "" : "s") + " carry a security advisory",
detail: "Worth installing before the rest",
page: "updates",
action: "Open"
});
}
if (Updates.rebootNeeded) {
found.push({
label: "A newer kernel is installed than the one running",
detail: "Restart to use it",
page: "updates",
action: "Open"
});
}
if (Updates.total > 0) {
found.push({
label: "Install " + Updates.total + " update" + (Updates.total === 1 ? "" : "s"),
detail: "Packages, applications and firmware, from wherever each comes from",
page: "updates",
action: "Open"
});
}
if (Health.summary?.errors > 0 || Health.summary?.warnings > 0) {
const count = Number(Health.summary?.errors ?? 0) + Number(Health.summary?.warnings ?? 0);
found.push({
label: count + " health check" + (count === 1 ? "" : "s") + " need attention",
detail: "Something the desktop owns is not in its expected state",
page: "services",
action: "Open"
});
}
return found;
}
// One line of everything that is fine, so the quiet case still says
// something rather than showing an empty page.
readonly property string reassurance: {
const parts = [];
if (Health.checks.length > 0)
parts.push(Health.checks.length + " health checks pass");
if (Disks.rootFilesystem)
parts.push(Disks.formatBytes(Number(Disks.rootFilesystem.availBytes ?? 0)) + " free");
const newest = Snapshots.configs.length > 0
? (Snapshots.configs[0].snapshots ?? [])[0]
: null;
if (newest)
parts.push("snapshots ran at " + root.clockOf(String(newest.date ?? "")));
return parts.join(" · ");
}
// snapper prints a full date; only the time is wanted in a summary line.
function clockOf(stamp: string): string {
const match = /(\d{1,2}:\d{2})(?::\d{2})?\s*(AM|PM)?/i.exec(stamp);
if (!match)
return stamp;
return match[2] ? match[1] + " " + match[2].toUpperCase() : match[1];
}
// Greets whoever is signed in, from the same accountsservice record the
// lock screen shows. This once hardcoded the author's first name, which
// made the first screen of Settings wrong for every other human.
readonly property string greetingName: {
if (!UserAccounts.me)
return "";
const full = UserAccounts.displayName(UserAccounts.me);
return full.split(" ")[0] || "";
}
// What the glance cards say about the sibling tabs.
readonly property var lightsOn: HomeAssistant.catalog.filter(entity => entity.active === true)
readonly property string myHomeGlance: {
if (HomeAssistant.lastError === "not-configured")
return "Set up Home Assistant";
if (root.lightsOn.length === 0)
return "No lights are on";
const names = root.lightsOn.slice(0, 2).map(entity => String(entity.name ?? entity.sourceName)).join(", ");
return root.lightsOn.length + (root.lightsOn.length === 1 ? " light on · " : " lights on · ") + names;
}
readonly property string phoneGlance: {
if (!KdeConnect.preferredPhone)
return "No paired phone";
const parts = [String(KdeConnect.preferredPhone.name)];
if (KdeConnect.phoneBattery)
parts.push(KdeConnect.phoneBattery.charge + "%");
parts.push(KdeConnect.phoneReachable ? "nearby" : "away");
return parts.join(" · ");
}
// The next timed calendar event, worded for a card.
readonly property string nextEventWhen: {
const event = CalendarAgenda.nextEvent;
if (!event)
return "";
const start = new Date(Number(event.start) * 1000);
const clock = start.toLocaleTimeString(Qt.locale(), Locale.ShortFormat);
const today = new Date();
const sameDay = start.getFullYear() === today.getFullYear()
&& start.getMonth() === today.getMonth() && start.getDate() === today.getDate();
return sameDay ? clock + " today" : clock + " " + start.toLocaleDateString(Qt.locale(), "dddd");
}
title: root.greetingName !== "" ? `${root.greeting}, ${root.greetingName}` : root.greeting
lede: Weather.available
? Math.round(Weather.temperature) + Weather.unitSuffix + " and "
+ Weather.description.toLowerCase() + " in " + Settings.weatherLocation
: "Your desktop is configured and ready."
// These scans also feed the System category's tab gating (Containers,
// Snapshots), so they fire when Settings opens even though this page no
// longer renders their cards.
Component.onCompleted: {
if (!Firewall.scanned)
Firewall.refresh();
if (!Containers.scanned)
Containers.refresh();
if (!Snapshots.scanned)
Snapshots.refresh();
if (!Disks.scanned)
Disks.refresh();
}
// ── Quick actions ───────────────────────────────────────────────────────
Grid {
id: quick
width: parent.width
columns: width >= 640 ? 5 : 2
columnSpacing: 10
rowSpacing: 10
readonly property real tileWidth: (width - (columns - 1) * columnSpacing) / columns
HomeQuickTile {
width: quick.tileWidth
glyph: "\u{F051F}"
label: FocusSession.active ? "End focus" : "Focus session"
caption: FocusSession.active
? FocusSession.remainingText + " left · tap to end"
: Settings.focusDurationMinutes + " min · Super+Shift+F"
onActivated: FocusSession.active ? FocusSession.end(false) : FocusSession.startDefault()
}
HomeQuickTile {
width: quick.tileWidth
glyph: "\u{F009A}"
glyphColor: Notifs.doNotDisturb ? Theme.warn : Theme.accent
label: "Do Not Disturb"
interactive: !FocusModes.active && !FocusSession.active
caption: {
if (FocusModes.active)
return "Held by the " + FocusModes.activeName + " mode";
if (FocusSession.active)
return "Held by the focus session";
return Notifs.doNotDisturb ? "On — tap to allow banners" : "Off — tap to hold banners";
}
onActivated: Notifs.doNotDisturb = !Notifs.doNotDisturb
}
HomeQuickTile {
width: quick.tileWidth
glyph: "\u{F0493}"
glyphColor: Health.status === "error"
? Theme.danger : (Health.status === "warning" ? Theme.warn : Theme.ok)
label: "Health"
caption: {
if (Health.checks.length === 0)
return "Checking the desktop";
const count = Number(Health.summary?.errors ?? 0) + Number(Health.summary?.warnings ?? 0);
return count > 0 ? count + " need attention" : Health.checks.length + " checks pass";
}
onActivated: ShellState.openSettings("services")
}
HomeQuickTile {
width: quick.tileWidth
glyph: "\u{F0954}"
label: "Snapshots"
caption: {
const newest = Snapshots.configs.length > 0
? (Snapshots.configs[0].snapshots ?? [])[0] : null;
if (newest)
return "Ran at " + root.clockOf(String(newest.date ?? ""));
return Snapshots.configs.length > 0 ? "Configured" : "Not protected";
}
onActivated: ShellState.openSettings("snapshots")
}
HomeQuickTile {
width: quick.tileWidth
glyph: "\u{F02CA}"
label: "Storage"
caption: Disks.rootFilesystem
? Disks.formatBytes(Number(Disks.rootFilesystem.availBytes ?? 0)) + " free"
: "Reading the drive"
onActivated: ShellState.openSettings("storage")
}
}
// ── This machine ────────────────────────────────────────────────────────
SettingsCard {
title: "This machine"
subtitle: root.reassurance
TextRow {
visible: root.findings.length === 0
label: "Nothing needs your attention"
detail: "Anything worth acting on would appear here"
value: ""
divider: false
}
Repeater {
model: root.findings
delegate: SettingRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.label ?? "")
detail: String(modelData.detail ?? "")
controlWidth: 110
divider: index < root.findings.length - 1
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: String(modelData.action ?? "Open")
onClicked: ShellState.settingsPage = String(modelData.page)
}
}
}
}
// ── Glances into the sibling tabs ───────────────────────────────────────
Grid {
id: glances
width: parent.width
columns: width >= 720 ? 2 : 1
columnSpacing: 16
rowSpacing: 16
readonly property real cardWidth: columns === 2 ? (width - columnSpacing) / 2 : width
Repeater {
model: [
{ glyph: "\u{F0335}", title: "My Home", sub: root.myHomeGlance, page: "my-home" },
{ glyph: "\u{F011C}", title: "Phone", sub: root.phoneGlance, page: "phone" }
]
delegate: Rectangle {
id: glance
required property var modelData
width: glances.cardWidth
height: 74
radius: 14
color: glanceArea.containsMouse
? Theme.alpha(Theme.bgPanel, 0.95)
: Theme.alpha(Theme.bgPanel, 0.72)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
Text {
id: glanceGlyph
anchors.left: parent.left
anchors.leftMargin: 16
anchors.verticalCenter: parent.verticalCenter
text: String(glance.modelData.glyph)
color: Theme.accent
font.family: Theme.fontMono
font.pixelSize: 19
}
Column {
anchors.left: glanceGlyph.right
anchors.leftMargin: 14
anchors.right: chevron.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
text: String(glance.modelData.title)
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: String(glance.modelData.sub)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
Text {
id: chevron
anchors.right: parent.right
anchors.rightMargin: 15
anchors.verticalCenter: parent.verticalCenter
text: ""
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 18
}
MouseArea {
id: glanceArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: ShellState.openSettings(String(glance.modelData.page))
}
Accessible.role: Accessible.Button
Accessible.name: "Open " + String(glance.modelData.title)
}
}
}
// ── Today ───────────────────────────────────────────────────────────────
SettingsCard {
visible: CalendarAgenda.available && CalendarAgenda.nextEvent !== null
title: "Today"
SettingRow {
label: CalendarAgenda.nextEvent
? String(CalendarAgenda.nextEvent.summary) + " — " + root.nextEventWhen
: ""
detail: "Next on your calendar"
divider: false
controlWidth: 110
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: String(CalendarAgenda.nextEvent?.joinUrl ?? "").startsWith("https://")
? "Join" : "Open"
onClicked: {
const event = CalendarAgenda.nextEvent;
if (!event)
return;
if (String(event.joinUrl ?? "").startsWith("https://"))
CalendarAgenda.join(event);
else
CalendarAgenda.openEvent(event);
}
}
}
}
// ── Weather ─────────────────────────────────────────────────────────────
SettingsCard {
title: "Weather"
subtitle: "Shown above and in the date menu."
// The location is set roughly once a year, so the search that changes it
// is behind a press rather than permanently open.
PickerRow {
id: locationPicker
label: "Location"
detail: "Only the search term is sent; the name is a label kept on this machine"
value: Settings.weatherLocation !== "" ? Settings.weatherLocation : "Not set — choose one to see weather"
LocationPicker {
width: parent.width
onPicked: locationPicker.collapse()
}
}
ChoiceRow { setting: "temperatureUnit" }
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
}
}