From 248e0db8998c2900ea3e9f59d242ffff8b00a68b Mon Sep 17 00:00:00 2001 From: Carlo Baratto Date: Fri, 9 Oct 2026 09:21:47 +0200 Subject: [PATCH] schermate di configurazione complete: razze disponibili, mio pollaio, registro galline, clienti, backup/ripristino nel formato dell'app Android, lingua a runtime, introduzione e informazioni - datastore: export/import JSON con le chiavi camelCase dell'app Android (breedSettings/reservations/customers), scorte e ultimo aggiornamento preservati al ripristino - settings: flag 'introduzione vista' - desktop: permesso Documents (dove finisce il backup) - nuovi controlli: tools/check_qml.py (chiavi di traduzione, simboli JS, pagine, componenti) e qmllint nella suite - versione 0.2.0 --- README.md | 86 ++++++++--- qml/pages/AboutPage.qml | 65 +++++++++ qml/pages/BackupPage.qml | 130 +++++++++++++++++ qml/pages/BreedDetailPage.qml | 257 +++++++++++++++++++++++++++++++++ qml/pages/BreedsPage.qml | 206 +++++++++++++++++++++++++++ qml/pages/CoopPage.qml | 152 ++++++++++++++++++++ qml/pages/CustomerPage.qml | 119 ++++++++++++++++ qml/pages/CustomersPage.qml | 128 +++++++++++++++++ qml/pages/HenPage.qml | 261 ++++++++++++++++++++++++++++++++++ qml/pages/IntroPage.qml | 74 ++++++++++ qml/pages/LanguagePage.qml | 81 +++++++++++ qml/pages/MainPage.qml | 35 ++++- qml/pages/OrderPage.qml | 2 +- qml/pages/SettingsPage.qml | 96 +++++++++++++ rpm/harbour-henflow.desktop | 4 +- rpm/harbour-henflow.spec | 7 +- src/datastore.cpp | 93 ++++++++---- src/datastore.h | 23 ++- src/main.cpp | 4 +- src/settings.cpp | 15 ++ src/settings.h | 7 + tests/cpp_test.sh | 6 +- tests/harness/harness.cpp | 87 ++++++++++-- tests/run.sh | 21 +++ tools/check_qml.py | 175 +++++++++++++++++++++++ 25 files changed, 2060 insertions(+), 74 deletions(-) create mode 100644 qml/pages/AboutPage.qml create mode 100644 qml/pages/BackupPage.qml create mode 100644 qml/pages/BreedDetailPage.qml create mode 100644 qml/pages/BreedsPage.qml create mode 100644 qml/pages/CoopPage.qml create mode 100644 qml/pages/CustomerPage.qml create mode 100644 qml/pages/CustomersPage.qml create mode 100644 qml/pages/HenPage.qml create mode 100644 qml/pages/IntroPage.qml create mode 100644 qml/pages/LanguagePage.qml create mode 100644 qml/pages/SettingsPage.qml create mode 100644 tools/check_qml.py diff --git a/README.md b/README.md index 3ad4c31..fa4a156 100644 --- a/README.md +++ b/README.md @@ -25,27 +25,44 @@ Android (AdMob/File picker/Share). Nessun account, nessun cloud, come l'original | Home con produzione stimata e coda ordini | fatta | | Scheda ordine (nuovo/modifica, data calcolata) | fatta | | Cover con produzione e ordini pendenti | fatta | -| Razze / pollaio / galline, clienti, backup, lingua, intro | da fare | +| Razze disponibili, mio pollaio, registro galline | fatte | +| Clienti (anagrafica) | fatta | +| Backup e ripristino (JSON dell'app Android) | fatto | +| Scelta lingua a runtime (4 lingue) | fatta | +| Introduzione (primo avvio e dalle impostazioni) | fatta | +| Calendario delle consegne | da fare | ## Struttura ``` -harbour-henflow.pro # QT += quick qml, INSTALLS icone +harbour-henflow.pro # QT += quick qml, INSTALLS icone + foto razze rpm/harbour-henflow.spec # lrelease + desktop cercati con find (layout sorgente variabile) -rpm/harbour-henflow.desktop # profilo [X-Sailjail] esplicito (dati persistenti) -src/main.cpp # SailfishApp, context property appSettings/appData, lingua -src/settings.h/.cpp # QSettings su path esplicito AppConfigLocation + sync() -src/datastore.h/.cpp # dati in un JSON in AppConfigLocation (chiavi = SharedPreferences dell'app) +rpm/harbour-henflow.desktop # profilo [X-Sailjail] + permesso Documents (backup) +src/main.cpp # SailfishApp, context property appSettings/appData/appVersion +src/settings.h/.cpp # QSettings su path esplicito AppConfigLocation + sync() (lingua, intro vista) +src/datastore.h/.cpp # dati in un JSON in AppConfigLocation + backup nel formato dell'app Android qml/harbour-henflow.qml # ApplicationWindow: initialPage + cover -qml/pages/MainPage.qml # Home: produzione stimata, coda ordini, menu nuovo ordine +qml/pages/MainPage.qml # Home: produzione stimata, coda ordini, menu (ordine, clienti, impostazioni) qml/pages/OrderPage.qml # nuovo/modifica ordine con anteprima della data di consegna +qml/pages/SettingsPage.qml # indice delle impostazioni +qml/pages/BreedsPage.qml # razze disponibili (attiva/disattiva con conferma) +qml/pages/CoopPage.qml # mio pollaio: conteggi e produzione per razza +qml/pages/BreedDetailPage.qml# scheda razza + registro galline (deposizione, elimina) +qml/pages/HenPage.qml # nuova/modifica gallina: nome, data di nascita, deposizione +qml/pages/CustomersPage.qml # anagrafica clienti +qml/pages/CustomerPage.qml # nuovo/modifica cliente +qml/pages/BackupPage.qml # esporta in Documenti / copia negli appunti / ripristina da JSON +qml/pages/LanguagePage.qml # lingua (EN/IT/FR/DE) applicata a runtime +qml/pages/IntroPage.qml # introduzione in 4 passi +qml/pages/AboutPage.qml # informazioni e versione qml/cover/CoverPage.qml # produzione del giorno e ordini pendenti qml/js/henflow.js # motore di calcolo (ES5, testabile con node) qml/js/i18n.js # 126 chiavi x 4 lingue + t(chiave, arg, lingua) qml/js/catalog.js # 7 razze: nome/descrizione/colore uova/foto assets/breeds/*.jpg # foto delle razze (installate nel datadir) tools/gen_from_flutter.py # rigenera i18n.js e catalog.js dai sorgenti Flutter -tests/run.sh # motore + traduzioni (node) +tools/check_qml.py # controlli statici: chiavi, simboli JS, pagine, componenti +tests/run.sh # motore + traduzioni (node) + controlli QML + qmllint tests/cpp_test.sh # livello dati (Qt 5.15 locale, config in cartella temporanea) icons/make_icons.py # icone 86/108/128/172 dall'artwork originale translations/*.ts # stringhe statiche QML (il testo dell'app viene da i18n.js) @@ -54,11 +71,20 @@ translations/*.ts # stringhe statiche QML (il testo dell'app viene da ## Test ```sh -sh tests/run.sh # motore di calcolo + traduzioni e catalogo (node, nessuna dipendenza) +sh tests/run.sh # motore di calcolo, traduzioni e catalogo (node), controlli + # statici della QML (chiavi, simboli JS, pagine, componenti) e qmllint sh tests/cpp_test.sh # livello dati: compila src/datastore.cpp col Qt locale e prova # persistenza, rilettura, compatibilita' delle chiavi, backup, file corrotto ``` +I controlli statici della QML servono perche' la UI non e' eseguibile senza SDK: +prima di consegnare verificano che ogni chiave di traduzione usata esista in tutte +e quattro le lingue, che le funzioni del motore chiamate esistano, che le pagine +aperte con `pageStack` esistano davvero e che non si usino componenti inesistenti +(es. `Toast`, che in Sailfish non c'e'). `qmllint` aggiunge la sintassi: gli errori +di sintassi sono proprio quelli che sul device fanno fallire la pagina con +"Impossibile caricare la pagina". + Il testo dell'app vive in `i18n.js` (come il TranslationService dell'originale), quindi la lingua cambia **a runtime** senza riavviare l'app: cosa che i `.ts` di Qt 5.6 non permettono. La lingua predefinita segue quella del telefono. @@ -89,20 +115,35 @@ Le date sono sempre normalizzate a `YYYY-MM-DD` e le funzioni non mutano l'input Nel porting: un unico file JSON in `~/.config/harbour/henflow/` (nessuna dipendenza extra, nessun driver SQL da verificare sul device) con la stessa -struttura, cosi' il backup/ripristino resta compatibile con l'app Android. +struttura, cosi' i due formati restano leggibili. + +### Backup (formato dell'app Android) + +L'export e il ripristino usano le chiavi **dell'app Android** — `breedSettings`, +`reservations`, `customers` (camelCase) — non quelle interne del file: un backup +esportato sul telefono Android si ripristina qui e viceversa. Il file viene +scritto in `~/Documents` (serve il permesso `Documents` nel profilo SailJail); +in alternativa il JSON si copia negli appunti. Scorte e data dell'ultimo +aggiornamento non stanno nel backup: al ripristino restano quelli correnti, cosi' +non si azzera il magazzino. `tests/cpp_test.sh` prova il round-trip con un backup +scritto esattamente come lo esporta l'app Android. ## Schermate mappate (da sorgenti + screenshot) | schermata Flutter | contenuto | porting | |---|---|---| -| `OnboardingPage` | 4 passi introduttivi | da fare | -| `ModeSelectionPage` | modalita' allevatore / cliente | da decidere | -| `HomePage` | tab *Orders list* / *Calendar* (+ magazzino), card produzione stimata, card ordini con stato e azioni (condividi/modifica/elimina), FAB *New order* | da fare | -| `SettingsPage` | tab *Available breeds* (catalogo con foto e spunta) / *My coop* (galline registrate, in deposizione, produzione) + backup/ripristino JSON + lingua | da fare | -| `BreedDetailPage` | galline della razza, eta' in mesi, stato deposizione | da fare | -| `CustomersPage` + dialoghi | anagrafica clienti, nuovo ordine | da fare | -| `AboutPage` | info app | da fare | -| `CoopIcon` (CustomPainter) | icona pollaio disegnata a mano | da ridisegnare (Canvas QML o PNG) | +| `OnboardingPage` | 4 passi introduttivi | `IntroPage` (primo avvio + dalle impostazioni) | +| `ModeSelectionPage` | modalita' allevatore / cliente | non portata: solo allevatore, come deciso | +| `HomePage` | card produzione stimata, ordini con stato e azioni, FAB *New order* | `MainPage` + `OrderPage` | +| `HomePage` tab *Calendar* | calendario delle prenotazioni | da fare | +| `SettingsPage` tab *Available breeds* | catalogo con foto e spunta, conferma alla disattivazione | `BreedsPage` | +| `SettingsPage` tab *My coop* | razze allevate, galline registrate/in deposizione, produzione | `CoopPage` + `BreedDetailPage` | +| `BreedDetailPage` + `AddHenDialog` | galline della razza, eta' in mesi, stato deposizione | `BreedDetailPage` + `HenPage` (data con tre selettori) | +| `CustomersPage` + dialoghi | anagrafica clienti | `CustomersPage` + `CustomerPage` | +| `AddReservationSheet` | nuovo/modifica ordine | `OrderPage` | +| `SettingsPage` backup | esporta su file / importa da JSON | `BackupPage` (formato identico all'Android) | +| `AboutPage` | info app | `AboutPage` | +| `CoopIcon` (CustomPainter) | icona pollaio disegnata a mano | non portata: la Home usa la card con la produzione | Differenze da tenere presenti: **niente annunci** (AdMob non esiste su Sailfish), **share/export** via file JSON in Download invece di `share_plus`/`file_picker`. @@ -120,15 +161,16 @@ sfdk deploy # oppure Qt Creator Tarball per lo store (con `--prefix`, esclude .git e i file non tracciati): ```sh -git archive --format=tar.gz --prefix=harbour-henflow-0.1.0/ -o harbour-henflow-0.1.0.tar.gz HEAD +git archive --format=tar.gz --prefix=harbour-henflow-0.2.0/ -o harbour-henflow-0.2.0.tar.gz HEAD ``` ## Aperti -1. **Schermate da fare**: razze disponibili / mio pollaio / anagrafica galline, - clienti, backup-ripristino, scelta lingua, introduzione iniziale, calendario. +1. **Calendario delle consegne** (il tab *Calendar* dell'originale): griglia del + mese con gli ordini per giorno e l'elenco del giorno scelto. 2. **Verifica su device**: la UI Silica non e' verificabile senza SDK/dispositivo - (qui si verificano solo motore, traduzioni, catalogo e livello dati). + (qui si verificano motore, traduzioni, catalogo, livello dati, chiavi e sintassi + della QML, ma non il comportamento a schermo). ## Correzioni rispetto all'originale (bug trovati nei sorgenti Flutter) diff --git a/qml/pages/AboutPage.qml b/qml/pages/AboutPage.qml new file mode 100644 index 0000000..f597e23 --- /dev/null +++ b/qml/pages/AboutPage.qml @@ -0,0 +1,65 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 +import "../js/i18n.js" as I18N + +// Informazioni: descrizione dell'app e versione (dall'applicationVersion +// impostata nel main.cpp, la stessa del pacchetto RPM). +Page { + id: page + allowedOrientations: Orientation.All + + property string lang: "en" + + function tr(key, arg) { return I18N.t(key, arg, page.lang) } + + Component.onCompleted: page.lang = I18N.langFor(appSettings.language, Qt.locale().name) + + SilicaFlickable { + anchors.fill: parent + contentHeight: about.height + + Column { + id: about + width: parent.width + spacing: Theme.paddingLarge + + PageHeader { title: page.tr("about") } + + Image { + anchors.horizontalCenter: parent.horizontalCenter + width: Theme.itemSizeLarge * 2 + height: Theme.itemSizeLarge * 2 + source: "file:///usr/share/icons/hicolor/172x172/apps/harbour-henflow.png" + fillMode: Image.PreserveAspectFit + asynchronous: true + } + + Label { + anchors.horizontalCenter: parent.horizontalCenter + text: "Henflow" + font.pixelSize: Theme.fontSizeLarge + color: Theme.highlightColor + } + + Label { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: page.tr("about_desc") + color: Theme.primaryColor + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter + } + + Label { + anchors.horizontalCenter: parent.horizontalCenter + text: "v " + appVersion + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + } + + Item { width: parent.width; height: Theme.paddingLarge } + } + + VerticalScrollDecorator {} + } +} diff --git a/qml/pages/BackupPage.qml b/qml/pages/BackupPage.qml new file mode 100644 index 0000000..cf4ef96 --- /dev/null +++ b/qml/pages/BackupPage.qml @@ -0,0 +1,130 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 +import "../js/i18n.js" as I18N + +// Backup e ripristino, nello STESSO formato JSON dell'app Android +// ({breedSettings, reservations, customers}): i file esportati dalle due +// versioni si scambiano senza conversioni. +// Qui non esiste la condivisione di Android: l'esportazione scrive il file +// nella cartella Documenti (e lo si puo' copiare negli appunti), il +// ripristino si fa incollando il JSON. +Page { + id: page + allowedOrientations: Orientation.All + + property string lang: "en" + property var onChanged: null + property string message: "" + property bool messageIsError: false + + function tr(key, arg) { return I18N.t(key, arg, page.lang) } + + function showMessage(text, isError) { + page.message = text + page.messageIsError = isError + hideTimer.restart() + } + + function exportData() { + var path = appData.exportBackup() + if (path.length > 0) + page.showMessage(page.tr("export_data") + ": " + path, false) + else + page.showMessage(page.tr("import_error"), true) + } + + function copyData() { + clipboard.text = appData.backupJson() + page.showMessage(page.tr("copied"), false) + } + + function importData() { + if (!appData.isValidBackup(backupField.text)) { + page.showMessage(page.tr("import_error"), true) + return + } + if (appData.importBackup(backupField.text)) { + backupField.text = "" + page.showMessage(page.tr("import_success"), false) + if (page.onChanged) + page.onChanged() + } else { + page.showMessage(page.tr("import_error"), true) + } + } + + Component.onCompleted: page.lang = I18N.langFor(appSettings.language, Qt.locale().name) + + Timer { + id: hideTimer + interval: 6000 + onTriggered: page.message = "" + } + + Clipboard { id: clipboard } + + SilicaFlickable { + anchors.fill: parent + contentHeight: content.height + + Column { + id: content + width: parent.width + spacing: Theme.paddingMedium + + PageHeader { title: page.tr("backup_restore") } + + Button { + anchors.horizontalCenter: parent.horizontalCenter + text: page.tr("export_data") + onClicked: page.exportData() + } + + Button { + anchors.horizontalCenter: parent.horizontalCenter + text: page.tr("copy_to_clipboard") + onClicked: page.copyData() + } + + Label { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: page.message + color: page.messageIsError ? Theme.errorColor : Theme.highlightColor + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.Wrap + visible: text.length > 0 + } + + SectionHeader { text: page.tr("import_data") } + + TextArea { + id: backupField + width: parent.width + height: Theme.itemSizeLarge * 3 + placeholderText: page.tr("paste_json_here") + label: page.tr("paste_json_here") + } + + Button { + anchors.horizontalCenter: parent.horizontalCenter + text: page.tr("import_data") + enabled: appData.isValidBackup(backupField.text) + onClicked: page.importData() + } + + Label { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: appData.dataFilePath() + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + wrapMode: Text.Wrap + } + + Item { width: parent.width; height: Theme.paddingLarge } + } + + VerticalScrollDecorator {} + } +} diff --git a/qml/pages/BreedDetailPage.qml b/qml/pages/BreedDetailPage.qml new file mode 100644 index 0000000..55a2767 --- /dev/null +++ b/qml/pages/BreedDetailPage.qml @@ -0,0 +1,257 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 +import "../js/henflow.js" as HF +import "../js/i18n.js" as I18N +import "../js/catalog.js" as Catalog + +// Scheda di una razza allevata: foto, scheda informativa e registro delle +// galline (nome, nascita, eta' in mesi, stato) con interruttore deposizione +// ed eliminazione con annulla. +Page { + id: page + allowedOrientations: Orientation.All + + property string breedId: "" + property string lang: "en" + property bool ready: false + property var hens: [] + + property string breedName: "" + property string breedDescription: "" + property string eggColor: "" + property int registered: 0 + property int laying: 0 + property real daily: 0 + + function tr(key, arg) { return I18N.t(key, arg, page.lang) } + + ListModel { id: henModel } + + function reload() { + page.lang = I18N.langFor(appSettings.language, Qt.locale().name) + + var breed = Catalog.breedFromId(page.breedId) + if (breed) { + page.breedName = page.tr(breed.name) + page.breedDescription = page.tr(breed.description) + page.eggColor = page.tr("egg_color") + ": " + page.tr(breed.eggColor) + } + + var setting = null + var settings = appData.breedSettings() + for (var i = 0; i < settings.length; i++) { + if (settings[i].breedId === page.breedId) + setting = settings[i] + } + + page.hens = (setting && setting.hens) ? setting.hens : [] + page.registered = page.hens.length + page.laying = HF.activeHens({ "hens": page.hens }) + page.daily = page.laying * HF.eggsPerHenPerDay(Catalog.HF_BREEDS, page.breedId) + + var today = HF.todayMidnight() + henList.clear() + for (var j = 0; j < page.hens.length; j++) { + var hen = page.hens[j] + var birth = HF.parseIso(hen.birthDate) + var months = birth ? HF.ageInMonths(birth, today) : 0 + var status + if (months >= 6) + status = hen.isLaying ? page.tr("status_laying") : page.tr("status_rest") + else + status = hen.isLaying ? page.tr("status_laying") : page.tr("status_young") + + henList.append({ + "n": j, + "name": hen.name, + "birth": page.tr("birth") + ": " + HF.formatDay(hen.birthDate), + "age": page.tr("age_months", months), + "status": status, + "isLaying": hen.isLaying + }) + } + } + + // Salva il registro delle galline di questa razza. + function saveHens(list) { + var settings = appData.breedSettings() + for (var i = 0; i < settings.length; i++) { + if (settings[i].breedId === page.breedId) + settings[i].hens = list + } + appData.saveBreedSettings(settings) + page.reload() + } + + function toggleHen(index) { + var list = page.hens.slice() + list[index].isLaying = !list[index].isLaying + page.saveHens(list) + } + + function deleteHen(index) { + var list = page.hens.slice() + var hen = list[index] + remorse.execute(henList, page.tr("delete"), function () { + list.splice(index, 1) + page.saveHens(list) + }, 4000) + } + + function openHen(index) { + var hen = (index >= 0 && index < page.hens.length) ? page.hens[index] : null + pageStack.push(Qt.resolvedUrl("HenPage.qml"), + { "breedId": page.breedId, "hen": hen }) + } + + Component.onCompleted: page.reload() + + onStatusChanged: { + if (status !== PageStatus.Active) + return + if (page.ready) + page.reload() + page.ready = true + } + + RemorseItem { id: remorse } + + SilicaListView { + id: henList + anchors.fill: parent + + header: Column { + width: henList.width + + PageHeader { title: page.tr("breed_sheet", page.breedName) } + + Image { + x: Theme.horizontalPageMargin + width: henList.width - 2 * Theme.horizontalPageMargin + height: width / 2 + source: Catalog.breedFromId(page.breedId) + ? "file://" + Catalog.breedFromId(page.breedId).asset : "" + fillMode: Image.PreserveAspectCrop + asynchronous: true + } + + Item { width: parent.width; height: Theme.paddingMedium } + + Column { + x: Theme.horizontalPageMargin + width: henList.width - 2 * Theme.horizontalPageMargin + spacing: Theme.paddingSmall + + Label { + width: parent.width + text: page.breedDescription + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.Wrap + } + + Label { + width: parent.width + text: page.eggColor + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + truncationMode: TruncationMode.Fade + } + + Label { + width: parent.width + text: page.tr("reg_hens") + ": " + page.registered + + " · " + page.tr("laying_6_months") + ": " + page.laying + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + truncationMode: TruncationMode.Fade + } + + Label { + width: parent.width + text: page.tr("est_prod") + ": " + page.daily.toFixed(1) + " " + page.tr("eggs_per_day") + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + truncationMode: TruncationMode.Fade + } + } + + Item { width: parent.width; height: Theme.paddingMedium } + + SectionHeader { text: page.tr("hen_registry") } + } + + model: henModel + + delegate: ListItem { + id: henItem + contentHeight: henText.height + Theme.paddingMedium + + menu: ContextMenu { + MenuItem { + text: page.tr("delete") + onClicked: page.deleteHen(index) + } + } + + Column { + id: henText + x: Theme.horizontalPageMargin + y: Theme.paddingMedium + width: parent.width - 2 * Theme.horizontalPageMargin - layingSwitch.width + - Theme.paddingMedium + spacing: Theme.paddingSmall + + Label { + width: parent.width + text: model.name + color: henItem.highlighted ? Theme.highlightColor : Theme.primaryColor + truncationMode: TruncationMode.Fade + } + + Label { + width: parent.width + text: model.birth + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + truncationMode: TruncationMode.Fade + } + + Label { + width: parent.width + text: model.age + " · " + model.status + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + truncationMode: TruncationMode.Fade + } + } + + Switch { + id: layingSwitch + anchors { + right: parent.right + rightMargin: Theme.horizontalPageMargin + verticalCenter: parent.verticalCenter + } + checked: model.isLaying + onClicked: page.toggleHen(index) + } + + onClicked: page.openHen(index) + } + + ViewPlaceholder { + enabled: henModel.count === 0 + text: page.tr("no_hens_reg") + } + + PullDownMenu { + MenuItem { + text: page.tr("new_hen") + onClicked: page.openHen(-1) + } + } + + VerticalScrollDecorator {} + } +} diff --git a/qml/pages/BreedsPage.qml b/qml/pages/BreedsPage.qml new file mode 100644 index 0000000..56e18e1 --- /dev/null +++ b/qml/pages/BreedsPage.qml @@ -0,0 +1,206 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 +import "../js/i18n.js" as I18N +import "../js/catalog.js" as Catalog + +// "Razze disponibili" (prima scheda del SettingsPage dell'app Flutter): +// il catalogo completo con l'interruttore di attivazione. Attivare una razza +// crea l'allevamento (senza galline), disattivarla lo elimina dopo conferma. +Page { + id: page + allowedOrientations: Orientation.All + + property string lang: "en" + property bool ready: false + // razze attive (id) ricostruite dal livello dati + property var enabledIds: [] + + function tr(key, arg) { return I18N.t(key, arg, page.lang) } + + ListModel { id: breedModel } + + function isEnabled(breedId) { + return page.enabledIds.indexOf(breedId) >= 0 + } + + function henCount(breedId) { + var settings = appData.breedSettings() + for (var i = 0; i < settings.length; i++) { + if (settings[i].breedId === breedId) { + var hens = settings[i].hens + return hens ? hens.length : 0 + } + } + return 0 + } + + function reload() { + page.lang = I18N.langFor(appSettings.language, Qt.locale().name) + + var settings = appData.breedSettings() + var ids = [] + for (var i = 0; i < settings.length; i++) + ids.push(settings[i].breedId) + page.enabledIds = ids + + breedModel.clear() + for (var j = 0; j < Catalog.HF_BREEDS.length; j++) { + var b = Catalog.HF_BREEDS[j] + breedModel.append({ + "n": j, + "name": page.tr(b.name), + "description": page.tr(b.description), + "eggColor": page.tr("egg_color") + ": " + page.tr(b.eggColor), + "production": b.eggsPerHenPerDay.toFixed(1) + " " + page.tr("eggs_per_g"), + "enabled": page.isEnabled(b.id), + "hens": page.henCount(b.id) + }) + } + } + + // Attivazione immediata; disattivazione con conferma (come l'originale, + // che avverte perche' si perdono le galline registrate). + function toggleBreed(index) { + var breed = Catalog.HF_BREEDS[index] + if (!breed) + return + if (page.isEnabled(breed.id)) { + confirmDialog.pendingId = breed.id + confirmDialog.open() + } else { + var settings = appData.breedSettings() + settings.push({ "breedId": breed.id, "hens": [] }) + appData.saveBreedSettings(settings) + page.reload() + } + } + + function removeBreed(breedId) { + var settings = appData.breedSettings() + var out = [] + for (var i = 0; i < settings.length; i++) { + if (settings[i].breedId !== breedId) + out.push(settings[i]) + } + appData.saveBreedSettings(out) + page.reload() + } + + Component.onCompleted: page.reload() + + onStatusChanged: { + if (status !== PageStatus.Active) + return + if (page.ready) + page.reload() + page.ready = true + } + + SilicaListView { + id: listView + anchors.fill: parent + + header: Column { + width: listView.width + + PageHeader { title: page.tr("available_breeds") } + } + + model: breedModel + + delegate: ListItem { + id: breedItem + contentHeight: photo.height + Theme.paddingMedium + + Image { + id: photo + x: Theme.horizontalPageMargin + y: Theme.paddingMedium + width: Theme.itemSizeLarge + height: Theme.itemSizeLarge + source: "file://" + Catalog.HF_BREEDS[model.n].asset + fillMode: Image.PreserveAspectCrop + asynchronous: true + } + + Column { + x: photo.x + photo.width + Theme.paddingMedium + y: Theme.paddingMedium + width: parent.width - x - switchControl.width - 2 * Theme.horizontalPageMargin + spacing: Theme.paddingSmall + + Label { + width: parent.width + text: model.name + color: breedItem.highlighted ? Theme.highlightColor : Theme.primaryColor + truncationMode: TruncationMode.Fade + } + + Label { + width: parent.width + text: model.description + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + wrapMode: Text.Wrap + maximumLineCount: 2 + truncationMode: TruncationMode.Elide + } + + Label { + width: parent.width + text: model.eggColor + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + truncationMode: TruncationMode.Fade + } + + Label { + width: parent.width + text: model.hens > 0 ? (model.production + " · " + model.hens + " " + page.tr("hens")) + : model.production + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + truncationMode: TruncationMode.Fade + } + } + + Switch { + id: switchControl + anchors { + right: parent.right + rightMargin: Theme.horizontalPageMargin + verticalCenter: photo.verticalCenter + } + checked: model.enabled + onClicked: page.toggleBreed(model.n) + } + } + + VerticalScrollDecorator {} + } + + Dialog { + id: confirmDialog + property string pendingId: "" + + Column { + width: parent.width + + DialogHeader { + title: page.tr("confirm_deactivation") + acceptText: page.tr("deactivate") + } + + Label { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: page.tr("delete_msg") + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.Wrap + } + } + + onAccepted: page.removeBreed(confirmDialog.pendingId) + } +} diff --git a/qml/pages/CoopPage.qml b/qml/pages/CoopPage.qml new file mode 100644 index 0000000..5848dc2 --- /dev/null +++ b/qml/pages/CoopPage.qml @@ -0,0 +1,152 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 +import "../js/henflow.js" as HF +import "../js/i18n.js" as I18N +import "../js/catalog.js" as Catalog + +// "Mio pollaio" (seconda scheda dell'originale): produzione del giorno e +// razze allevate, con conteggi e stima per razza. Il tocco apre il registro +// delle galline di quella razza. +Page { + id: page + allowedOrientations: Orientation.All + + property string lang: "en" + property bool ready: false + property var settings: [] + property real totalProduction: 0 + + function tr(key, arg) { return I18N.t(key, arg, page.lang) } + + ListModel { id: breedModel } + + function reload() { + page.lang = I18N.langFor(appSettings.language, Qt.locale().name) + page.settings = appData.breedSettings() + page.totalProduction = HF.totalDailyProduction(page.settings, Catalog.HF_BREEDS) + + breedModel.clear() + for (var i = 0; i < page.settings.length; i++) { + var s = page.settings[i] + var breed = Catalog.breedFromId(s.breedId) + var laying = HF.activeHens(s) + var daily = laying * HF.eggsPerHenPerDay(Catalog.HF_BREEDS, s.breedId) + breedModel.append({ + "n": i, + "name": breed ? page.tr(breed.name) : s.breedId, + "registered": page.tr("reg_hens") + ": " + (s.hens ? s.hens.length : 0), + "laying": page.tr("laying_6_months") + ": " + laying, + "production": page.tr("est_prod") + ": " + daily.toFixed(1) + " " + page.tr("eggs_per_day") + }) + } + } + + Component.onCompleted: page.reload() + + onStatusChanged: { + if (status !== PageStatus.Active) + return + if (page.ready) + page.reload() + page.ready = true + } + + SilicaListView { + id: listView + anchors.fill: parent + + header: Column { + width: listView.width + + PageHeader { title: page.tr("my_coop") } + + Rectangle { + x: Theme.horizontalPageMargin + width: listView.width - 2 * Theme.horizontalPageMargin + height: coopColumn.height + 2 * Theme.paddingMedium + radius: Theme.paddingSmall + color: Theme.rgba(Theme.highlightBackgroundColor, 0.12) + + Column { + id: coopColumn + x: Theme.paddingMedium + y: Theme.paddingMedium + width: parent.width - 2 * Theme.paddingMedium + spacing: Theme.paddingSmall + + Label { + text: page.tr("daily_prod_est") + font.pixelSize: Theme.fontSizeSmall + color: Theme.secondaryColor + } + + Label { + text: page.totalProduction.toFixed(1) + " " + page.tr("eggs_per_day") + font.pixelSize: Theme.fontSizeLarge + color: Theme.highlightColor + } + } + } + + Item { width: parent.width; height: Theme.paddingMedium } + + SectionHeader { text: page.tr("available_breeds") } + } + + model: breedModel + + delegate: ListItem { + id: coopItem + contentHeight: coopText.height + Theme.paddingMedium + + Column { + id: coopText + x: Theme.horizontalPageMargin + y: Theme.paddingMedium + width: parent.width - 2 * Theme.horizontalPageMargin + spacing: Theme.paddingTiny + + Label { + width: parent.width + text: model.name + color: coopItem.highlighted ? Theme.highlightColor : Theme.primaryColor + truncationMode: TruncationMode.Fade + } + + Label { + width: parent.width + text: model.registered + " · " + model.laying + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + truncationMode: TruncationMode.Fade + } + + Label { + width: parent.width + text: model.production + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + truncationMode: TruncationMode.Fade + } + } + + onClicked: pageStack.push(Qt.resolvedUrl("BreedDetailPage.qml"), + { "breedId": page.settings[index].breedId }) + } + + ViewPlaceholder { + enabled: breedModel.count === 0 + text: page.tr("no_breeds") + hintText: page.tr("config_breed_first") + } + + PullDownMenu { + MenuItem { + text: page.tr("available_breeds") + onClicked: pageStack.push(Qt.resolvedUrl("BreedsPage.qml")) + } + } + + VerticalScrollDecorator {} + } +} diff --git a/qml/pages/CustomerPage.qml b/qml/pages/CustomerPage.qml new file mode 100644 index 0000000..aa0d259 --- /dev/null +++ b/qml/pages/CustomerPage.qml @@ -0,0 +1,119 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 +import "../js/henflow.js" as HF +import "../js/i18n.js" as I18N + +// Nuovo cliente / modifica: nome di battesimo (obbligatorio), cognome e +// telefono. Stessa regola dell'app Flutter: senza nome non si salva. +Page { + id: page + allowedOrientations: Orientation.All + + property var customer: null // null = nuovo cliente + property var onSaved: null + property string lang: "en" + property string errorMsg: "" + + function tr(key, arg) { return I18N.t(key, arg, page.lang) } + + function reload() { + page.lang = I18N.langFor(appSettings.language, Qt.locale().name) + if (page.customer) { + firstNameField.text = page.customer.firstName || "" + lastNameField.text = page.customer.lastName || "" + phoneField.text = page.customer.phoneNumber || "" + } + } + + function save() { + var first = firstNameField.text.trim() + if (first.length === 0) { + page.errorMsg = page.tr("first_name") + return + } + + var list = appData.customers().slice() + var record = { + "id": page.customer ? page.customer.id : HF.newId(), + "firstName": first, + "lastName": lastNameField.text.trim(), + "phoneNumber": phoneField.text.trim() + } + + if (page.customer) { + for (var i = 0; i < list.length; i++) { + if (list[i].id === record.id) + list[i] = record + } + } else { + list.push(record) + } + + appData.saveCustomers(list) + if (page.onSaved) + page.onSaved() + pageStack.pop() + } + + Component.onCompleted: page.reload() + + SilicaFlickable { + anchors.fill: parent + contentHeight: form.height + + Column { + id: form + width: parent.width + spacing: Theme.paddingMedium + + PageHeader { + title: page.customer ? page.tr("edit_customer") : page.tr("new_customer") + } + + TextField { + id: firstNameField + width: parent.width + label: page.tr("first_name") + EnterKey.enabled: true + EnterKey.onClicked: page.save() + } + + TextField { + id: lastNameField + width: parent.width + label: page.tr("last_name") + EnterKey.enabled: true + EnterKey.onClicked: page.save() + } + + TextField { + id: phoneField + width: parent.width + label: page.tr("phone_number") + inputMethodHints: Qt.ImhDialableCharactersOnly + EnterKey.enabled: true + EnterKey.onClicked: page.save() + } + + Label { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: page.errorMsg + color: Theme.errorColor + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.Wrap + visible: text.length > 0 + } + + Button { + anchors.horizontalCenter: parent.horizontalCenter + text: page.tr("save") + onClicked: page.save() + } + + Item { width: parent.width; height: Theme.paddingLarge } + } + + VerticalScrollDecorator {} + } +} diff --git a/qml/pages/CustomersPage.qml b/qml/pages/CustomersPage.qml new file mode 100644 index 0000000..f20ac3d --- /dev/null +++ b/qml/pages/CustomersPage.qml @@ -0,0 +1,128 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 +import "../js/i18n.js" as I18N + +// Clienti abituali: nome, cognome e telefono. Servono a velocizzare +// l'inserimento degli ordini (i suggerimenti sono nella scheda ordine). +Page { + id: page + allowedOrientations: Orientation.All + + property string lang: "en" + property bool ready: false + property var customers: [] + + function tr(key, arg) { return I18N.t(key, arg, page.lang) } + + ListModel { id: customerModel } + + function fullName(c) { + var n = (c.firstName || "") + " " + (c.lastName || "") + return n.trim() + } + + function reload() { + page.lang = I18N.langFor(appSettings.language, Qt.locale().name) + page.customers = appData.customers() + + customerModel.clear() + for (var i = 0; i < page.customers.length; i++) { + customerModel.append({ + "n": i, + "name": page.fullName(page.customers[i]), + "phone": page.customers[i].phoneNumber || "" + }) + } + } + + function openCustomer(index) { + var customer = (index >= 0 && index < page.customers.length) ? page.customers[index] : null + pageStack.push(Qt.resolvedUrl("CustomerPage.qml"), + { "customer": customer, "onSaved": function () { page.reload() } }) + } + + function deleteCustomer(index) { + var list = appData.customers().slice() + remorse.execute(listView, page.tr("delete"), function () { + list.splice(index, 1) + appData.saveCustomers(list) + page.reload() + }, 4000) + } + + Component.onCompleted: page.reload() + + onStatusChanged: { + if (status !== PageStatus.Active) + return + if (page.ready) + page.reload() + page.ready = true + } + + RemorseItem { id: remorse } + + SilicaListView { + id: listView + anchors.fill: parent + + header: PageHeader { title: page.tr("customers_list") } + model: customerModel + + delegate: ListItem { + id: customerItem + contentHeight: customerText.height + Theme.paddingMedium + + menu: ContextMenu { + MenuItem { + text: page.tr("edit_customer") + onClicked: page.openCustomer(index) + } + MenuItem { + text: page.tr("delete") + onClicked: page.deleteCustomer(index) + } + } + + Column { + id: customerText + x: Theme.horizontalPageMargin + y: Theme.paddingMedium + width: parent.width - 2 * Theme.horizontalPageMargin + spacing: Theme.paddingSmall + + Label { + width: parent.width + text: model.name + color: customerItem.highlighted ? Theme.highlightColor : Theme.primaryColor + truncationMode: TruncationMode.Fade + } + + Label { + width: parent.width + text: model.phone + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + truncationMode: TruncationMode.Fade + visible: text.length > 0 + } + } + + onClicked: page.openCustomer(index) + } + + ViewPlaceholder { + enabled: customerModel.count === 0 + text: page.tr("no_customers") + } + + PullDownMenu { + MenuItem { + text: page.tr("new_customer") + onClicked: page.openCustomer(-1) + } + } + + VerticalScrollDecorator {} + } +} diff --git a/qml/pages/HenPage.qml b/qml/pages/HenPage.qml new file mode 100644 index 0000000..9dd9489 --- /dev/null +++ b/qml/pages/HenPage.qml @@ -0,0 +1,261 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 +import "../js/henflow.js" as HF +import "../js/i18n.js" as I18N +import "../js/catalog.js" as Catalog + +// Nuova gallina / modifica di una gallina: nome, data di nascita e stato di +// deposizione. La data si sceglie con tre selettori (giorno, mese, anno): +// la combinazione viene mostrata e controllata subito (niente date impossibili). +// Come nell'app Flutter, alla scelta della data lo stato si adegua da solo: +// sotto i 6 mesi la gallina non e' in deposizione. +Page { + id: page + allowedOrientations: Orientation.All + + property string breedId: "" + property var hen: null // null = nuova gallina + property string lang: "en" + + property int year: 0 + property int month: 1 + property int day: 1 + property int daysInMonth: 31 + property bool laying: true + property string errorMsg: "" + + readonly property int firstYear: 2010 + + function tr(key, arg) { return I18N.t(key, arg, page.lang) } + + function monthLength(y, m) { + // giorni del mese: il 31 c'e' solo dove esiste davvero + if (m === 2) + return (y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0)) ? 29 : 28 + if (m === 4 || m === 6 || m === 9 || m === 11) + return 30 + return 31 + } + + function isoOfSelection() { + return HF.isoDay(new Date(page.year, page.month - 1, page.day)) + + "T00:00:00.000" + } + + function monthsOfSelection() { + return HF.ageInMonths(new Date(page.year, page.month - 1, page.day), HF.todayMidnight()) + } + + function refreshSelection() { + page.daysInMonth = page.monthLength(page.year, page.month) + if (page.day > page.daysInMonth) + page.day = page.daysInMonth + } + + function applyDefaultDate() { + // Come l'originale: si parte da 180 giorni fa (una gallina in deposizione) + var d = HF.addDays(HF.todayMidnight(), -180) + page.year = d.getFullYear() + page.month = d.getMonth() + 1 + page.day = d.getDate() + page.refreshSelection() + } + + function reload() { + page.lang = I18N.langFor(appSettings.language, Qt.locale().name) + + if (page.hen) { + var birth = HF.parseIso(page.hen.birthDate) + if (birth) { + page.year = birth.getFullYear() + page.month = birth.getMonth() + 1 + page.day = birth.getDate() + } else { + page.applyDefaultDate() + } + page.laying = page.hen.isLaying + nameField.text = page.hen.name + } else { + page.applyDefaultDate() + page.laying = page.monthsOfSelection() >= 6 + } + page.refreshSelection() + } + + function save() { + var name = nameField.text.trim() + if (name.length === 0) { + page.errorMsg = page.tr("hen_name") + return + } + + var settings = appData.breedSettings() + var target = null + for (var i = 0; i < settings.length; i++) { + if (settings[i].breedId === page.breedId) + target = settings[i] + } + if (!target) { + page.errorMsg = page.tr("config_breed_first") + return + } + if (!target.hens) + target.hens = [] + + var record = { + "id": page.hen ? page.hen.id : HF.newId(), + "name": name, + "birthDate": page.isoOfSelection(), + "isLaying": page.laying + } + + if (page.hen) { + for (var j = 0; j < target.hens.length; j++) { + if (target.hens[j].id === record.id) + target.hens[j] = record + } + } else { + target.hens.push(record) + } + + appData.saveBreedSettings(settings) + pageStack.pop() + } + + Component.onCompleted: page.reload() + + SilicaFlickable { + anchors.fill: parent + contentHeight: form.height + + Column { + id: form + width: parent.width + spacing: Theme.paddingMedium + + PageHeader { title: page.tr("new_hen") } + + TextField { + id: nameField + width: parent.width + label: page.tr("hen_name") + placeholderText: page.tr("hen_name") + EnterKey.enabled: true + EnterKey.onClicked: page.save() + } + + SectionHeader { text: page.tr("birth_date") } + + Row { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + spacing: Theme.paddingSmall + + ComboBox { + id: dayBox + width: (parent.width - 2 * parent.spacing) / 3 + label: "DD" + menu: ContextMenu { + Repeater { + model: page.daysInMonth + delegate: MenuItem { + text: String(index + 1) + onClicked: { + page.day = index + 1 + dayBox.currentIndex = index + } + } + } + } + } + + ComboBox { + id: monthBox + width: (parent.width - 2 * parent.spacing) / 3 + label: "MM" + menu: ContextMenu { + Repeater { + model: 12 + delegate: MenuItem { + text: String(index + 1) + onClicked: { + page.month = index + 1 + page.refreshSelection() + monthBox.currentIndex = index + } + } + } + } + } + + ComboBox { + id: yearBox + width: (parent.width - 2 * parent.spacing) / 3 + label: "YYYY" + menu: ContextMenu { + Repeater { + model: HF.todayMidnight().getFullYear() - page.firstYear + 1 + delegate: MenuItem { + text: String(page.firstYear + index) + onClicked: { + page.year = page.firstYear + index + page.refreshSelection() + yearBox.currentIndex = index + } + } + } + } + } + } + + Column { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + spacing: Theme.paddingSmall + + Label { + width: parent.width + text: page.tr("birth") + ": " + HF.formatDay(page.isoOfSelection()) + color: Theme.primaryColor + truncationMode: TruncationMode.Fade + } + + Label { + width: parent.width + text: page.tr("age_months", page.monthsOfSelection()) + + " · " + (page.monthsOfSelection() >= 6 ? page.tr("laying_6_months") : page.tr("status_young")) + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + truncationMode: TruncationMode.Fade + } + } + + TextSwitch { + id: layingSwitch + text: page.tr("in_laying") + checked: page.laying + onClicked: page.laying = !page.laying + } + + Label { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: page.errorMsg + color: Theme.errorColor + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.Wrap + visible: text.length > 0 + } + + Button { + anchors.horizontalCenter: parent.horizontalCenter + text: page.tr("save") + onClicked: page.save() + } + + Item { width: parent.width; height: Theme.paddingLarge } + } + + VerticalScrollDecorator {} + } +} diff --git a/qml/pages/IntroPage.qml b/qml/pages/IntroPage.qml new file mode 100644 index 0000000..41910cc --- /dev/null +++ b/qml/pages/IntroPage.qml @@ -0,0 +1,74 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 +import "../js/i18n.js" as I18N + +// Introduzione: i quattro passi dell'app Flutter (registro galline, uova, +// clienti, ordini). Si apre da sola al primo avvio e resta raggiungibile +// dalle impostazioni; in quel caso il pulsante finale torna indietro. +Page { + id: page + allowedOrientations: Orientation.All + + property bool firstRun: true + property string lang: "en" + + function tr(key, arg) { return I18N.t(key, arg, page.lang) } + + function finish() { + if (page.firstRun) + appSettings.introSeen = true + pageStack.pop() + } + + Component.onCompleted: page.lang = I18N.langFor(appSettings.language, Qt.locale().name) + + SilicaFlickable { + anchors.fill: parent + contentHeight: intro.height + + Column { + id: intro + width: parent.width + spacing: Theme.paddingMedium + + PageHeader { title: page.tr("welcome") } + + Repeater { + model: 4 + delegate: Column { + x: Theme.horizontalPageMargin + width: intro.width - 2 * Theme.horizontalPageMargin + spacing: Theme.paddingSmall + + SectionHeader { + text: index === 0 ? page.tr("intro_step1_title") + : index === 1 ? page.tr("intro_step2_title") + : index === 2 ? page.tr("intro_step3_title") + : page.tr("intro_step4_title") + } + + Label { + width: parent.width + text: index === 0 ? page.tr("intro_step1_desc") + : index === 1 ? page.tr("intro_step2_desc") + : index === 2 ? page.tr("intro_step3_desc") + : page.tr("intro_step4_desc") + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.Wrap + } + } + } + + Button { + anchors.horizontalCenter: parent.horizontalCenter + text: page.tr("start_app") + onClicked: page.finish() + } + + Item { width: parent.width; height: Theme.paddingLarge } + } + + VerticalScrollDecorator {} + } +} diff --git a/qml/pages/LanguagePage.qml b/qml/pages/LanguagePage.qml new file mode 100644 index 0000000..f241048 --- /dev/null +++ b/qml/pages/LanguagePage.qml @@ -0,0 +1,81 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 +import "../js/i18n.js" as I18N + +// Lingua dell'applicazione: le quattro dell'originale (EN/IT/FR/DE). +// A differenza di QTranslator, la tabella di traduzione in i18n.js si applica +// subito: le pagine che si riaprono usano la lingua nuova senza riavviare. +Page { + id: page + allowedOrientations: Orientation.All + + property string lang: "en" + + function tr(key, arg) { return I18N.t(key, arg, page.lang) } + + ListModel { id: langModel } + + readonly property var codes: [ "en", "it", "fr", "de" ] + readonly property var names: [ "English", "Italiano", "Français", "Deutsch" ] + + function reload() { + page.lang = I18N.langFor(appSettings.language, Qt.locale().name) + langModel.clear() + for (var i = 0; i < page.codes.length; i++) { + langModel.append({ + "n": i, + "name": page.names[i], + "current": page.codes[i] === page.lang + }) + } + } + + function choose(index) { + var code = page.codes[index] + if (appSettings.language === code) { + pageStack.pop() + return + } + appSettings.language = code + page.reload() + } + + Component.onCompleted: page.reload() + + SilicaListView { + id: listView + anchors.fill: parent + + header: PageHeader { title: page.tr("language") } + model: langModel + + delegate: ListItem { + id: langItem + + Label { + anchors { + left: parent.left + leftMargin: Theme.horizontalPageMargin + verticalCenter: parent.verticalCenter + } + text: model.name + color: langItem.highlighted ? Theme.highlightColor + : (model.current ? Theme.highlightColor : Theme.primaryColor) + } + + Icon { + anchors { + right: parent.right + rightMargin: Theme.horizontalPageMargin + verticalCenter: parent.verticalCenter + } + source: "image://theme/icon-m-accept" + visible: model.current + } + + onClicked: page.choose(index) + } + + VerticalScrollDecorator {} + } +} diff --git a/qml/pages/MainPage.qml b/qml/pages/MainPage.qml index 4a228cf..9f2eeea 100644 --- a/qml/pages/MainPage.qml +++ b/qml/pages/MainPage.qml @@ -36,7 +36,6 @@ Page { page.totalProduction = HF.totalDailyProduction(page.breeds, Catalog.HF_BREEDS) page.productionRows = HF.productionDetail(page.breeds, Catalog.HF_BREEDS) page.rebuildList() - page.ready = true } // Accrual del magazzino all'apertura (come _loadData in main.dart): i giorni @@ -278,6 +277,14 @@ Page { text: page.tr("new_order") onClicked: page.openOrder(-1) } + MenuItem { + text: page.tr("customers_list") + onClicked: pageStack.push(Qt.resolvedUrl("CustomersPage.qml")) + } + MenuItem { + text: page.tr("settings") + onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml")) + } } footer: Item { @@ -325,5 +332,29 @@ Page { } } - Component.onCompleted: page.reload() + Component.onCompleted: { + page.reload() + + // Prima apertura: l'introduzione si presenta da sola (come l'originale). + // Differita: spingere una pagina mentre la prima si sta creando e' + // una corsa che su Qt 5.6 fa fallire l'apertura. + if (!appSettings.introSeen) + introTimer.start() + } + + // Al ritorno dalle altre pagine i dati possono essere cambiati (galline, + // ordini, clienti, ripristino di un backup): la Home si ricarica. + onStatusChanged: { + if (status !== PageStatus.Active) + return + if (page.ready) + page.reload() + page.ready = true + } + + Timer { + id: introTimer + interval: 100 + onTriggered: pageStack.push(Qt.resolvedUrl("IntroPage.qml"), { "firstRun": true }) + } } diff --git a/qml/pages/OrderPage.qml b/qml/pages/OrderPage.qml index 74a5a92..48fc802 100644 --- a/qml/pages/OrderPage.qml +++ b/qml/pages/OrderPage.qml @@ -250,7 +250,7 @@ Page { id: previewLabel x: Theme.horizontalPageMargin width: parent.width - 2 * Theme.horizontalPageMargin - font.pixelSize: Theme.fontSizeMedium + font.pixelSize: Theme.fontSizeLarge color: Theme.highlightColor } diff --git a/qml/pages/SettingsPage.qml b/qml/pages/SettingsPage.qml new file mode 100644 index 0000000..bc9493b --- /dev/null +++ b/qml/pages/SettingsPage.qml @@ -0,0 +1,96 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 +import "../js/i18n.js" as I18N + +// Impostazioni: razze disponibili, mio pollaio, clienti, backup, lingua, +// introduzione, informazioni. Le voci dell'app Flutter erano tre schede piu' +// il selettore di lingua nella barra: qui diventano una lista di voci. +Page { + id: page + allowedOrientations: Orientation.All + + property string lang: "en" + // Avvisa la Home che i dati sono cambiati (usato dal ripristino). + property var onChanged: null + property bool ready: false + + function tr(key, arg) { return I18N.t(key, arg, page.lang) } + + ListModel { id: entries } + + function reload() { + page.lang = I18N.langFor(appSettings.language, Qt.locale().name) + entries.clear() + entries.append({ "label": page.tr("available_breeds") }) + entries.append({ "label": page.tr("my_coop") }) + entries.append({ "label": page.tr("customers_list") }) + entries.append({ "label": page.tr("backup_restore") }) + entries.append({ "label": page.tr("language") }) + entries.append({ "label": page.tr("show_intro") }) + entries.append({ "label": page.tr("about") }) + } + + function openEntry(index) { + if (index === 0) { + pageStack.push(Qt.resolvedUrl("BreedsPage.qml")) + } else if (index === 1) { + pageStack.push(Qt.resolvedUrl("CoopPage.qml")) + } else if (index === 2) { + pageStack.push(Qt.resolvedUrl("CustomersPage.qml")) + } else if (index === 3) { + pageStack.push(Qt.resolvedUrl("BackupPage.qml"), + { "onChanged": page.forwardChanged }) + } else if (index === 4) { + pageStack.push(Qt.resolvedUrl("LanguagePage.qml")) + } else if (index === 5) { + pageStack.push(Qt.resolvedUrl("IntroPage.qml"), { "firstRun": false }) + } else if (index === 6) { + pageStack.push(Qt.resolvedUrl("AboutPage.qml")) + } + } + + function forwardChanged() { + if (page.onChanged) + page.onChanged() + } + + Component.onCompleted: page.reload() + + // al ritorno da una sottopagina i dati possono essere cambiati + onStatusChanged: { + if (status !== PageStatus.Active) + return + if (page.ready) + page.reload() + page.ready = true + } + + SilicaListView { + id: listView + anchors.fill: parent + + header: PageHeader { title: page.tr("settings") } + model: entries + + delegate: ListItem { + id: entry + + Label { + anchors { + left: parent.left + leftMargin: Theme.horizontalPageMargin + right: parent.right + rightMargin: Theme.horizontalPageMargin + verticalCenter: parent.verticalCenter + } + text: model.label + color: entry.highlighted ? Theme.highlightColor : Theme.primaryColor + truncationMode: TruncationMode.Fade + } + + onClicked: page.openEntry(index) + } + + VerticalScrollDecorator {} + } +} diff --git a/rpm/harbour-henflow.desktop b/rpm/harbour-henflow.desktop index 76f7d71..fad890d 100644 --- a/rpm/harbour-henflow.desktop +++ b/rpm/harbour-henflow.desktop @@ -14,7 +14,9 @@ Categories=Utility; # persistenti (sintomo: "salva via SSH ma non dal launcher"). # Org/App dichiarati QUI (li legge la sandbox) e nel main.cpp (li legge # QStandardPaths): AppConfigLocation = ~/.config/harbour/henflow. -# Nessuna permission: l'app e' completamente locale (nessuna rete). +# Permessi: solo Documenti, dove il backup scrive il file JSON esportato +# (nessuna rete: l'app e' completamente locale). [X-Sailjail] OrganizationName=harbour ApplicationName=henflow +Permissions=Documents diff --git a/rpm/harbour-henflow.spec b/rpm/harbour-henflow.spec index 4e4d104..9e68e1e 100644 --- a/rpm/harbour-henflow.spec +++ b/rpm/harbour-henflow.spec @@ -1,6 +1,6 @@ Name: harbour-henflow Summary: Manage hens, eggs and orders -Version: 0.1.0 +Version: 0.2.0 Release: 1 Group: Qt/Qt License: MIT @@ -26,6 +26,11 @@ pickup calendar, regular customers and a simple dashboard. No account, no cloud: all data stays on the device. %changelog +* Fri Oct 09 2026 Carlo Baratto - 0.2.0-1 +- Porting completo della modalita' allevatore: home con produzione stimata e + coda ordini, scheda ordine con data di consegna calcolata, razze disponibili, + mio pollaio e registro galline, anagrafica clienti, backup/ripristino nel + formato dell'app Android, scelta lingua a runtime, introduzione, informazioni * Thu Oct 08 2026 Carlo Baratto - 0.1.0-1 - Shell del porting nativo Sailfish OS: finestra, pagina principale, cover, impostazioni (lingua) e pipeline RPM complete diff --git a/src/datastore.cpp b/src/datastore.cpp index 26de226..ef92c1b 100644 --- a/src/datastore.cpp +++ b/src/datastore.cpp @@ -1,5 +1,6 @@ #include "datastore.h" +#include #include #include #include @@ -19,6 +20,13 @@ const char *kCustomers = "customers_v1"; const char *kWarehouseStock = "warehouse_stock_v1"; const char *kLastUpdate = "last_production_update_v1"; +// Chiavi del BACKUP: quelle dell'app Android (export/import di SettingsPage), +// non quelle del file interno. Non cambiarle: i file devono restare +// interscambiabili fra la versione Play Store e questa. +const char *kBackupBreeds = "breedSettings"; +const char *kBackupReservations = "reservations"; +const char *kBackupCustomers = "customers"; + } // namespace DataStore::DataStore(QObject *parent) @@ -160,46 +168,81 @@ bool DataStore::reload() return ok; } -bool DataStore::exportTo(const QString &path) const +// --- backup (formato app Android) ----------------------------------------- + +QString DataStore::backupFileName() +{ + return QStringLiteral("henflow-backup-") + + QDate::currentDate().toString(QStringLiteral("yyyy-MM-dd")) + + QStringLiteral(".json"); +} + +QString DataStore::documentsDir() const +{ + const QString dir = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); + if (!dir.isEmpty()) + QDir().mkpath(dir); + return dir; +} + +QString DataStore::backupJson() const { QJsonObject root; - root.insert(QLatin1String(kVersion), 1); - root.insert(QLatin1String(kBreedSettings), QJsonArray::fromVariantList(m_breedSettings)); - root.insert(QLatin1String(kReservations), QJsonArray::fromVariantList(m_reservations)); - root.insert(QLatin1String(kCustomers), QJsonArray::fromVariantList(m_customers)); - root.insert(QLatin1String(kWarehouseStock), QJsonObject::fromVariantMap(m_stock)); - if (!m_lastProductionUpdate.isEmpty()) - root.insert(QLatin1String(kLastUpdate), m_lastProductionUpdate); + root.insert(QLatin1String(kBackupBreeds), QJsonArray::fromVariantList(m_breedSettings)); + root.insert(QLatin1String(kBackupReservations), QJsonArray::fromVariantList(m_reservations)); + root.insert(QLatin1String(kBackupCustomers), QJsonArray::fromVariantList(m_customers)); + return QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented)); +} + +QString DataStore::exportBackup() const +{ + const QString dir = documentsDir(); + if (dir.isEmpty()) { + qWarning() << "henflow: no Documents location for the backup"; + return QString(); + } + const QString path = dir + QLatin1Char('/') + backupFileName(); QSaveFile out(path); if (!out.open(QIODevice::WriteOnly)) { qWarning() << "henflow: cannot write backup" << path << out.errorString(); - return false; + return QString(); } - out.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); - return out.commit(); + out.write(backupJson().toUtf8()); + if (!out.commit()) { + qWarning() << "henflow: backup commit failed" << out.errorString(); + return QString(); + } + return path; } -bool DataStore::importFrom(const QString &path) +bool DataStore::isValidBackup(const QString &jsonText) const { - QFile file(path); - if (!file.open(QIODevice::ReadOnly)) { - qWarning() << "henflow: cannot read backup" << path << file.errorString(); + if (jsonText.trimmed().isEmpty()) return false; - } QJsonParseError error; - const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error); - if (error.error != QJsonParseError::NoError || !doc.isObject()) { - qWarning() << "henflow: backup non valido" << path << error.errorString(); + const QJsonDocument doc = QJsonDocument::fromJson(jsonText.toUtf8(), &error); + if (error.error != QJsonParseError::NoError || !doc.isObject()) + return false; + const QJsonObject root = doc.object(); + // Le due liste indispensabili; i clienti possono mancare (backup vecchi). + return root.value(QLatin1String(kBackupBreeds)).isArray() + && root.value(QLatin1String(kBackupReservations)).isArray(); +} + +bool DataStore::importBackup(const QString &jsonText) +{ + if (!isValidBackup(jsonText)) { + qWarning() << "henflow: backup non valido (attese le chiavi breedSettings/reservations)"; return false; } - const QJsonObject root = doc.object(); - m_breedSettings = root.value(QLatin1String(kBreedSettings)).toArray().toVariantList(); - m_reservations = root.value(QLatin1String(kReservations)).toArray().toVariantList(); - m_customers = root.value(QLatin1String(kCustomers)).toArray().toVariantList(); - m_stock = root.value(QLatin1String(kWarehouseStock)).toObject().toVariantMap(); - m_lastProductionUpdate = root.value(QLatin1String(kLastUpdate)).toString(); + const QJsonObject root = QJsonDocument::fromJson(jsonText.toUtf8()).object(); + m_breedSettings = root.value(QLatin1String(kBackupBreeds)).toArray().toVariantList(); + m_reservations = root.value(QLatin1String(kBackupReservations)).toArray().toVariantList(); + m_customers = root.value(QLatin1String(kBackupCustomers)).toArray().toVariantList(); + // Scorte e ultimo aggiornamento NON stanno nel backup Android: si tengono + // quelli correnti, cosi' il ripristino non azzera il magazzino. setDirty(); return store(); } diff --git a/src/datastore.h b/src/datastore.h index 6433fe8..572a7f8 100644 --- a/src/datastore.h +++ b/src/datastore.h @@ -7,12 +7,15 @@ #include // Persistenza dei dati di Henflow: un unico file JSON in AppConfigLocation -// (~/.config/harbour/henflow/henflow.json), con la STESSA struttura delle -// chiavi SharedPreferences dell'app Flutter, cosi' il backup resta -// compatibile con la versione Android. +// (~/.config/harbour/henflow/henflow.json). Le chiavi sono quelle +// SharedPreferences dell'app Flutter, cosi' i due formati restano leggibili. // // Il calcolo (produzione, coda FIFO, date) NON sta qui: e' in qml/js/henflow.js, // dove e' verificabile con node. Questa classe legge e scrive soltanto. +// +// Il BACKUP invece e' nel formato esatto dell'app Android +// ({breedSettings, reservations, customers}), perche' deve essere +// interscambiabile con i file esportati dalla versione Play Store. class DataStore : public QObject { Q_OBJECT @@ -35,16 +38,21 @@ public: // Rilegge dal disco (usato quando l'app torna in primo piano). Q_INVOKABLE bool reload(); - // Percorso del file dati, per diagnostica e per il backup. + // Percorso del file dati, per diagnostica. Q_INVOKABLE QString dataFilePath() const; - // Backup/ripristino: copia dell'intero file JSON. - Q_INVOKABLE bool exportTo(const QString &path) const; - Q_INVOKABLE bool importFrom(const QString &path); + // Backup/ripristino nel formato dell'app Android. + Q_INVOKABLE QString documentsDir() const; + Q_INVOKABLE QString backupJson() const; // contenuto pronto da copiare + Q_INVOKABLE QString exportBackup() const; // scrive in Documenti -> percorso (vuoto = errore) + Q_INVOKABLE bool importBackup(const QString &jsonText); // sostituisce i dati; false se il formato non va // Cancella tutto (voce "azzera dati" nelle impostazioni). Q_INVOKABLE bool clearAll(); + // Una voce di backup e' valida? (usato dall'interfaccia per abilitare Importa) + Q_INVOKABLE bool isValidBackup(const QString &jsonText) const; + signals: void changed(); @@ -54,6 +62,7 @@ private: bool store(); static QString dataPath(); + static QString backupFileName(); QVariantList m_breedSettings; QVariantList m_reservations; diff --git a/src/main.cpp b/src/main.cpp index eacabda..393d4d1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -18,7 +18,7 @@ Q_DECL_EXPORT int main(int argc, char *argv[]) // AppConfigLocation = ~/.config/harbour/henflow (zona persistente). app->setOrganizationName(QStringLiteral("harbour")); app->setApplicationName(QStringLiteral("henflow")); - app->setApplicationVersion(QStringLiteral("0.1.0")); + app->setApplicationVersion(QStringLiteral("0.2.0")); Settings settings; DataStore data; @@ -48,7 +48,7 @@ Q_DECL_EXPORT int main(int argc, char *argv[]) QQuickView *view = SailfishApp::createView(); - qDebug() << "harbour-henflow v0.1.0 build" << __DATE__ << __TIME__; + qDebug() << "harbour-henflow v0.2.0 build" << __DATE__ << __TIME__; view->rootContext()->setContextProperty(QStringLiteral("appSettings"), &settings); view->rootContext()->setContextProperty(QStringLiteral("appData"), &data); diff --git a/src/settings.cpp b/src/settings.cpp index 44cb017..8cb3be9 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -35,3 +35,18 @@ void Settings::setLanguage(const QString &language) m_settings.sync(); emit languageChanged(); } + +bool Settings::introSeen() const +{ + return m_settings.value(QStringLiteral("ui/intro_seen"), false).toBool(); +} + +void Settings::setIntroSeen(bool seen) +{ + if (seen == introSeen()) + return; + + m_settings.setValue(QStringLiteral("ui/intro_seen"), seen); + m_settings.sync(); + emit introSeenChanged(); +} diff --git a/src/settings.h b/src/settings.h index 4c20c74..94909b5 100644 --- a/src/settings.h +++ b/src/settings.h @@ -13,15 +13,22 @@ class Settings : public QObject { Q_OBJECT Q_PROPERTY(QString language READ language WRITE setLanguage NOTIFY languageChanged) + Q_PROPERTY(bool introSeen READ introSeen WRITE setIntroSeen NOTIFY introSeenChanged) public: explicit Settings(QObject *parent = nullptr); + // "system", "it", "en", "fr", "de" (come l'app Android). QString language() const; void setLanguage(const QString &language); + // L'introduzione e' stata mostrata? (primo avvio) + bool introSeen() const; + void setIntroSeen(bool seen); + signals: void languageChanged(); + void introSeenChanged(); private: static QString configPath(); diff --git a/tests/cpp_test.sh b/tests/cpp_test.sh index 422b024..60d100e 100644 --- a/tests/cpp_test.sh +++ b/tests/cpp_test.sh @@ -13,9 +13,13 @@ export PATH LD_LIBRARY_PATH WORK=$(mktemp -d) trap 'rm -rf "$WORK"' EXIT +# HOME temporanea: anche DocumentsLocation (dove finisce il backup) resta nel +# temporaneo, cosi' il banco di prova non scrive nella cartella vera dell'utente. +HOME="$WORK/home" XDG_CONFIG_HOME="$WORK/config" XDG_DATA_HOME="$WORK/data" -export XDG_CONFIG_HOME XDG_DATA_HOME +mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" +export HOME XDG_CONFIG_HOME XDG_DATA_HOME rm -f Makefile.harness harness qmake harness.pro -o Makefile.harness >/dev/null diff --git a/tests/harness/harness.cpp b/tests/harness/harness.cpp index 7b14b65..5da9ad9 100644 --- a/tests/harness/harness.cpp +++ b/tests/harness/harness.cpp @@ -123,17 +123,74 @@ int main(int argc, char *argv[]) check("chiave last_production_update_v1", root.contains("last_production_update_v1")); } - // --- backup / ripristino ---------------------------------------------- + // --- backup nel formato dell'app Android ------------------------------- + QString exported; { - const QString backup = QDir(QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation)) - .filePath("backup-test.json"); - check("export", store.exportTo(backup)); - check("clearAll", store.clearAll()); - check("dopo clearAll: vuoto", store.reservations().isEmpty() && store.customers().isEmpty()); - check("import", store.importFrom(backup)); - check("dopo import: prenotazioni ripristinate", store.reservations().size() == 1); - check("dopo import: clienti ripristinati", store.customers().size() == 1); - QFile::remove(backup); + exported = store.exportBackup(); + check("export: percorso restituito", !exported.isEmpty(), exported); + check("export: file scritto in Documenti", + exported.contains(QStringLiteral("/Documents/")), exported); + check("export: file esistente", QFile::exists(exported)); + + QFile f(exported); + f.open(QIODevice::ReadOnly); + const QJsonObject root = QJsonDocument::fromJson(f.readAll()).object(); + f.close(); + check("backup: chiave breedSettings (come l'app Android)", root.contains("breedSettings")); + check("backup: chiave reservations", root.contains("reservations")); + check("backup: chiave customers", root.contains("customers")); + check("backup: nessuna chiave interna (breed_settings_v2)", + !root.contains("breed_settings_v2")); + check("backup: esattamente 3 chiavi", root.size() == 3); + } + + // backup prodotto dall'app Android (scritto a mano come lo esporta lei): + // deve essere accettato e sostituire i dati. + { + const QString android = QStringLiteral( + "{\n" + " \"breedSettings\": [\n" + " {\"breedId\": \"livorno\", \"hens\": [\n" + " {\"id\": \"h9\", \"name\": \"Gina\", \"birthDate\": \"2024-03-01T00:00:00.000\", \"isLaying\": true}\n" + " ]}\n" + " ],\n" + " \"reservations\": [\n" + " {\"id\": \"r9\", \"customerName\": \"Luca\", \"breedId\": \"livorno\", \"eggsRequested\": 6,\n" + " \"createdAt\": \"2026-10-01T08:00:00.000\", \"deliveryDate\": \"2026-10-03\", \"isDelivered\": false}\n" + " ],\n" + " \"customers\": [\n" + " {\"id\": \"c9\", \"firstName\": \"Luca\", \"lastName\": \"Bianchi\", \"phoneNumber\": \"333\"}\n" + " ]\n" + "}\n"); + check("backup Android riconosciuto valido", store.isValidBackup(android)); + check("import del backup Android", store.importBackup(android)); + check("import: razze sostituite", store.breedSettings().size() == 1 + && store.breedSettings().at(0).toMap().value("breedId").toString() == "livorno"); + check("import: gallina annidata letta", store.breedSettings().at(0).toMap() + .value("hens").toList().at(0).toMap().value("name").toString() == "Gina"); + check("import: prenotazioni sostituite", store.reservations().size() == 1 + && store.reservations().at(0).toMap().value("eggsRequested").toInt() == 6); + check("import: clienti sostituiti", store.customers().size() == 1 + && store.customers().at(0).toMap().value("lastName").toString() == "Bianchi"); + + // il backup Android non porta scorte ne' ultima produzione: si tengono + check("import: magazzino preservato", !store.warehouseStock().isEmpty()); + check("import: data aggiornamento preservata", + store.lastProductionUpdate() == "2026-10-09"); + + // rilettura da disco: l'import e' stato salvato + DataStore reopened; + check("import: salvato su disco", reopened.reservations().size() == 1 + && reopened.breedSettings().at(0).toMap().value("breedId").toString() == "livorno"); + } + + // backup senza 'customers' (le versioni vecchie non lo avevano) + { + const QString old = QStringLiteral("{\"breedSettings\": [], \"reservations\": []}"); + check("backup senza clienti: valido", store.isValidBackup(old)); + check("backup senza clienti: importato", store.importBackup(old)); + check("backup senza clienti: liste vuote", store.breedSettings().isEmpty() + && store.customers().isEmpty()); } // --- file corrotto: non deve crashare e non deve perdere i dati --------- @@ -147,10 +204,16 @@ int main(int argc, char *argv[]) check("file corrotto: non crasha", true); check("file corrotto: stato vuoto", broken.reservations().isEmpty()); - // il backup di un file corrotto viene rifiutato - check("import di un file non valido rifiutato", !broken.importFrom(store.dataFilePath())); + check("import di un JSON non valido rifiutato", !broken.importBackup("{ rotto")); + check("import di un oggetto senza le chiavi richieste rifiutato", + !broken.importBackup("{\"altro\": 1}")); + check("import di un array (non oggetto) rifiutato", !broken.importBackup("[]")); + check("import di testo vuoto rifiutato", !broken.importBackup(" ")); + check("isValidBackup su testo vuoto", !broken.isValidBackup("")); } + QFile::remove(exported); + qInfo().noquote() << (failures == 0 ? "\ntutti i controlli superati" : QString("\n%1 controlli falliti").arg(failures)); return failures == 0 ? 0 : 1; diff --git a/tests/run.sh b/tests/run.sh index dba4c7b..1bd480f 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -9,3 +9,24 @@ node tests/engine.test.js echo echo "== traduzioni e catalogo ==" node tests/i18n.test.js +echo +echo "== interfaccia QML (controlli statici) ==" +python3 tools/check_qml.py +echo +echo "== sintassi QML (qmllint, se il Qt host c'e') ==" +# qmllint senza il modulo Silica controlla SOLO la sintassi, ma proprio gli +# errori di sintassi (es. `id x` senza i due punti) fanno fallire la pagina +# sul device con "Impossibile caricare la pagina": meglio vederli qui. +QTDIR=${QTDIR:-/home/kaneda/Qt/5.15.2/gcc_64} +if [ -x "$QTDIR/bin/qmllint" ]; then + PATH="$QTDIR/bin:$PATH" + export PATH + find qml -name '*.qml' | while read -r f; do + # si scartano solo le righe sui moduli mancanti (Silica qui non c'e') + qmllint "$f" 2>&1 | grep -vE "is not installed|Cannot find|^Warnings occurred|^$" \ + | sed "s|^|$f: |" || true + done + echo "qmllint: nessun errore di sintassi" +else + echo "qmllint non disponibile: controllo saltato" +fi diff --git a/tools/check_qml.py b/tools/check_qml.py new file mode 100644 index 0000000..ef47254 --- /dev/null +++ b/tools/check_qml.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Controlli statici sulla QML di harbour-henflow. + +La UI Silica non e' eseguibile qui (serve l'SDK Sailfish), ma quasi tutti gli +errori che si vedono sul device sono verificabili PRIMA di consegnare: + * chiavi di traduzione inesistenti (schermata con la chiave grezza a video) + * funzioni del motore JS chiamate ma non esistenti (TypeError a runtime) + * pagine aperte con pageStack ma senza file (pagina che non si apre) + * componenti Silica/QtQuick inesistenti (\"Impossibile caricare la pagina\") + * parentesi non bilanciate (l'intero file QML non compila) + +Uso: python3 tools/check_qml.py # esce 1 se trova problemi +""" + +import json +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +QML_DIR = os.path.join(ROOT, "qml") +JS_DIR = os.path.join(QML_DIR, "js") + +# Componenti disponibili: Silica 1.0 (elenco ufficiale) + QtQuick/JS di base. +SILICA = """ +AddAnimation ApplicationWindow BackgroundItem BusyIndicator BusyLabel Button ButtonLayout +Clipboard ColorPicker ColorPickerDialog ColorPickerPage ColumnView ComboBox ContextMenu Cover +CoverAction CoverActionList CoverBackground CoverPlaceholder DatePicker DatePickerDialog DetailItem +Dialog DialogHeader DockedPanel Drawer EnterKey FadeAnimation FadeAnimator FirstTimeUseCounter +GridItem HighlightImage HorizontalScrollDecorator Icon IconButton IconTextSwitch InteractionHintLabel +Keypad Label LinkedLabel ListItem MenuItem MenuLabel Notices OpacityRampEffect Page PageBusyIndicator +PagedView PageHeader PageStack Palette PasswordField ProgressBar PullDownMenu PushUpMenu Remorse +RemorseItem RemorsePopup RemoveAnimation Screen ScrollDecorator SearchField SectionHeader Separator +SilicaControl SilicaFlickable SilicaGridView SilicaItem SilicaListView Slider SlideshowView +StandardPaths Switch TapInteractionHint TextArea TextField TextSwitch Theme TimePicker TimePickerDialog +TouchBlocker TouchInteractionHint ValueButton VerticalScrollDecorator ViewPlaceholder +""".split() + +QTQUICK = """ +AnchorChanges AnchorAnimation Behavior Binding BorderImage Canvas Column Connections Component +DoubleValidator Easing Enumerators Flipable Flow FontLoader FontMetrics Grid Image IntValidator +Item ItemGradient KeyNavigation Keys LayoutMirroring ListElement ListModel ListView Loader +MouseArea NumberAnimation NumberAnimationOnRotation ParallelAnimation ParentAnimation ParentChange +Path PathAnimation PauseAnimation PointDirection PropertyAction PropertyAnimation PropertyChanges +Rectangle Repeater RotationAnimation Row ScaleAnimator ScriptAction SequentialAnimation Shape +SmoothedAnimation SpringAnimation State StateGroup StateChangeScript StateMachine Text TextEdit +TextInput TextMetrics Timer Transition Translate VectorImage ViewTransition XAnimator YAnimator +NumberAnimator OpacityAnimator RegExpValidator ScrollView SplitView StackView TabBar TabView +ToolBar ToolButton GroupBox CheckBox RadioButton Slider SpinBox DialogButtonBox +""".split() + +KNOWN = set(SILICA) | set(QTQUICK) + + +def strip_strings(src): + """Rimuove stringhe e commenti (per bilanciamento e per i controlli sui tipi).""" + out = [] + i, n = 0, len(src) + while i < n: + c = src[i] + if c == "/" and i + 1 < n and src[i + 1] == "/": + while i < n and src[i] != "\n": + i += 1 + elif c == "/" and i + 1 < n and src[i + 1] == "*": + i += 2 + while i + 1 < n and not (src[i] == "*" and src[i + 1] == "/"): + i += 1 + i += 2 + elif c in "\"'": + quote = c + i += 1 + while i < n and src[i] != quote: + i += 2 if src[i] == "\\" else 1 + i += 1 + out.append('""') + else: + out.append(c) + i += 1 + return "".join(out) + + +def js_symbols(path): + """Simboli di primo livello esportati da un modulo JS (var e function).""" + src = open(path, encoding="utf-8").read() + names = set(re.findall(r"^function\s+(\w+)", src, re.M)) + names |= set(re.findall(r"^var\s+(\w+)", src, re.M)) + names |= set(re.findall(r"^ (\w+):\s", src, re.M)) # voci di module.exports + return names + + +def i18n_languages(path): + """{lingua: insieme di chiavi} dal modulo i18n.js.""" + src = open(path, encoding="utf-8").read() + m = re.search(r"var HF_STRINGS = (\{.*?\n\});", src, re.S) + if not m: + raise SystemExit("check_qml: HF_STRINGS non trovato in i18n.js") + data = json.loads(m.group(1)) + return {lang: set(keys) for lang, keys in data.items()} + + +def qml_files(): + found = [] + for base, _dirs, files in os.walk(QML_DIR): + for f in files: + if f.endswith(".qml"): + found.append(os.path.join(base, f)) + return sorted(found) + + +def main(): + problems = [] + files = qml_files() + if not files: + raise SystemExit("check_qml: nessun file QML trovato") + + hf = js_symbols(os.path.join(JS_DIR, "henflow.js")) + cat = js_symbols(os.path.join(JS_DIR, "catalog.js")) + i18n = js_symbols(os.path.join(JS_DIR, "i18n.js")) + langs = i18n_languages(os.path.join(JS_DIR, "i18n.js")) + all_keys = set.intersection(*langs.values()) if langs else set() + + for path in files: + rel = os.path.relpath(path, ROOT) + raw = open(path, encoding="utf-8").read() + src = strip_strings(raw) + + # 1. bilanciamento parentesi + for opener, closer in (("{", "}"), ("(", ")"), ("[", "]")): + if src.count(opener) != src.count(closer): + problems.append("%s: %s%s non bilanciate (%d vs %d)" + % (rel, opener, closer, src.count(opener), src.count(closer))) + + # 2. riferimenti ai moduli JS + for mod, symbols, spelled in (("HF", hf, "henflow.js"), + ("Catalog", cat, "catalog.js"), + ("I18N", i18n, "i18n.js")): + for name in sorted(set(re.findall(r"\b%s\.(\w+)" % mod, src))): + if name not in symbols: + problems.append("%s: %s.%s non esiste in %s" % (rel, mod, name, spelled)) + + # 3. chiavi di traduzione usate + for key in sorted(set(re.findall(r'\b(?:I18N\.t|tr)\(\s*"([A-Za-z0-9_]+)"', raw))): + if key not in all_keys: + problems.append("%s: chiave di traduzione mancante: %s" % (rel, key)) + + # 4. pagine aperte con pageStack + for target in sorted(set(re.findall(r'Qt\.resolvedUrl\(\s*"([^"]+\.qml)"', raw))): + if not os.path.exists(os.path.join(os.path.dirname(path), target)): + problems.append("%s: pagina inesistente: %s" % (rel, target)) + + # 5. componenti sconosciuti (Toast & co.) + for name in sorted(set(re.findall(r"^\s*([A-Z][A-Za-z0-9_]*)\s*\{", src, re.M))): + if name not in KNOWN: + problems.append("%s: componente non riconosciuto: %s" % (rel, name)) + + # 6. le chiavi devono esistere in TUTTE le lingue + for lang, keys in sorted(langs.items()): + missing = all_keys - keys + if missing: + problems.append("i18n.js: chiavi assenti in '%s': %s" + % (lang, ", ".join(sorted(missing)))) + + print("check_qml: %d file QML, %d chiavi di traduzione, %d lingue" + % (len(files), len(all_keys), len(langs))) + if problems: + print("\nPROBLEMI (%d):" % len(problems)) + for p in problems: + print(" - " + p) + return 1 + print("nessun problema trovato") + return 0 + + +if __name__ == "__main__": + sys.exit(main())