428 lines
17 KiB
QML
428 lines
17 KiB
QML
// 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 }
|
||
}
|
||
}
|