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:
@@ -301,6 +301,6 @@ Item {
|
||||
|
||||
function openHomeSettings(): void {
|
||||
ShellState.close();
|
||||
ShellState.openSettings("home-phone");
|
||||
ShellState.openSettings("my-home");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+255
-209
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The first thing a new machine shows.
|
||||
//
|
||||
// Panama has fifteen settings categories and thirty-odd pages inside them,
|
||||
// Panama has fourteen settings categories and thirty-odd pages inside them,
|
||||
// which is the opposite of the usual problem: somebody arriving from GNOME,
|
||||
// macOS or Windows cannot tell which four things matter. This is those four
|
||||
// things, once, on the first start.
|
||||
|
||||
Reference in New Issue
Block a user