Files
harbour-aira/qml/pages/MainPage.qml
T
Carlo 8e13180ffb 0.7.5: la Home manda le sue misure al bridge
Dopo tre diagnosi dedotte e tre volte sbagliate, le coordinate arrivano dal
telefono: il timer 'misura' le manda a /layout, il bridge le scrive nel suo
registro. Nel QML arrivano versione, schermo, contenuto e y/h/testo di ogni
riga.
2026-10-11 17:16:10 +02:00

419 lines
17 KiB
QML

import QtQuick 2.2
import Sailfish.Silica 1.0
import "../components"
// Home: una schermata da GUARDARE, non un modulo da compilare.
//
// Stato in una riga, le due letture che contano (casa e acqua calda), tre
// azioni rapide. Le regolazioni stanno in SetpointsPage, gli altri comandi in
// CommandsPage, i numeri in DataPage, i dettagli in PlantPage: qui solo quello
// che si guarda molte volte al giorno, senza scorrere (docs/UX-STUDIO.md).
Page {
id: page
allowedOrientations: Orientation.All
function raw(key) {
return api.summary[key]
}
// I numeri si formattano nel C++ (ApiClient::formatValue): cosi' il
// separatore decimale segue la lingua dell'app, non il toFixed di JS.
function fmt(value) { return api.formatValue(value) }
function count(key) {
var v = page.raw(key)
if (v === undefined || v === null)
return "—"
return String(Math.round(Number(v)))
}
// Colore del pallino di stato. Il livello (0 regolare, 1 modo attivo,
// 2 avviso, 3 allarme serio) lo calcola il C++; qui si sceglie il colore,
// e l'ambra quando i numeri sono vecchi: un dato vecchio non e' un dato
// buono. Rosso solo quando e' la macchina a essere ferma o guasta: un
// avviso (per esempio il compressore limitato dalla temperatura esterna)
// e' ambra, come nell'app ufficiale.
function statusColor() {
if (!api.hasData || api.stale)
return "orange"
var livello = api.statusLevel()
if (livello === 3)
return Theme.errorColor
if (livello === 2)
return "#ffb300"
if (livello === 1)
return Theme.highlightColor
return "#4caf50"
}
// Quanto e' carica l'acqua calda verso l'obiettivo (da 10 °C in su).
function waterProgress() {
var ora = Number(page.raw("dhw_current"))
var obiettivo = Number(page.raw("dhw_target"))
if (!(obiettivo > 10) || isNaN(ora))
return 0
return (ora - 10) / (obiettivo - 10)
}
// Riga sotto il titolo: dice anche quando i numeri sono vecchi.
function updateText() {
if (!api.hasData)
return qsTr("No data yet")
if (api.stale)
return qsTr("Not updated · last %1").arg(api.lastUpdate)
if (api.lastUpdate.length === 0)
return ""
return qsTr("Updated at %1").arg(api.lastUpdate)
}
function openSettings() {
pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
}
function openCommands() {
pageStack.push(Qt.resolvedUrl("CommandsPage.qml"))
}
function openPlant() {
pageStack.push(Qt.resolvedUrl("PlantPage.qml"))
}
function openData() {
pageStack.push(Qt.resolvedUrl("DataPage.qml"))
}
// UNA sola assegnazione: in Qt 5.6 assegnare Component.onCompleted due volte
// e' un errore di compilazione del file, e la pagina non si crea piu'
// ("Type MainPage unavailable" e app senza Home).
Component.onCompleted: {
if (api.configured)
api.refresh()
misura.start()
}
// Fuori dal pulsante: dentro il pulsante i figli finiscono nel contentItem.
RemorseItem { id: remorse }
SilicaFlickable {
id: flick
anchors.fill: parent
contentHeight: col.height + Theme.paddingLarge
// I menu pulley stanno DENTRO la vista: come figli diretti della Page
// fanno deref su null (TypeError + crash).
PullDownMenu {
MenuItem {
text: qsTr("Refresh")
onClicked: api.refresh()
}
MenuItem {
text: qsTr("Data")
onClicked: page.openData()
}
MenuItem {
text: qsTr("Plant")
onClicked: page.openPlant()
}
MenuItem {
text: qsTr("Settings")
onClicked: page.openSettings()
}
}
Column {
id: col
width: parent.width
spacing: Theme.paddingSmall
PageHeader {
title: "Aira"
description: page.updateText()
}
// ---- bridge non configurato ------------------------------------ //
Label {
visible: !api.configured
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: qsTr("Bridge not configured. Open the settings and enter the server address and the token.")
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.Wrap
}
Button {
visible: !api.configured
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Settings")
onClicked: page.openSettings()
}
// ---- stato in una riga ----------------------------------------- //
Item {
visible: api.configured
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
height: Math.max(stato.height, pallino.height, attesa.height)
Rectangle {
id: pallino
width: Theme.fontSizeSmall * 0.6
height: width
radius: width / 2
color: page.statusColor()
anchors.verticalCenter: parent.verticalCenter
}
Label {
id: stato
anchors {
left: pallino.right
leftMargin: Theme.paddingMedium
right: attesa.left
rightMargin: Theme.paddingMedium
verticalCenter: parent.verticalCenter
}
text: api.stateShort()
color: Theme.primaryColor
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
BusyIndicator {
id: attesa
anchors {
right: parent.right
verticalCenter: parent.verticalCenter
}
size: BusyIndicatorSize.Small
running: api.busy
visible: api.busy
}
}
// ---- le due letture che contano -------------------------------- //
Row {
id: letture
visible: api.configured
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
spacing: Theme.paddingSmall
ReadingCell {
width: (letture.width - letture.spacing) / 2
title: qsTr("House")
value: page.fmt(page.raw("zone1_temperature")) + " °C"
subtitle: qsTr("target %1 °C").arg(page.fmt(page.raw("zone1_setpoint")))
valueColor: api.stale ? Theme.secondaryColor : Theme.primaryColor
onTapped: pageStack.push(Qt.resolvedUrl("SetpointsPage.qml"),
{ "section": "house" })
}
ReadingCell {
width: (letture.width - letture.spacing) / 2
title: qsTr("Hot water")
value: page.fmt(page.raw("dhw_current")) + " °C"
subtitle: qsTr("target %1 °C").arg(page.fmt(page.raw("dhw_target")))
progress: page.waterProgress()
showBar: true
valueColor: api.stale ? Theme.secondaryColor : Theme.primaryColor
onTapped: pageStack.push(Qt.resolvedUrl("SetpointsPage.qml"),
{ "section": "water" })
}
}
// Una sola riga, sempre della stessa altezza: mentre il boost corre
// il conto alla rovescia prende il posto del suggerimento senza
// spostare niente sotto. Il carattere e' quello del suggerimento
// (fontSizeExtraSmall): se fosse piu' grande, la riga piu' alta
// spingerebbe giu' i pulsanti e la riga di contorno finirebbe loro
// addosso (segnalato da Carlo).
Item {
visible: api.configured
width: parent.width
height: Math.max(contoRovescia.implicitHeight,
suggerimento.implicitHeight)
Label {
id: contoRovescia
visible: api.boostActive
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: qsTr("Boost · %1").arg(api.boostCountdown)
color: Theme.highlightColor
font.pixelSize: Theme.fontSizeExtraSmall
font.bold: true
}
Label {
id: suggerimento
visible: !api.boostActive
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: qsTr("Tap a value to adjust it")
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
}
}
// ---- azioni rapide --------------------------------------------- //
SectionHeader {
visible: api.configured
text: qsTr("Quick actions")
}
Row {
visible: api.configured
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
spacing: Theme.paddingSmall
Button {
id: boostBtn
width: (parent.width - 2 * parent.spacing) / 3
// In Silica il tasto piglia l'altezza dal tema
// (itemSizeExtraSmall ~56 px), ma icona E etichetta ne
// occupano ~110: senza questa riga l'etichetta esce dalla
// scatola e la riga successiva della colonna le finisce
// sopra (segnalato da Carlo: "la riga sotto copre i
// pulsanti"). Vedi tests/label_fit_check.py.
height: Theme.itemSizeMedium
// Mentre il boost e' in corso il tasto ferma invece di
// avviare: dice cosa succede premendolo, come il tasto
// dell'assenza. "Stop boost" per intero non ci sta accanto
// all'icona (92 px contro 73 disponibili): resta "Stop",
// che il glifo di stop rende inequivocabile.
text: api.boostActive ? qsTr("Stop") : qsTr("Boost")
// La casella dell'icona la decide il tema: senza, l'Image
// prende la misura del file e si mangia il testo.
icon.source: api.boostActive ? "../icons/quick-boost-stop.png"
: "../icons/quick-boost.png"
icon.width: Theme.iconSizeExtraSmall
icon.height: Theme.iconSizeExtraSmall
onClicked: remorse.execute(
boostBtn,
api.boostActive ? qsTr("Stopping DHW boost")
: qsTr("Starting DHW boost"),
function() {
api.sendCommand(api.boostActive ? "hot_water_boost_off"
: "hot_water_boost_on")
})
}
Button {
id: assenzaBtn
width: (parent.width - 2 * parent.spacing) / 3
height: Theme.itemSizeMedium
// Il pulsante dice cosa succede premendolo, non lo stato.
// Etichette corte: nel tasto, largo un terzo di schermo,
// icona e testo devono starci insieme.
text: page.raw("away_mode") ? qsTr("Home") : qsTr("Away")
icon.source: "../icons/quick-away.png"
icon.width: Theme.iconSizeExtraSmall
icon.height: Theme.iconSizeExtraSmall
onClicked: {
if (page.raw("away_mode")) {
remorse.execute(assenzaBtn, qsTr("Away mode off"),
function() { api.sendCommand("away_mode_off") })
} else {
// L'assenza si imposta da un popup con le date
// (richiesta dell'utente): si apre qui e il comando
// parte solo se si accetta.
var scelta = pageStack.push(Qt.resolvedUrl("AwayDialog.qml"))
scelta.accepted.connect(function() {
api.sendCommand("away_mode_on", null, {
"start": Qt.formatDate(scelta.inizio, "yyyy-MM-dd"),
"end": Qt.formatDate(scelta.fine, "yyyy-MM-dd")
})
})
}
}
}
Button {
width: (parent.width - 2 * parent.spacing) / 3
height: Theme.itemSizeMedium
// "Comandi" (8 lettere) non ci sta accanto a un'icona in un
// terzo di schermo: la pagina resta "Comandi impianto".
text: qsTr("More")
icon.source: "../icons/quick-commands.png"
icon.width: Theme.iconSizeExtraSmall
icon.height: Theme.iconSizeExtraSmall
onClicked: page.openCommands()
}
}
// ---- letture di contorno (si toccano: portano a Impianto) ------ //
BackgroundItem {
visible: api.configured
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
height: contornoTxt.height + Theme.paddingMedium
onClicked: page.openPlant()
Label {
id: contornoTxt
anchors {
left: parent.left
right: parent.right
verticalCenter: parent.verticalCenter
}
text: qsTr("Outside %1 °C · Thermostat %2 °C · Zones %3")
.arg(page.fmt(page.raw("outdoor_temperature")))
.arg(page.fmt(page.raw("thermostat_temperature")))
.arg(page.count("zones"))
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
wrapMode: Text.Wrap
}
}
Item { width: 1; height: Theme.paddingMedium }
}
}
NoticeBar { }
// La disposizione non e' definitiva subito: si misura un secondo dopo.
// Questa e' la riga da leggere alla prima build sul telefono: la Home deve
// stare in una schermata sola, se "contenuto" supera "schermo" c'e' ancora
// qualcosa da togliere (o da accettare, ma sapendolo).
Timer {
id: misura
interval: 1000
onTriggered: {
console.log("harbour-aira home: contenuto", flick.contentHeight,
"schermo", height)
// Ogni riga della Home con la sua posizione: se una riga finisce
// addosso a un'altra, qui si legge quale e di quanto. E' la riga da
// mandare al primo avvio se la disposizione non torna.
var righe = []
for (var i = 0; i < col.children.length; ++i) {
var r = col.children[i]
if (r.height === undefined)
continue
console.log("harbour-aira riga", i, "y", Math.round(r.y),
"h", Math.round(r.height),
r.text !== undefined ? JSON.stringify(r.text) : "")
righe.push({
"i": i,
"y": Math.round(r.y),
"h": Math.round(r.height),
"t": r.text !== undefined ? String(r.text) : ""
})
}
// Le stesse misure vanno anche al bridge, che le scrive nel
// registro: cosi' l'agente le legge da li' invece di chiedere il
// terminale o di decifrare uno screenshot.
if (api.configured)
api.reportLayout({
"versione": appVersion,
"schermo": Math.round(height),
"contenuto": Math.round(flick.contentHeight),
"righe": righe
})
}
}
}