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 }) } } }