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
469 lines
18 KiB
QML
469 lines
18 KiB
QML
// 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: "my-home-page"
|
|
title: "My Home"
|
|
lede: root.homeStatus()
|
|
|
|
property string lightQuery: searchInput.text.trim().toLowerCase()
|
|
|
|
readonly property var availableLights: HomeAssistant.catalog.filter(entity => {
|
|
if (HomeAssistant.selectedEntities.some(selected => selected.id === entity.id))
|
|
return false;
|
|
const haystack = (entity.sourceName + " " + entity.id).toLowerCase();
|
|
return root.lightQuery === "" || haystack.includes(root.lightQuery);
|
|
})
|
|
readonly property string availableEmptyText: root.availableLights.length > 0
|
|
? ""
|
|
: (root.lightQuery !== ""
|
|
? "No lights match that search"
|
|
: (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(),
|
|
rooms: (HomeAssistant.rooms ?? []).map(room => ({
|
|
name: String(room.name ?? ""),
|
|
count: (room.lights ?? []).length
|
|
}))
|
|
})
|
|
|
|
function homeStatus(): string {
|
|
if (HomeAssistant.lastError === "authentication-required")
|
|
return "Authentication required";
|
|
if (HomeAssistant.lastError === "not-configured")
|
|
return "Home Assistant is not configured";
|
|
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")
|
|
return "Connecting to Home Assistant";
|
|
return "Home Assistant is unavailable";
|
|
}
|
|
|
|
// The entity list is a one-time migration seed for the Control Center
|
|
// selection, not the light catalog -- the helper discovers that live from
|
|
// Home Assistant. It is passed back unchanged so that saving a URL or a
|
|
// token cannot disturb the seed.
|
|
function saveHomeAssistantConfig(): void {
|
|
HomeAssistantConfig.save(
|
|
homeUrlInput.text,
|
|
HomeAssistantConfig.entities.join(", "),
|
|
homeTokenInput.text
|
|
);
|
|
}
|
|
|
|
Connections {
|
|
target: HomeAssistantConfig
|
|
|
|
function onConfigurationSaved(): void {
|
|
homeTokenInput.clear();
|
|
homeUrlInput.text = HomeAssistantConfig.url;
|
|
}
|
|
}
|
|
|
|
// ── 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()
|
|
|
|
SettingRow {
|
|
label: "Connection"
|
|
detail: HomeAssistantConfig.tokenConfigured
|
|
? "A long-lived access token is stored privately"
|
|
: "Paste a long-lived access token to connect"
|
|
value: HomeAssistantConfig.configured ? "Configured" : "Not configured"
|
|
}
|
|
|
|
SettingRow {
|
|
label: "Server URL"
|
|
detail: "The local or remote address of Home Assistant"
|
|
controlWidth: 330
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: Theme.pillRadius
|
|
color: Theme.alpha(Theme.fg, 0.07)
|
|
border.width: homeUrlInput.activeFocus ? 2 : 1
|
|
border.color: homeUrlInput.activeFocus
|
|
? Theme.alpha(Theme.accent, 0.55) : "transparent"
|
|
|
|
TextInput {
|
|
id: homeUrlInput
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 12
|
|
anchors.rightMargin: 12
|
|
activeFocusOnTab: true
|
|
text: HomeAssistantConfig.url
|
|
color: Theme.fg
|
|
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
|
selectedTextColor: Theme.fg
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
verticalAlignment: TextInput.AlignVCenter
|
|
clip: true
|
|
|
|
Text {
|
|
anchors.fill: parent
|
|
visible: homeUrlInput.text === ""
|
|
text: "https://homeassistant.local:8123"
|
|
color: Theme.fgMuted
|
|
font: homeUrlInput.font
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingRow {
|
|
label: "Access token"
|
|
detail: HomeAssistantConfig.tokenConfigured
|
|
? "Stored · leave blank to keep it"
|
|
: "Create one in your Home Assistant profile"
|
|
controlWidth: 330
|
|
|
|
PasswordField {
|
|
id: homeTokenInput
|
|
anchors.fill: parent
|
|
placeholder: HomeAssistantConfig.tokenConfigured
|
|
? "Stored token" : "Long-lived access token"
|
|
onAccepted: root.saveHomeAssistantConfig()
|
|
}
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: HomeAssistantConfig.lastError !== ""
|
|
text: HomeAssistantConfig.lastError
|
|
color: Theme.danger
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
wrapMode: Text.WordWrap
|
|
bottomPadding: 9
|
|
}
|
|
|
|
SettingRow {
|
|
label: "Private configuration"
|
|
detail: "Saved with owner-only permissions in a private environment file"
|
|
controlWidth: 216
|
|
|
|
Row {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 8
|
|
|
|
SettingsButton {
|
|
id: clearTokenButton
|
|
text: "Clear token"
|
|
enabled: HomeAssistantConfig.tokenConfigured && !HomeAssistantConfig.busy
|
|
activeFocusOnTab: enabled
|
|
border.width: activeFocus ? 2 : 1
|
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
|
onClicked: HomeAssistantConfig.clearToken()
|
|
Keys.onReturnPressed: if (enabled) HomeAssistantConfig.clearToken()
|
|
Keys.onSpacePressed: if (enabled) HomeAssistantConfig.clearToken()
|
|
}
|
|
|
|
SettingsButton {
|
|
id: saveHomeConfigButton
|
|
text: HomeAssistantConfig.busy ? "Saving…" : "Save"
|
|
tone: "accent"
|
|
enabled: !HomeAssistantConfig.busy
|
|
activeFocusOnTab: enabled
|
|
border.width: activeFocus ? 2 : 0
|
|
border.color: activeFocus ? Theme.fg : "transparent"
|
|
onClicked: root.saveHomeAssistantConfig()
|
|
Keys.onReturnPressed: if (enabled) root.saveHomeAssistantConfig()
|
|
Keys.onSpacePressed: if (enabled) root.saveHomeAssistantConfig()
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingRow {
|
|
label: "Light catalog"
|
|
detail: "Light state is read through the Home Assistant helper."
|
|
divider: false
|
|
controlWidth: 176
|
|
|
|
Row {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 8
|
|
|
|
SettingsButton {
|
|
id: refreshButton
|
|
text: "Refresh"
|
|
activeFocusOnTab: true
|
|
border.width: activeFocus ? 2 : 1
|
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
|
onClicked: HomeAssistant.refresh()
|
|
Keys.onReturnPressed: HomeAssistant.refresh()
|
|
Keys.onSpacePressed: HomeAssistant.refresh()
|
|
}
|
|
|
|
SettingsButton {
|
|
id: openHomeButton
|
|
text: "Open"
|
|
activeFocusOnTab: true
|
|
border.width: activeFocus ? 2 : 1
|
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
|
onClicked: HomeAssistant.open()
|
|
Keys.onReturnPressed: HomeAssistant.open()
|
|
Keys.onSpacePressed: HomeAssistant.open()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|