Merge Home & Phone into a three-tab Home that knows your house
Home is now Overview | My Home | Phone. Overview leads with quick-action tiles (focus, Do Not Disturb, health, snapshots, storage), keeps the findings card — updates fold in, the reclaim-space prompt is gone on purpose — and adds glance cards, the next calendar event, and weather. My Home groups every light by Home Assistant area: the helper gained an `areas` command (one REST template render, no websocket), and the rooms degrade to a flat list on setups without areas. The favorites editor and connection card moved intact. Phone gains a vitals strip — battery and cell signal read from KDE Connect's plugin D-Bus objects, where absence is data, not an error — beside ring, clipboard, send-a-file, and the BlueBubbles handoff. The retired home-phone id resolves to my-home forever via a new alias map in SettingsRoutes (with a hasOwnProperty guard so prototype names cannot leak into settingsPage). Storage no longer claims 0 B free — the old page read a field the disks helper never emitted. Contracts updated alongside; per the new workflow, the full suite runs once at the end of the redesign (see the test backlog note). Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -1,18 +1,11 @@
|
||||
// Home — the page you open to do something, not to read a report.
|
||||
//
|
||||
// It used to lead with a diagram of the attached monitor: DP-2, 4500 x 3000,
|
||||
// 1.13x scale, XRGB2101010. That is Displays-page data, it was the largest
|
||||
// thing on the screen, and nobody has ever needed it here. Below it sat a
|
||||
// permanently open search field for a weather location that is set about once a
|
||||
// year, and then roughly half a page of nothing.
|
||||
//
|
||||
// What Panama already knows is the useful material: whether anything needs
|
||||
// attention, what is running, and the two or three things worth doing next. A
|
||||
// finding is shown when there is one and the page is quiet when there is not,
|
||||
// which is the same shape the Firewall and Containers pages use.
|
||||
//
|
||||
// Weather stays. It is the one genuinely ambient thing here, and it belongs
|
||||
// next to a greeting rather than in a card of its own with a search box open.
|
||||
// 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
|
||||
@@ -60,6 +53,14 @@ SettingsPage {
|
||||
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({
|
||||
@@ -79,7 +80,7 @@ SettingsPage {
|
||||
if (Health.checks.length > 0)
|
||||
parts.push(Health.checks.length + " health checks pass");
|
||||
if (Disks.rootFilesystem)
|
||||
parts.push(Disks.formatBytes(Number(Disks.rootFilesystem.available ?? 0)) + " free");
|
||||
parts.push(Disks.formatBytes(Number(Disks.rootFilesystem.availBytes ?? 0)) + " free");
|
||||
const newest = Snapshots.configs.length > 0
|
||||
? (Snapshots.configs[0].snapshots ?? [])[0]
|
||||
: null;
|
||||
@@ -106,12 +107,48 @@ SettingsPage {
|
||||
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();
|
||||
@@ -123,90 +160,81 @@ SettingsPage {
|
||||
Disks.refresh();
|
||||
}
|
||||
|
||||
// ── What you came here to do ────────────────────────────────────────────
|
||||
// ── Quick actions ───────────────────────────────────────────────────────
|
||||
|
||||
Grid {
|
||||
id: doing
|
||||
id: quick
|
||||
|
||||
width: parent.width
|
||||
columns: width >= 720 ? 2 : 1
|
||||
columnSpacing: 16
|
||||
rowSpacing: 16
|
||||
columns: width >= 640 ? 5 : 2
|
||||
columnSpacing: 10
|
||||
rowSpacing: 10
|
||||
|
||||
SettingsCard {
|
||||
width: doing.columns === 2
|
||||
? (doing.width - doing.columnSpacing) / 2
|
||||
: doing.width
|
||||
title: "Focus"
|
||||
subtitle: FocusSession.active
|
||||
? "A session is running · " + FocusSession.remainingText + " left"
|
||||
: (FocusModes.active
|
||||
? FocusModes.activeName + " is on because " + FocusModes.activeReason
|
||||
: (Notifs.doNotDisturb
|
||||
? "Do Not Disturb is on"
|
||||
: "Nothing is quieting this machine"))
|
||||
readonly property real tileWidth: (width - (columns - 1) * columnSpacing) / columns
|
||||
|
||||
SettingRow {
|
||||
label: FocusSession.active ? "End the session" : "Start a focus session"
|
||||
detail: FocusSession.active
|
||||
? "Puts Do Not Disturb and Caffeine back as they were"
|
||||
: Settings.focusDurationMinutes + " minutes · Super+Shift+F"
|
||||
controlWidth: 120
|
||||
|
||||
SettingsButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: FocusSession.active ? "End" : "Start"
|
||||
tone: FocusSession.active ? "normal" : "accent"
|
||||
onClicked: FocusSession.active ? FocusSession.end(false) : FocusSession.startDefault()
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Do Not Disturb"
|
||||
detail: FocusModes.active
|
||||
? "Held on by the " + FocusModes.activeName + " mode"
|
||||
: "Banners are held; the notification centre still fills"
|
||||
divider: false
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
enabled: !FocusModes.active && !FocusSession.active
|
||||
checked: Notifs.doNotDisturb
|
||||
onToggled: value => Notifs.doNotDisturb = value
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: doing.columns === 2
|
||||
? (doing.width - doing.columnSpacing) / 2
|
||||
: doing.width
|
||||
title: "Right now"
|
||||
subtitle: Containers.running > 0
|
||||
? Containers.running + " of " + Containers.total + " containers running"
|
||||
: "No containers are running"
|
||||
|
||||
TextRow {
|
||||
label: Containers.running > 0
|
||||
? Containers.projects.filter(project => project.running > 0)
|
||||
.map(project => String(project.title)).join(", ")
|
||||
: "Nothing to report"
|
||||
detail: Containers.running > 0
|
||||
? "Started for development, still up"
|
||||
: "Start a stack from the Containers page"
|
||||
value: ""
|
||||
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
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Containers"
|
||||
detail: "Projects, what they expose, and what they cost"
|
||||
action: "Open"
|
||||
divider: false
|
||||
onTriggered: ShellState.settingsPage = "containers"
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,29 +274,129 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Worth doing, when there is something ────────────────────────────────
|
||||
// ── 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: Updates.total > 0 || Containers.reclaimable > 0
|
||||
title: "Do next"
|
||||
subtitle: "Small things this machine is waiting on."
|
||||
visible: CalendarAgenda.available && CalendarAgenda.nextEvent !== null
|
||||
title: "Today"
|
||||
|
||||
ActionRow {
|
||||
visible: Updates.total > 0
|
||||
label: "Install " + Updates.total + " update" + (Updates.total === 1 ? "" : "s")
|
||||
detail: "Packages, applications and firmware, from wherever each comes from"
|
||||
action: "Open"
|
||||
divider: Containers.reclaimable > 0
|
||||
onTriggered: ShellState.settingsPage = "updates"
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
visible: Containers.reclaimable > 0
|
||||
label: "Reclaim " + Containers.formatBytes(Containers.reclaimable)
|
||||
detail: "Container images and volumes nothing references"
|
||||
action: "Review"
|
||||
SettingRow {
|
||||
label: CalendarAgenda.nextEvent
|
||||
? String(CalendarAgenda.nextEvent.summary) + " — " + root.nextEventWhen
|
||||
: ""
|
||||
detail: "Next on your calendar"
|
||||
divider: false
|
||||
onTriggered: ShellState.settingsPage = "containers"
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user