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
@@ -0,0 +1,163 @@
// One light on the My Home page: power square, name, state, and a brightness
// slider when the light dims. The slider is Control Center's — one dimmer
// implementation everywhere, covered by home-brightness-slider-contract.
import QtQuick
import qs.config
import qs.services
import qs.modules.quicksettings
Rectangle {
id: root
// A catalog entity: { id, sourceName, name, state, available, active,
// dimmable, brightnessPct }.
property var entity: ({})
// Whether this light sits in the first four favorites, and therefore in
// Control Center.
property bool featured: false
readonly property string entityId: String(root.entity.id ?? "")
readonly property bool busy: HomeAssistant.isBusy(root.entityId)
readonly property int pending: HomeAssistant.pendingFor(root.entityId)
readonly property string entityError: HomeAssistant.errorFor(root.entityId)
readonly property bool showsSlider: root.entity.dimmable === true && root.entity.available === true
radius: 14
color: Theme.alpha(Theme.fg, 0.05)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
implicitHeight: header.implicitHeight + (showsSlider ? 36 : 0)
+ (entityError !== "" ? errorLine.implicitHeight + 4 : 0) + 22
Row {
id: header
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 11
spacing: 11
Rectangle {
id: power
width: 30
height: 29
radius: 10
anchors.verticalCenter: parent.verticalCenter
color: root.entity.active === true
? Theme.alpha(Theme.warn, 0.16)
: Theme.alpha(Theme.fg, 0.07)
border.width: 1
border.color: root.entity.active === true
? Theme.alpha(Theme.warn, 0.4)
: Theme.alpha(Theme.fg, 0.09)
opacity: root.entity.available === true && !root.busy ? 1 : 0.45
Text {
anchors.centerIn: parent
text: "\u{F0335}"
color: root.entity.active === true ? Theme.warn : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 14
}
MouseArea {
anchors.fill: parent
enabled: root.entity.available === true && !root.busy
cursorShape: Qt.PointingHandCursor
onClicked: HomeAssistant.toggleEntity(root.entityId)
}
Accessible.role: Accessible.Button
Accessible.name: (root.entity.active === true ? "Turn off " : "Turn on ")
+ String(root.entity.name ?? root.entity.sourceName ?? "")
}
Column {
width: parent.width - power.width - parent.spacing
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Row {
width: parent.width
spacing: 8
Text {
text: String(root.entity.name ?? root.entity.sourceName ?? "")
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Rectangle {
visible: root.featured
anchors.verticalCenter: parent.verticalCenter
width: featuredCaption.implicitWidth + 16
height: 19
radius: Theme.pillRadius
color: Theme.alpha(Theme.accent, 0.1)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.2)
Text {
id: featuredCaption
anchors.centerIn: parent
text: "Control Center"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: 9
font.weight: Font.DemiBold
}
}
}
Text {
width: parent.width
text: {
if (root.entity.available !== true)
return "Unavailable";
if (root.entity.active !== true)
return "Off";
const pct = root.pending >= 0 ? root.pending : Number(root.entity.brightnessPct ?? 0);
return root.entity.dimmable === true ? "On · " + pct + "%" : "On";
}
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
HomeBrightnessSlider {
visible: root.showsSlider
anchors.left: parent.left
anchors.leftMargin: 11
anchors.right: parent.right
anchors.rightMargin: 11
anchors.top: header.bottom
accessibleName: String(root.entity.name ?? root.entity.sourceName ?? "Light") + " brightness"
value: root.pending >= 0 ? root.pending : Number(root.entity.brightnessPct ?? 0)
onCommitted: value => HomeAssistant.setBrightness(root.entityId, value)
}
Text {
id: errorLine
visible: root.entityError !== ""
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 11
text: root.entityError === "unreachable"
? "Home Assistant did not respond"
: "That change did not apply"
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
@@ -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);
}
}
}
}
@@ -0,0 +1,83 @@
// A quick action on the Home overview: one glyph, one name, one line of state,
// one tap. The fixed set lives in HomePage; this only draws and reports.
import QtQuick
import qs.config
Rectangle {
id: root
property string glyph
property color glyphColor: Theme.accent
property string label
property string caption
property bool interactive: true
signal activated()
radius: 14
color: tileArea.containsMouse && root.interactive
? Theme.alpha(Theme.fg, 0.09)
: Theme.alpha(Theme.fg, 0.05)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
implicitHeight: body.implicitHeight + 24
Column {
id: body
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 12
spacing: 7
Rectangle {
width: 30
height: 30
radius: 10
color: Theme.alpha(root.glyphColor, 0.13)
Text {
anchors.centerIn: parent
text: root.glyph
color: root.glyphColor
font.family: Theme.fontMono
font.pixelSize: 14
}
}
Text {
width: parent.width
text: root.label
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.caption
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
maximumLineCount: 2
elide: Text.ElideRight
}
}
MouseArea {
id: tileArea
anchors.fill: parent
hoverEnabled: true
cursorShape: root.interactive ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (root.interactive) root.activated()
}
Accessible.role: Accessible.Button
Accessible.name: root.label
Accessible.description: root.caption
}
@@ -1,12 +1,21 @@
// My Home the Home Assistant half of the old Home & Phone page, promoted to
// a tab of Home and taught about rooms.
//
// Lights render grouped by Home Assistant areas when the helper can read them
// (HomeAssistant.rooms); a setup without areas degrades to one flat section
// with no header, which is exactly the old behavior. The favorites editor and
// the connection card move here unchanged the credential boundary they carry
// is contract-tested and was not worth re-inventing.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "home-phone-page"
title: "Home & Phone"
lede: "Choose what appears in Control Center and keep phone continuity close at hand."
objectName: "my-home-page"
title: "My Home"
lede: root.homeStatus()
property string lightQuery: searchInput.text.trim().toLowerCase()
@@ -23,10 +32,20 @@ SettingsPage {
: (HomeAssistant.catalog.length === 0
? "No lights discovered"
: "All discovered lights are already selected"))
// The first four favorites appear in Control Center; the pill on a tile
// says so. Everything else about featuring is HomePreferences' business.
readonly property var featuredIds: HomeAssistant.selectedEntities
.slice(0, 4).map(entity => String(entity.id))
readonly property var pageDiagnostics: ({
availableLightIds: root.availableLights.map(entity => entity.id),
availableEmptyText: root.availableEmptyText,
homeStatus: root.homeStatus()
homeStatus: root.homeStatus(),
rooms: (HomeAssistant.rooms ?? []).map(room => ({
name: String(room.name ?? ""),
count: (room.lights ?? []).length
}))
})
function homeStatus(): string {
@@ -34,8 +53,12 @@ SettingsPage {
return "Authentication required";
if (HomeAssistant.lastError === "not-configured")
return "Home Assistant is not configured";
if (HomeAssistant.phase === "ready")
return `Connected · ${HomeAssistant.discoveredCount} lights discovered`;
if (HomeAssistant.phase === "ready") {
const named = (HomeAssistant.rooms ?? []).filter(room => String(room.name ?? "") !== "").length;
return named > 0
? `Connected · ${HomeAssistant.discoveredCount} lights across ${named} room${named === 1 ? "" : "s"}`
: `Connected · ${HomeAssistant.discoveredCount} lights discovered`;
}
if (HomeAssistant.phase === "degraded")
return "Last update unavailable · showing saved controls";
if (HomeAssistant.phase === "loading")
@@ -64,6 +87,232 @@ SettingsPage {
}
}
// Rooms
SettingsCard {
visible: HomeAssistant.catalog.length > 0
title: "Lights"
subtitle: (HomeAssistant.rooms ?? []).some(room => String(room.name ?? "") !== "")
? "Grouped by Home Assistant areas. Tap to toggle; drag a track to dim."
: "Tap to toggle; drag a track to dim."
Column {
width: parent.width
spacing: 4
Repeater {
model: HomeAssistant.rooms ?? []
delegate: Column {
id: roomSection
required property var modelData
width: parent.width
spacing: 8
Text {
visible: String(roomSection.modelData.name ?? "") !== ""
text: String(roomSection.modelData.name ?? "").toUpperCase()
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.letterSpacing: 0.8
topPadding: 6
}
Grid {
id: roomGrid
width: parent.width
columns: width >= 560 ? 2 : 1
columnSpacing: 10
rowSpacing: 10
bottomPadding: 6
Repeater {
model: roomSection.modelData.lights ?? []
delegate: HomeLightTile {
required property var modelData
width: roomGrid.columns === 2
? (roomGrid.width - roomGrid.columnSpacing) / 2
: roomGrid.width
entity: modelData
featured: root.featuredIds.indexOf(String(modelData.id)) >= 0
}
}
}
}
}
}
}
// Accessories (the Control Center selection)
SettingsCard {
title: "Accessories"
subtitle: HomeAssistant.selectedEntities.length === 0
? "Select the lights that belong on your Control Center shelf."
: `${Math.min(4, HomeAssistant.selectedEntities.length)} in Control Center · ${HomeAssistant.selectedEntities.length} selected`
Text {
width: parent.width
visible: HomeAssistant.selectedEntities.length === 0
text: "Choose lights below to build your Control Center shelf."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
wrapMode: Text.WordWrap
topPadding: 3
bottomPadding: 13
}
GridView {
id: favoritesGrid
width: parent.width
height: Math.ceil(count / 2) * cellHeight
visible: count > 0
interactive: false
clip: false
cellWidth: width / 2
cellHeight: 116
model: HomeAssistant.selectedEntities
delegate: HomeFavoriteCard {
required property var modelData
width: GridView.view.cellWidth - 6
height: 108
favorite: ({
id: modelData.id,
alias: modelData.name === modelData.sourceName ? "" : modelData.name
})
sourceName: modelData.sourceName
featured: index < 4
canMoveEarlier: index > 0
canMoveLater: index < favoritesGrid.count - 1
onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias)
onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex)
onRemoveRequested: id => HomePreferences.remove(id)
}
}
Rectangle {
width: parent.width
height: 46
visible: HomePreferences.saveError !== ""
radius: 9
color: Theme.alpha(Theme.warn, 0.09)
border.width: 1
border.color: Theme.alpha(Theme.warn, 0.24)
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: retryButton.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: HomePreferences.saveError
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
SettingsButton {
id: retryButton
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: "Retry"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.warn : Theme.alpha(Theme.fg, 0.08)
onClicked: HomePreferences.retrySave()
Keys.onReturnPressed: HomePreferences.retrySave()
Keys.onSpacePressed: HomePreferences.retrySave()
}
}
Rectangle {
width: parent.width
height: 38
radius: 10
color: Theme.alpha(Theme.fg, searchInput.activeFocus ? 0.08 : 0.05)
border.width: searchInput.activeFocus ? 2 : 1
border.color: searchInput.activeFocus
? Theme.alpha(Theme.accent, 0.72)
: Theme.alpha(Theme.fg, 0.065)
Text {
anchors.left: parent.left
anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "\u{F0349}"
color: Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 14
}
TextInput {
id: searchInput
anchors.left: parent.left
anchors.leftMargin: 36
anchors.right: parent.right
anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter
activeFocusOnTab: true
color: Theme.fg
selectionColor: Theme.accent
selectedTextColor: Theme.bgDark
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
clip: true
Text {
anchors.fill: parent
visible: searchInput.text === "" && !searchInput.activeFocus
text: "Search available lights"
color: Theme.fgMuted
font: searchInput.font
verticalAlignment: Text.AlignVCenter
}
}
}
Column {
width: parent.width
visible: root.availableLights.length > 0
Repeater {
model: root.availableLights
AvailableLightRow {
required property var modelData
width: parent.width
entity: modelData
onAddRequested: id => HomePreferences.add(id)
}
}
}
Text {
width: parent.width
visible: root.availableLights.length === 0
text: root.availableEmptyText
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
topPadding: 18
bottomPadding: 10
}
}
// Connection
SettingsCard {
title: "Home Assistant"
subtitle: root.homeStatus()
@@ -216,207 +465,4 @@ SettingsPage {
}
}
}
SettingsCard {
title: "Control Center lights"
subtitle: HomeAssistant.selectedEntities.length === 0
? "Select the lights that belong on your shelf."
: `${Math.min(4, HomeAssistant.selectedEntities.length)} in Control Center · ${HomeAssistant.selectedEntities.length} selected`
Text {
width: parent.width
visible: HomeAssistant.selectedEntities.length === 0
text: "Choose lights below to build your Control Center shelf."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
wrapMode: Text.WordWrap
topPadding: 3
bottomPadding: 13
}
GridView {
id: favoritesGrid
width: parent.width
height: Math.ceil(count / 2) * cellHeight
visible: count > 0
interactive: false
clip: false
cellWidth: width / 2
cellHeight: 116
model: HomeAssistant.selectedEntities
delegate: HomeFavoriteCard {
required property var modelData
width: GridView.view.cellWidth - 6
height: 108
favorite: ({
id: modelData.id,
alias: modelData.name === modelData.sourceName ? "" : modelData.name
})
sourceName: modelData.sourceName
featured: index < 4
canMoveEarlier: index > 0
canMoveLater: index < favoritesGrid.count - 1
onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias)
onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex)
onRemoveRequested: id => HomePreferences.remove(id)
}
}
Rectangle {
width: parent.width
height: 46
visible: HomePreferences.saveError !== ""
radius: 9
color: Theme.alpha(Theme.warn, 0.09)
border.width: 1
border.color: Theme.alpha(Theme.warn, 0.24)
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: retryButton.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: HomePreferences.saveError
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
SettingsButton {
id: retryButton
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: "Retry"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.warn : Theme.alpha(Theme.fg, 0.08)
onClicked: HomePreferences.retrySave()
Keys.onReturnPressed: HomePreferences.retrySave()
Keys.onSpacePressed: HomePreferences.retrySave()
}
}
}
SettingsCard {
title: "Available lights"
subtitle: "Search the Home Assistant catalog by source name or entity ID."
Rectangle {
width: parent.width
height: 38
radius: 10
color: Theme.alpha(Theme.fg, searchInput.activeFocus ? 0.08 : 0.05)
border.width: searchInput.activeFocus ? 2 : 1
border.color: searchInput.activeFocus
? Theme.alpha(Theme.accent, 0.72)
: Theme.alpha(Theme.fg, 0.065)
Text {
anchors.left: parent.left
anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "\u{F0349}"
color: Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 14
}
TextInput {
id: searchInput
anchors.left: parent.left
anchors.leftMargin: 36
anchors.right: parent.right
anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter
activeFocusOnTab: true
color: Theme.fg
selectionColor: Theme.accent
selectedTextColor: Theme.bgDark
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
clip: true
Text {
anchors.fill: parent
visible: searchInput.text === "" && !searchInput.activeFocus
text: "Search available lights"
color: Theme.fgMuted
font: searchInput.font
verticalAlignment: Text.AlignVCenter
}
}
}
Column {
width: parent.width
visible: root.availableLights.length > 0
Repeater {
model: root.availableLights
AvailableLightRow {
required property var modelData
width: parent.width
entity: modelData
onAddRequested: id => HomePreferences.add(id)
}
}
}
Text {
width: parent.width
visible: root.availableLights.length === 0
text: root.availableEmptyText
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
topPadding: 18
bottomPadding: 10
}
}
SettingsCard {
title: "Phone continuity"
subtitle: "Keep the Messages handoff independent from phone connectivity."
SettingRow {
label: "Messages"
detail: "Opens BlueBubbles"
divider: false
controlWidth: 204
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 12
Text {
anchors.verticalCenter: parent.verticalCenter
text: SystemSettings.bluebubblesAvailable ? "Installed" : "Unavailable"
color: SystemSettings.bluebubblesAvailable ? Theme.ok : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
SettingsButton {
id: openBlueBubblesButton
text: "Open"
enabled: SystemSettings.bluebubblesAvailable
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: SystemSettings.openApplication("bluebubbles")
Keys.onReturnPressed: if (enabled) SystemSettings.openApplication("bluebubbles")
Keys.onSpacePressed: if (enabled) SystemSettings.openApplication("bluebubbles")
}
}
}
}
}
@@ -0,0 +1,258 @@
// Phone — the continuity half of the old Home & Phone page, promoted to a tab
// of Home and given vitals.
//
// The vitals strip reads battery and cell signal from KDE Connect's plugin
// D-Bus objects, which exist only while the phone is paired and reachable — a
// missing datum renders as an em-dash, never an error. Actions mirror Control
// Center's semantics exactly: KDE Connect actions need the phone nearby;
// Messages needs only BlueBubbles.
import QtQuick
import QtQuick.Dialogs
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "phone-page"
title: "Phone"
lede: {
if (!KdeConnect.available)
return "KDE Connect is unavailable — open System Health for the service.";
if (!KdeConnect.preferredPhone)
return "Pair a phone with KDE Connect to reach it from here.";
return String(KdeConnect.preferredPhone.name)
+ (KdeConnect.phoneReachable ? " · nearby on Wi-Fi" : " · not nearby");
}
readonly property bool actionsReady: KdeConnect.phoneReachable && !KdeConnect.transferActive
function localPath(selectedUrl: url): string {
const value = String(selectedUrl);
if (!value.startsWith("file://"))
return "";
return decodeURIComponent(value.slice(7));
}
FileDialog {
id: fileDialog
title: KdeConnect.preferredPhone
? "Send a file to " + KdeConnect.preferredPhone.name : "Send a file"
fileMode: FileDialog.OpenFile
nameFilters: ["All files (*)"]
onAccepted: {
const path = root.localPath(selectedFile);
if (path !== "")
KdeConnect.sendFile(path);
}
}
// ── Vitals ──────────────────────────────────────────────────────────────
Row {
id: vitals
width: parent.width
spacing: 12
Repeater {
model: [
{
value: KdeConnect.phoneBattery
? KdeConnect.phoneBattery.charge + "%" : "—",
caption: KdeConnect.phoneBattery
? (KdeConnect.phoneBattery.charging ? "Battery · charging" : "Battery")
: "Battery"
},
{
value: KdeConnect.phoneSignal
? String(KdeConnect.phoneSignal.networkType) : "—",
caption: KdeConnect.phoneSignal
? "Signal · " + KdeConnect.phoneSignal.strength + " of 4"
: "Signal"
},
{
value: KdeConnect.phoneReachable
? "Nearby"
: (KdeConnect.pairedCount > 0 ? "Away" : "Unpaired"),
caption: KdeConnect.phoneReachable
? "Reachable on Wi-Fi"
: (KdeConnect.pairedCount > 0
? "Actions wait for the phone to reconnect"
: "Pair a device to continue")
}
]
delegate: Rectangle {
id: vital
required property var modelData
width: (vitals.width - vitals.spacing * 2) / 3
height: 66
radius: 12
color: Theme.alpha(Theme.fg, 0.045)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.06)
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 14
anchors.rightMargin: 14
spacing: 3
Text {
text: String(vital.modelData.value)
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge + 3
font.weight: Font.DemiBold
font.features: Theme.tabularFigures
}
Text {
width: parent.width
text: String(vital.modelData.caption)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
}
}
// ── Reach it ────────────────────────────────────────────────────────────
SettingsCard {
title: "Reach it"
subtitle: KdeConnect.transferActive
? "Sending " + KdeConnect.transferFileName + "…"
: "Everything you actually do weekly, one card."
SettingRow {
label: "Ring"
detail: "Even when silenced"
controlWidth: 110
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Ring"
enabled: root.actionsReady && KdeConnect.supports("ring")
onClicked: KdeConnect.ring()
}
}
SettingRow {
label: "Clipboard"
detail: "Send what you just copied"
controlWidth: 110
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Send"
enabled: root.actionsReady && KdeConnect.supports("clipboard")
onClicked: KdeConnect.sendClipboard()
}
}
SettingRow {
label: "Send a file"
detail: KdeConnect.recentExchange
? "Last sent " + KdeConnect.recentExchange.fileName
: "Lands in the phone's downloads"
divider: KdeConnect.transferActive
controlWidth: 110
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Choose…"
enabled: root.actionsReady && KdeConnect.supports("share")
onClicked: fileDialog.open()
}
}
SettingRow {
visible: KdeConnect.transferActive
label: "Sending " + KdeConnect.transferFileName
detail: "To " + KdeConnect.transferDeviceName
divider: false
controlWidth: 110
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Cancel"
tone: "danger"
onClicked: KdeConnect.cancelTransfer()
}
}
}
// ── Messages ────────────────────────────────────────────────────────────
SettingsCard {
title: "Messages"
subtitle: "Kept independent from phone connectivity."
SettingRow {
label: "iMessage"
detail: "Opens BlueBubbles"
divider: false
controlWidth: 204
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 12
Text {
anchors.verticalCenter: parent.verticalCenter
text: SystemSettings.bluebubblesAvailable ? "Installed" : "Unavailable"
color: SystemSettings.bluebubblesAvailable ? Theme.ok : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
SettingsButton {
id: openBlueBubblesButton
text: "Open"
enabled: SystemSettings.bluebubblesAvailable
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: SystemSettings.openApplication("bluebubbles")
Keys.onReturnPressed: if (enabled) SystemSettings.openApplication("bluebubbles")
Keys.onSpacePressed: if (enabled) SystemSettings.openApplication("bluebubbles")
}
}
}
}
// ── Device ──────────────────────────────────────────────────────────────
SettingsCard {
title: "Device"
ActionRow {
label: "Connection"
detail: "Ask the service to look again"
action: "Refresh"
onTriggered: KdeConnect.refresh()
}
ActionRow {
label: "Device settings"
detail: "Pairing, plugins and service health"
action: "Open"
divider: false
onTriggered: ShellState.openSettings("connectivity")
}
}
}
@@ -6,13 +6,21 @@ such rather than half-reimplemented.
## Navigation
The sidebar lists fifteen **categories**, not one row per page. A category
covering several subjects — Input, Network & Sharing, Applications, Users &
Accounts, Privacy & Security, System — draws a tab strip above the page, and
each of its **leaf** pages is one tab; a category with a single subject is a
leaf itself and shows no strip. The column used to be a flat list of thirty-one
pages, which made finding Printers a scan of the whole thing. Grouped, it sits
under Network & Sharing, where somebody looking for it already expects it.
The sidebar lists fourteen **categories**, not one row per page. A category
covering several subjects — Home, Input, Network & Sharing, Applications,
Users & Accounts, Privacy & Security, System — draws a tab strip above the
page, and each of its **leaf** pages is one tab; a category with a single
subject is a leaf itself and shows no strip. The column used to be a flat list
of thirty-one pages, which made finding Printers a scan of the whole thing.
Grouped, it sits under Network & Sharing, where somebody looking for it already
expects it.
**Home** carries three tabs: **Overview** (`home`, the quick-action tiles,
findings, glance cards, Today and Weather that the shell opens by default),
**My Home** (`my-home`, Home Assistant lights grouped by room, accessories, and
the connection card) and **Phone** (`phone`, vitals, ring, clipboard, send file,
and BlueBubbles Messages). It absorbed the former standalone Home & Phone
category, whose `home-phone` id is now retired.
Leaf pages are ordinary `SettingsPage` files and know nothing about this.
`SettingsShell` draws the strip and hosts the page inside it, so moving a page
@@ -29,6 +37,15 @@ always used, so every existing deep link, IPC call, and search hit keeps
working and now lands on the exact tab. A category id is accepted too, and
resolves to that category's first available tab.
**A retired id never stops resolving.** When a page is split, merged, or
renamed, its old id goes into `SettingsRoutes.retired` — a map from the dead id
to the leaf that absorbed its content — rather than being deleted. Old Vicinae
commands, shell history, notification handoffs, and muscle memory all hold
those ids, and `resolve()` checks the map first, so `home-phone` still lands on
**My Home** instead of falling back to Home. Retired ids are aliases only: they
are not leaves, so nothing generates a command, a tab, or a doc heading for
them.
Availability gating belongs to the strip rather than the sidebar:
`SettingsRoutes.pageAvailable()` drops the Containers tab on a machine without
podman and Snapshots without a snapper configuration, but only once a scan has
@@ -6,9 +6,9 @@ Rectangle {
id: root
property var hostWindow: null
readonly property var homePhoneDiagnostics: pageLoader.status === Loader.Ready
readonly property var myHomeDiagnostics: pageLoader.status === Loader.Ready
&& pageLoader.item
&& pageLoader.item.objectName === "home-phone-page"
&& pageLoader.item.objectName === "my-home-page"
? pageLoader.item.pageDiagnostics
: ({})
readonly property var healthDiagnostics: pageLoader.status === Loader.Ready
@@ -134,7 +134,8 @@ Rectangle {
case "appearance": return appearancePage;
case "displays": return displaysPage;
case "connectivity": return connectivityPage;
case "home-phone": return homePhonePage;
case "my-home": return myHomePage;
case "phone": return phonePage;
case "desktop": return desktopPage;
case "sound": return soundPage;
case "gaming": return gamingPage;
@@ -213,7 +214,8 @@ Rectangle {
Component { id: appearancePage; AppearancePage {} }
Component { id: displaysPage; DisplaysPage {} }
Component { id: connectivityPage; ConnectivityPage {} }
Component { id: homePhonePage; HomePhonePage {} }
Component { id: myHomePage; MyHomePage {} }
Component { id: phonePage; PhonePage {} }
Component { id: desktopPage; DesktopPage {} }
Component { id: soundPage; SoundPage {} }
Component { id: gamingPage; GamingPage {} }
@@ -6,7 +6,7 @@ import qs.services
FloatingWindow {
id: root
readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics
readonly property var myHomeDiagnostics: settingsShell.myHomeDiagnostics
title: "Settings"
visible: ShellState.settingsOpen
@@ -11,7 +11,6 @@ AvatarCropper 1.0 AvatarCropper.qml
PickerRow 1.0 PickerRow.qml
SettingsTabs 1.0 SettingsTabs.qml
GamingPage 1.0 GamingPage.qml
HomePhonePage 1.0 HomePhonePage.qml
HomeFavoriteCard 1.0 HomeFavoriteCard.qml
AvailableLightRow 1.0 AvailableLightRow.qml
DesktopPage 1.0 DesktopPage.qml
@@ -74,6 +73,10 @@ TimeOfDayRow 1.0 TimeOfDayRow.qml
LocationPicker 1.0 LocationPicker.qml
FontPicker 1.0 FontPicker.qml
MousePage 1.0 MousePage.qml
MyHomePage 1.0 MyHomePage.qml
PhonePage 1.0 PhonePage.qml
HomeQuickTile 1.0 HomeQuickTile.qml
HomeLightTile 1.0 HomeLightTile.qml
DictationPage 1.0 DictationPage.qml
TextEntryRow 1.0 TextEntryRow.qml
PrivacyPage 1.0 PrivacyPage.qml