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
This commit is contained in:
2026-10-09 09:21:47 +02:00
parent a73d94a74e
commit 248e0db899
25 changed files with 2060 additions and 74 deletions
+64 -22
View File
@@ -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)
+65
View File
@@ -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 {}
}
}
+130
View File
@@ -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 {}
}
}
+257
View File
@@ -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 {}
}
}
+206
View File
@@ -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)
}
}
+152
View File
@@ -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 {}
}
}
+119
View File
@@ -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 {}
}
}
+128
View File
@@ -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 {}
}
}
+261
View File
@@ -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 {}
}
}
+74
View File
@@ -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 {}
}
}
+81
View File
@@ -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 {}
}
}
+33 -2
View File
@@ -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 })
}
}
+1 -1
View File
@@ -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
}
+96
View File
@@ -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 {}
}
}
+3 -1
View File
@@ -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
+6 -1
View File
@@ -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 <carlo@carlobaratto.it> - 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 <carlo@carlobaratto.it> - 0.1.0-1
- Shell del porting nativo Sailfish OS: finestra, pagina principale,
cover, impostazioni (lingua) e pipeline RPM complete
+68 -25
View File
@@ -1,5 +1,6 @@
#include "datastore.h"
#include <QDate>
#include <QDebug>
#include <QDir>
#include <QFile>
@@ -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();
}
+16 -7
View File
@@ -7,12 +7,15 @@
#include <QVariantMap>
// 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;
+2 -2
View File
@@ -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);
+15
View File
@@ -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();
}
+7
View File
@@ -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();
+5 -1
View File
@@ -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
+75 -12
View File
@@ -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;
+21
View File
@@ -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
+175
View File
@@ -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())