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:
Gabriel Brown
2026-08-23 22:06:18 -04:00
parent 5490fd285d
commit 7578348db1
40 changed files with 2578 additions and 703 deletions
@@ -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);
}
}
}
}