Compare commits

...
Author SHA1 Message Date
livio 3aa4ff1564 Enable fiducial download from navigation context 2026-10-06 16:55:30 +02:00
livio 74527638c1 Unify cadastral object selection 2026-10-06 00:15:05 +02:00
livio a0e24fc925 Smooth compass and strengthen map selection 2026-10-05 22:42:18 +02:00
livio 8a8fc1dfe4 Keep comparison and multi-selection visible 2026-10-05 18:29:18 +02:00
livio e70a30f7d7 Fix geometry comparison and add map multi-select 2026-10-05 11:35:05 +02:00
livio 47b58a9b0e Preserve fiducial details when editing notes 2026-10-05 09:18:30 +02:00
livio fc1ee8f21a Add Pro multiple notes and refresh guides 2026-10-05 08:31:32 +02:00
livio bb69b9fd0d Keep compass rose fixed while needle turns 2026-10-05 02:50:36 +02:00
livio 260e0a3df5 Keep compass responsive in north-up mode 2026-10-05 01:18:28 +02:00
livio a15c8e0b36 Add draggable compass and restore project fiducials 2026-10-04 23:46:22 +02:00
livio aa389f7581 Prepare Pro 1.0.15 release 2026-10-04 22:01:22 +02:00
livio 812420b86a Add heading-up compass and persist fiducial layers 2026-10-04 21:40:34 +02:00
livio bc037a57d4 Prepare Pro 1.0.14 release 2026-10-04 20:23:59 +02:00
livio ce355f820c Add contextual fiducial point downloads 2026-10-04 19:28:31 +02:00
livio 85e1c93d8a Add mapped Pro fiducial point layer 2026-10-04 15:18:42 +02:00
livio 7b642afe2d Add Pro fiducial monograph lookup 2026-10-04 14:38:49 +02:00
livio 342935d99d Show cadastral sheets while navigating 2026-10-02 11:24:35 +02:00
livio 5ea424b46c Improve cadastral GPS reliability and mobile overlays 2026-10-02 08:38:14 +02:00
livio 29c3848a22 Fix managed Google Play product catalog result 2026-10-01 10:58:47 +02:00
livio f27e47d83a Improve Google Play subscription offer handling 2026-10-01 08:35:12 +02:00
livio bcc9e01179 Fix mobile map overlays and GPS recentering 2026-09-30 21:17:15 +02:00
livio 8afb79f893 Fix narrow-screen layer panel layout 2026-09-30 21:09:07 +02:00
livio 01da7efc2f Fix unavailable Play subscription handling 2026-09-30 20:59:24 +02:00
livio 6a675ce635 Add subscription pricing and Base drawing limit 2026-09-30 10:12:24 +02:00
livio ad64d6def0 Add Google Play Store assets 2026-09-29 12:05:01 +02:00
livio a7c8127091 Require HTTPS for WebDAV sync 2026-09-29 11:27:42 +02:00
livio 480134538a Add Play review license command 2026-09-29 11:15:34 +02:00
livio f41d263a2e Add Play Store privacy policy 2026-09-29 11:09:14 +02:00
livio 91fd60766d Fix mobile panel safe area 2026-09-28 21:01:09 +02:00
35 changed files with 3776 additions and 221 deletions

No files matched your search

+20 -4
View File
@@ -24,6 +24,7 @@
{
"layers" => "Qui controlli visibilita, ordine, selezione e organizzazione di tutti gli oggetti presenti sulla mappa.",
"cadastre" => "Trova una particella nel catalogo catastale e mostrane il confine ufficiale sulla mappa.",
"fiducials" => "Ricerca i Punti Fiduciali di un foglio, consultali sulla mappa e scarica le monografie disponibili.",
"data" => "Importa file GIS, crea disegni e misure oppure aggiungi punti di interesse.",
"save" => "Esporta gli elementi della mappa in un file compatibile con altre applicazioni GIS e GPS.",
"projects" => "Salva il lavoro completo, riaprilo, organizzalo e recupera revisioni o copie di sicurezza.",
@@ -40,6 +41,7 @@
"Gerarchia: premi la freccia accanto alla maniglia per aprire un gruppo. Il rientro, il carattere e la tonalita indicano se una riga e figlia, nipote o di livello successivo.",
"Selezione multipla: attiva il flag sulle righe desiderate. Esempio: seleziona tre poligoni per esportare solo quelli oppure per bloccarli con una sola operazione.",
"Rinomina e attributi: Rinomina modifica il nome mostrato e salvato nei progetti e nei file esportati; Attributi apre un editor multilinea per consultare o correggere i dati associati.",
"Note: espandi un oggetto e premi Visualizza note per consultarne l'elenco. Con LME Pro, tramite abbonamento o licenza manuale, puoi aggiungere, modificare ed eliminare un numero qualsiasi di note; le vecchie note singole restano compatibili.",
"Colori: espandi un padre o un figlio e usa gli editor di linea e riempimento. Puoi scegliere il colore, prelevarlo con il contagocce se supportato, oppure inserire e copiare i valori HEX, RGB e HSL; sul padre viene applicato a tutto il gruppo.",
"Blocco: il lucchetto impedisce modifiche e spostamenti involontari. Sblocca l'oggetto prima di usare la matita o gli strumenti geometrici.",
"Sposta nel gruppo cambia l'appartenenza. Esempio: trasferisci un poligono da Disegni e POI a Progetti e dati importati, oppure in un nuovo sottogruppo chiamato Rilievo nord.",
@@ -56,6 +58,16 @@
"Se un oggetto gia presente era nascosto, viene reso nuovamente visibile. La visibilita generale dei contorni evidenziati e dei dati importati resta controllabile dal pannello Layer.",
"Quando il servizio ufficiale non restituisce subito il confine, attendi il completamento indicato nel pannello e riprova mantenendo gli stessi dati selezionati."
],
"fiducials" =>
[
"La funzione richiede LME Pro: sono validi sia l'abbonamento Google Play sia una licenza manuale attiva.",
"Scegli Provincia, Comune catastale e Foglio, quindi premi Cerca Punti Fiduciali. Puoi anche usare il comando PF sulla mappa dopo aver selezionato una particella o un foglio, dalla particella individuata dal GPS oppure, in navigazione, mostrando il layer del foglio catastale corrente.",
"Mantieni i PF dei fogli gia caricati conserva i risultati precedenti come sottogruppi distinti; disattivalo per sostituirli con il nuovo foglio.",
"Il flag Mostra i PF sulla mappa controlla la visibilita generale. Nel pannello Layer puoi inoltre cambiare colore, ordine, visibilita o rimuovere il singolo gruppo del foglio.",
"Tocca un punto rosso per vedere identificativo e coordinate. Quando disponibile, Scarica PDF monografia apre o salva il documento ufficiale dell'Agenzia delle Entrate.",
"I PF caricati vengono salvati nel progetto con gruppi, popup e collegamenti alle monografie; riaprendo il progetto tornano disponibili come gli altri oggetti.",
"Il posizionamento cartografico e sperimentale e non sostituisce un rilievo topografico o la verifica professionale sul posto."
],
"data" =>
[
"Importazione: premi Carica KML, KMZ o GeoJSON e scegli il file dal dispositivo. I nomi e gli attributi riconosciuti vengono mantenuti.",
@@ -75,11 +87,11 @@
"Attiva solo le categorie da includere. Esempio: per consegnare disegni e particelle importate senza la traccia GPS, lascia attive quelle due voci e disattiva Traccia GPS.",
"Solo oggetti selezionati esporta esclusivamente le righe contrassegnate nel pannello Layer. Se non devi filtrare manualmente, lascia questa opzione disattivata.",
"Premi Esporta e salva file e scegli la cartella o l'app di destinazione proposta dal dispositivo.",
"Nomi rinominati e attributi vengono salvati in GeoJSON, KML e KMZ. Gli sfondi satellitari, stradali e WMS non vengono incorporati nei file vettoriali."
"Nomi rinominati, attributi e note compatibili vengono salvati nei formati che supportano metadati. Gli sfondi satellitari, stradali e WMS non vengono incorporati nei file vettoriali."
],
"projects" =>
[
"Nuovo progetto: inserisci un nome, scegli se includere i contorni catastali evidenziati e premi Salva progetto. Vengono conservati oggetti, gruppi, nomi, attributi e opzioni di visualizzazione.",
"Nuovo progetto: inserisci un nome, scegli se includere i contorni catastali evidenziati e premi Salva progetto. Vengono conservati oggetti, gruppi, nomi, attributi, note, Punti Fiduciali caricati e opzioni di visualizzazione.",
"Aggiornamento: dopo aver aperto un progetto, usa di nuovo Salva progetto con lo stesso nome per aggiornarlo. Se nelle Impostazioni attivi Aggiorna automaticamente il progetto aperto, le modifiche vengono registrate dopo 3 secondi di inattivita.",
"Cartelle: seleziona un progetto, scrivi per esempio Cantieri 2026 e premi Sposta per organizzarlo nell'archivio; questa operazione non sposta le geometrie sulla mappa.",
"Rinomina cambia il nome del progetto selezionato senza alterarne contenuti e revisioni. Duplica crea invece una copia modificabile, utile prima di una variante importante o quando ricevi un progetto in sola lettura.",
@@ -90,7 +102,8 @@
],
"tools" =>
[
"Tabella oggetti: apre una vista ordinata di particelle, file importati e disegni. Usa i flag per selezionare piu righe, il lucchetto per proteggerle e la matita per modificare i metadati.",
"Tabella oggetti: apre una vista ordinata di particelle catastali, file importati e disegni. La selezione e bidirezionale con mappa e pannello Layer: Singola sostituisce la scelta, Multipla aggiunge o rimuove oggetti, Disattivata deseleziona tutto e blocca nuove selezioni. Cancella selezione toglie soltanto lo stato selezionato e non elimina geometrie.",
"Colonna Note: il pulsante Note e sempre attivo e apre tutte le note dell'oggetto. La consultazione resta disponibile anche senza Pro; aggiunta, modifica ed eliminazione richiedono abbonamento o licenza manuale.",
"Copia metadati: nella maschera Metadati oggetto scegli Copia da e l'oggetto sorgente. Attiva Importa anche i valori per copiare nomi e contenuti; disattivalo per creare soltanto gli stessi campi, lasciandoli vuoti o mantenendo i valori gia presenti.",
"Coordinate WGS84 e UTM: scegli un punto sulla mappa per leggere latitudine e longitudine oppure le coordinate metriche UTM, utili per rilievi e confronti tecnici.",
"Registro operazioni: mostra in ordine cronologico disegni, modifiche, rinomine, spostamenti ed eliminazioni, cosi puoi capire cosa e cambiato durante la sessione.",
@@ -102,6 +115,8 @@
"settings" =>
[
"Mappa e sfondo: scegli la cartografia di base e regola Trasparenza catasto per leggere meglio confini e immagini sottostanti. Esempio: riducila quando le linee coprono dettagli del terreno.",
"Bussola: attiva Mostra bussola per visualizzare N, NE, E, SE, S, SW, W e NW. Puoi trascinarla nella posizione preferita. La modalita predefinita mantiene il nord della mappa fisso in alto e la lancetta indica il nord con movimento continuo; in modalita Navigatore la mappa segue l'orientamento del dispositivo.",
"Messaggi mappa: scegli dopo quanti secondi devono chiudersi automaticamente; imposta 0 per mantenerli finche non premi la X.",
"Autosalvataggio locale dopo ogni modifica: se attivo, ogni disegno, modifica, rinomina o eliminazione aggiorna immediatamente una copia di recupero nel dispositivo. Per usarla apri Dati e disegni e premi Recupera ultimo autosalvataggio.",
"Aggiorna automaticamente il progetto aperto: funziona solo dopo aver aperto o salvato un progetto modificabile. Dopo una modifica attende 3 secondi senza altre operazioni e aggiorna quel progetto; un progetto in sola lettura non viene mai sovrascritto.",
"Backup automatico: scegli Giornaliero o Settimanale. Alla successiva pressione di Salva progetto, se e trascorso l'intervallo, viene creata una copia interna di tutti i progetti; l'app conserva le 10 copie automatiche piu recenti. Disattivato non crea nuove copie programmate.",
@@ -109,7 +124,8 @@
"Aggancio ai vertici: avvicinando un nuovo vertice a uno esistente, l'app lo allinea entro la distanza in pixel scelta. Aumenta la distanza su schermi piccoli; riducila quando ci sono molti vertici ravvicinati.",
"Centra e zooma quando seleziono un oggetto: attivalo per inquadrare automaticamente ogni elemento scelto; disattivalo se vuoi mantenere ferma la vista mentre lavori dall'elenco Layer.",
"WebDAV: inserisci indirizzo, utente e password applicazione, salva la configurazione, poi usa Invia al cloud per caricare il backup o Scarica dal cloud per ripristinare quello remoto.",
"GPS e percorso: Mostra punto visualizza posizione e precisione; Registra traccia memorizza il cammino; distanza e avvisi aiutano a controllare l'avvicinamento e l'attraversamento del confine selezionato.",
"GPS e percorso: Mostra punto visualizza posizione e precisione; Registra traccia memorizza il cammino; distanza e avvisi aiutano a controllare l'avvicinamento e l'attraversamento del confine selezionato. Avvia/Ferma GPS e Centra sono disposti verticalmente e il riquadro puo essere compresso.",
"Fogli catastali in navigazione: con Fogli catastali attivo nel pannello Layer, l'app carica il foglio anche senza particelle o progetti e lo mantiene visibile a zoom piu ampi senza duplicarlo se e gia presente.",
"Google Maps e Azure Maps richiedono le rispettive chiavi solo quando scegli quegli sfondi. Controlla nuova versione confronta la versione installata con l'ultima pubblicata e mostra le relative modifiche."
],
_ => []
+558 -8
View File
@@ -3,6 +3,7 @@
@inject PreciseLocationService LocationService
@inject CadastralCatalogService CatalogService
@inject OfficialCadastreService OfficialCadastreService
@inject FiducialPointService FiducialPointService
@inject LocationSearchService LocationSearchService
@inject NetworkLocationService NetworkLocationService
@inject AzureMapsService AzureMapsService
@@ -18,7 +19,7 @@
<PageTitle>LME Mappe Catastali Pro</PageTitle>
<div class="map-app">
<section class="map-stage @(ActiveTab == "search" ? "search-active" : null)">
<section class="map-stage @(ActiveTab == "search" ? "search-active" : null) @(LayerPanelOpen ? "layers-open" : null)">
<div id="map"></div>
<header class="map-toolbar">
@@ -46,6 +47,7 @@
<button @onclick='() => SelectTab("map")'>@L("menu.map")</button>
<button @onclick='() => SelectTab("search")'>@L("menu.search")</button>
<button @onclick='() => SelectTab("cadastre")'>@L("menu.cadastre")</button>
<button @onclick='() => SelectTab("fiducials")'>Punti fiduciali</button>
<button @onclick='() => SelectTab("data")'>@L("menu.data")</button>
<button @onclick='() => SelectTab("projects")'>@L("menu.projects")</button>
<button @onclick='() => SelectTab("save")'>@L("menu.export")</button>
@@ -94,6 +96,16 @@
<small>Particelle e geometrie verdi di file e progetti</small>
</span>
</label>
@if (CanUse(AppFeatures.FiducialPoints))
{
<label class="map-layer-option">
<input type="checkbox" @bind="ShowFiducialPoints" @bind:after="ApplyFiducialVisibility" />
<span>
<strong>Punti fiduciali</strong>
<small>PF georiferiti caricati nella ricerca Pro</small>
</span>
</label>
}
<section class="layer-tree-section">
<header>
<strong>Ordine livelli e oggetti</strong>
@@ -109,13 +121,18 @@
</section>
}
<div class="gps-overlay @(ActiveTab == "search" ? "search-open" : null)">
<div class="gps-overlay @(ActiveTab == "search" ? "search-open" : null) @(GpsOverlayCollapsed ? "is-collapsed" : null)">
<div class="gps-state">
<span id="gpsState">GPS non attivo</span>
<strong id="parcelState">Nessuna particella selezionata</strong>
</div>
<div class="gps-actions">
<button class="gps-main" @onclick="ToggleGps">@(GpsActive ? "Ferma GPS" : "Avvia GPS")</button>
<button class="gps-center" @onclick="CenterOnGps" aria-label="Centra posizione">Centra</button>
<button class="gps-center" @onclick="CenterOnGps" aria-label="Centra posizione GPS">Centra</button>
</div>
<button class="gps-collapse" @onclick="ToggleGpsOverlay" aria-label="@(GpsOverlayCollapsed ? "Espandi pannello GPS" : "Riduci pannello GPS")" title="@(GpsOverlayCollapsed ? "Espandi pannello GPS" : "Riduci pannello GPS")">
<span aria-hidden="true">@(GpsOverlayCollapsed ? "+" : "−")</span>
</button>
</div>
@if (ActiveTab is not "map" and not "search")
@@ -196,6 +213,105 @@
<MenuHelp Topic="cadastre" />
</section>
}
else if (ActiveTab == "fiducials")
{
<section class="panel-content">
@if (!CanUse(AppFeatures.FiducialPoints))
{
<div class="service-note protected-service">
<strong>Funzione LME Pro</strong>
<p>La ricerca dei Punti Fiduciali e delle monografie ufficiali è disponibile con abbonamento Pro o chiave manuale valida.</p>
<button class="primary-command" @onclick='() => SelectTab("settings")'>Apri abbonamento Pro</button>
</div>
}
else
{
<label>
Provincia
<select value="@SelectedFiducialProvinceCode" @onchange="OnFiducialProvinceChanged" disabled="@FiducialBusy">
<option value="">Seleziona provincia</option>
@foreach (var province in FiducialProvinces)
{
<option value="@province.Code">@province.Name</option>
}
</select>
</label>
<label>
Comune catastale
<select value="@SelectedFiducialMunicipalityCode" @onchange="OnFiducialMunicipalityChanged" disabled="@(FiducialMunicipalities.Count == 0 || FiducialBusy)">
<option value="">Seleziona comune</option>
@foreach (var municipality in FiducialMunicipalities)
{
<option value="@municipality.Code">@municipality.Name (@municipality.Code)</option>
}
</select>
</label>
<label>
Foglio
<select @bind="SelectedFiducialSheet" disabled="@(FiducialSheets.Count == 0 || FiducialBusy)">
<option value="">Seleziona foglio</option>
@foreach (var sheet in FiducialSheets)
{
<option value="@sheet.Code">@sheet.Name</option>
}
</select>
</label>
<button class="primary-command" @onclick="SearchFiducials" disabled="@(string.IsNullOrWhiteSpace(SelectedFiducialSheet) || FiducialBusy)">
@(FiducialBusy ? "Ricerca..." : "Cerca Punti Fiduciali")
</button>
<label class="toggle-line">
<input type="checkbox" @bind="KeepPreviousFiducialPoints" @bind:after="ApplyKeepPreviousFiducials" />
<span>Mantieni i PF dei fogli già caricati</span>
</label>
<small class="field-help">Ogni foglio resta un sottogruppo separato nel pannello Layer, da cui puoi nasconderlo o rimuoverlo.</small>
<label class="toggle-line">
<input type="checkbox" @bind="ShowFiducialPoints" @bind:after="ApplyFiducialVisibility" />
<span>Mostra i PF sulla mappa</span>
</label>
<label>
Colore PF
<input type="color" @bind="FiducialColor" @bind:after="ApplyFiducialColor" />
</label>
@if (!string.IsNullOrWhiteSpace(FiducialMessage))
{
<div class="catalog-progress"><span>@FiducialMessage</span></div>
}
@if (MappedFiducialPoints.Count > 0)
{
<section class="panel-section">
<h2>Punti cartografici (@MappedFiducialPoints.Count)</h2>
<button class="secondary-command" @onclick="FitFiducialPoints">Inquadra tutti i PF</button>
<div class="fiducial-list">
@foreach (var point in MappedFiducialPoints)
{
<article class="fiducial-item">
<strong>@point.Identifier</strong>
<small>Nord/X: @FormatCoordinate(point.CadastralNorth) · Est/Y: @FormatCoordinate(point.CadastralEast)</small>
@if (!string.IsNullOrWhiteSpace(point.MonographUrl))
{
<button class="secondary-command" @onclick="() => DownloadFiducialMonograph(point.MonographUrl)">Scarica PDF monografia</button>
}
</article>
}
</div>
</section>
}
<div class="service-note">
<strong>Monografie ufficiali, posizione sperimentale</strong>
<p>Elenco e PDF provengono dall'Agenzia delle Entrate; il posizionamento sperimentale usa il servizio cartografico GloboGIS. Le coordinate sono un ausilio: verificale prima di qualsiasi uso topografico o professionale.</p>
</div>
}
<MenuHelp Topic="fiducials" />
</section>
}
else if (ActiveTab == "data")
{
<section class="panel-content">
@@ -235,6 +351,10 @@
<section class="panel-section">
<h2>Disegni e misure</h2>
@if (!Entitlements.HasProAccess)
{
<small class="field-help">@L("drawings.base.limit")</small>
}
<div class="button-row">
<button class="secondary-command" @onclick="UndoMapChange">Annulla</button>
<button class="secondary-command" @onclick="RedoMapChange">Ripristina</button>
@@ -494,8 +614,8 @@
<p>@L("settings.pro.note")</p>
<p>@L("settings.pro.offline")</p>
<div class="button-row">
<button class="primary-command" @onclick='() => SubscribeGooglePro(EntitlementService.MonthlyBasePlanId)' disabled="@BillingBusy">@L("settings.pro.monthly")</button>
<button class="primary-command" @onclick='() => SubscribeGooglePro(EntitlementService.YearlyBasePlanId)' disabled="@BillingBusy">@L("settings.pro.yearly")</button>
<button class="primary-command" @onclick='() => SubscribeGooglePro(EntitlementService.MonthlyBasePlanId)' disabled="@BillingBusy">@MonthlyPlanLabel</button>
<button class="primary-command" @onclick='() => SubscribeGooglePro(EntitlementService.YearlyBasePlanId)' disabled="@BillingBusy">@YearlyPlanLabel</button>
</div>
<div class="button-row">
<button class="secondary-command" @onclick="RestorePurchases" disabled="@BillingBusy">@L("settings.pro.restore")</button>
@@ -549,6 +669,22 @@
Trasparenza catasto
<input type="range" min="0" max="1" step="0.05" value="@CadastralOpacity.ToString(System.Globalization.CultureInfo.InvariantCulture)" @onchange="ChangeCadastreOpacity" />
</label>
<label>
Durata messaggi mappa (secondi)
<input type="number" min="0" max="30" step="1" @bind="MapMessageTimeoutSeconds" @bind:after="ApplyMapMessageOptions" />
</label>
<small class="field-help">Imposta 0 per lasciare i messaggi visibili fino alla chiusura manuale.</small>
<label class="toggle-line project-save-option">
<input type="checkbox" @bind="ShowCompass" @bind:after="ApplyCompassOptions" />
<span>Mostra bussola sulla mappa</span>
</label>
<label>
Orientamento bussola
<select @bind="CompassMode" @bind:after="ApplyCompassOptions" disabled="@(!ShowCompass)">
<option value="fixed">Mappa: nord fisso in alto (predefinito)</option>
<option value="heading">Navigatore: direzione del dispositivo in alto</option>
</select>
</label>
</section>
<section class="panel-section">
@@ -637,6 +773,12 @@
}
</section>
<section class="panel-section">
<h2>Privacy</h2>
<p class="field-help">Consulta come vengono trattati posizione, progetti, licenze e dati inviati ai servizi esterni.</p>
<button class="secondary-command" @onclick="OpenPrivacyPolicy">Apri informativa sulla privacy</button>
</section>
<section class="panel-section">
<h2>GPS e percorso</h2>
<label class="toggle-line project-save-option">
@@ -679,7 +821,10 @@
</aside>
}
<div id="mapInfo" class="map-info">Pronto</div>
<div id="mapInfo" class="map-info" role="status" aria-live="polite">
<span id="mapInfoText" class="map-info-text">Pronto</span>
<button type="button" class="map-info-close" onclick="window.lmeMap.hideInfo()" aria-label="Chiudi messaggio" title="Chiudi messaggio">×</button>
</div>
<div id="wmsPreview" class="wms-preview" hidden>
<section class="wms-preview-dialog" role="dialog" aria-modal="true" aria-labelledby="wmsPreviewTitle">
@@ -719,7 +864,15 @@
private string MeasurementPreference { get; set; } = "auto";
private string ManualLicenseCode { get; set; } = string.Empty;
private string ManualLicenseMessage { get; set; } = string.Empty;
private string MonthlyPrice { get; set; } = string.Empty;
private string YearlyPrice { get; set; } = string.Empty;
private bool BillingBusy { get; set; }
private bool FiducialBusy { get; set; }
private string SelectedFiducialProvinceCode { get; set; } = string.Empty;
private string SelectedFiducialMunicipalityCode { get; set; } = string.Empty;
private string SelectedFiducialSheet { get; set; } = string.Empty;
private string FiducialMessage { get; set; } = string.Empty;
private string FiducialColor { get; set; } = "#dc2626";
private string Status { get; set; } = "online";
private string SelectedBaseLayer { get; set; } = "satellite";
private string GoogleMapsApiKey { get; set; } = string.Empty;
@@ -731,10 +884,17 @@
private string CatalogMessage { get; set; } = string.Empty;
private double? CatalogFraction { get; set; }
private double CadastralOpacity { get; set; } = 0.75;
private int MapMessageTimeoutSeconds { get; set; } = 3;
private bool CatalogBusy { get; set; }
private bool GpsActive { get; set; }
private bool GpsOverlayCollapsed { get; set; }
private bool ShowGpsPosition { get; set; } = true;
private bool ShowCompass { get; set; }
private string CompassMode { get; set; } = "fixed";
private bool CompassSensorSubscribed { get; set; }
private bool ShowSelectedParcels { get; set; } = true;
private bool ShowFiducialPoints { get; set; } = true;
private bool KeepPreviousFiducialPoints { get; set; } = true;
private bool ShowImportedData { get; set; } = true;
private bool DrawToolsVisible { get; set; }
private bool TrailEnabled { get; set; }
@@ -778,6 +938,12 @@
private string UpdateDownloadUrl { get; set; } = string.Empty;
private string UpdateReleaseNotes { get; set; } = string.Empty;
private string ProUpgradeText => "Funzione inclusa in LME Pro. I dati esistenti restano consultabili.";
private string MonthlyPlanLabel => string.IsNullOrWhiteSpace(MonthlyPrice)
? L("settings.pro.monthly")
: $"{L("settings.pro.monthly")} - {MonthlyPrice}/mese";
private string YearlyPlanLabel => string.IsNullOrWhiteSpace(YearlyPrice)
? L("settings.pro.yearly")
: $"{L("settings.pro.yearly")} - {YearlyPrice}/anno";
private CancellationTokenSource? CatalogCancellation { get; set; }
private CancellationTokenSource? GpsLookupCancellation { get; set; }
private CancellationTokenSource? ProjectAutoSaveCancellation { get; set; }
@@ -788,6 +954,11 @@
private IReadOnlyList<MunicipalityOption> Municipalities { get; set; } = [];
private IReadOnlyList<string> Sheets { get; set; } = [];
private IReadOnlyList<ParcelOption> Parcels { get; set; } = [];
private IReadOnlyList<FiducialAreaOption> FiducialProvinces { get; set; } = [];
private IReadOnlyList<FiducialAreaOption> FiducialMunicipalities { get; set; } = [];
private IReadOnlyList<FiducialAreaOption> FiducialSheets { get; set; } = [];
private IReadOnlyList<FiducialPointEntry> FiducialPoints { get; set; } = [];
private IReadOnlyList<MappedFiducialPoint> MappedFiducialPoints { get; set; } = [];
private IReadOnlyList<MapProjectSummary> Projects { get; set; } = [];
private IReadOnlyList<MapProjectVersion> ProjectVersions { get; set; } = [];
private MapProjectSummary? SelectedProject => Projects.FirstOrDefault(item => item.Id == SelectedProjectId);
@@ -801,6 +972,7 @@
private string PanelTitle => ActiveTab switch
{
"cadastre" => "Ricerca catastale",
"fiducials" => "Punti fiduciali",
"data" => "Dati e disegni",
"projects" => "Archivio progetti",
"save" => "Salvataggio ed esportazione",
@@ -812,6 +984,7 @@
private string PanelEyebrow => ActiveTab switch
{
"cadastre" => "Regione, Comune, Foglio, Particella",
"fiducials" => "Monografie ufficiali AdE",
"data" => "File GIS, misure e POI",
"projects" => "Salva, apri e visualizza i tuoi lavori",
"save" => "Esporta file GIS",
@@ -886,6 +1059,7 @@
await ApplyEntitlementsToMapAsync();
await ApplyFlagPreferencesToMapAsync();
await ApplyAdvancedOptions();
await RefreshBillingStateAsync();
await CenterOnInitialLocationAsync();
await MigrateLegacyProjectAsync();
var offlineCount = await JS.InvokeAsync<int>("lmeMap.getOfflineTileStats");
@@ -1022,6 +1196,10 @@
{
await RefreshProjectsAsync();
}
if (tab == "fiducials" && CanUse(AppFeatures.FiducialPoints) && FiducialProvinces.Count == 0)
{
await LoadFiducialProvincesAsync();
}
await Task.Delay(30);
await JS.InvokeVoidAsync("lmeMap.resize");
if (LayerPanelOpen)
@@ -1101,6 +1279,235 @@
: await CatalogService.GetMunicipalitiesAsync(SelectedRegionFile);
}
private async Task LoadFiducialProvincesAsync()
{
FiducialBusy = true;
FiducialMessage = "Carico l'elenco delle province...";
try
{
FiducialProvinces = await FiducialPointService.GetProvincesAsync();
FiducialMessage = string.Empty;
}
catch (Exception error)
{
FiducialMessage = $"Servizio monografie non disponibile: {error.GetBaseException().Message}";
}
finally
{
FiducialBusy = false;
}
}
private async Task OnFiducialProvinceChanged(ChangeEventArgs args)
{
SelectedFiducialProvinceCode = args.Value?.ToString() ?? string.Empty;
SelectedFiducialMunicipalityCode = string.Empty;
SelectedFiducialSheet = string.Empty;
FiducialMunicipalities = [];
FiducialSheets = [];
FiducialPoints = [];
MappedFiducialPoints = [];
if (string.IsNullOrWhiteSpace(SelectedFiducialProvinceCode))
{
FiducialMessage = string.Empty;
return;
}
FiducialBusy = true;
FiducialMessage = "Carico i Comuni catastali...";
try
{
FiducialMunicipalities = await FiducialPointService.GetMunicipalitiesAsync(SelectedFiducialProvinceCode);
FiducialMessage = string.Empty;
}
catch (Exception error)
{
FiducialMessage = $"Comuni non disponibili: {error.GetBaseException().Message}";
}
finally
{
FiducialBusy = false;
}
}
private async Task OnFiducialMunicipalityChanged(ChangeEventArgs args)
{
SelectedFiducialMunicipalityCode = args.Value?.ToString() ?? string.Empty;
SelectedFiducialSheet = string.Empty;
FiducialSheets = [];
FiducialPoints = [];
MappedFiducialPoints = [];
if (string.IsNullOrWhiteSpace(SelectedFiducialMunicipalityCode))
{
FiducialMessage = string.Empty;
return;
}
FiducialBusy = true;
FiducialMessage = "Carico i fogli disponibili...";
try
{
FiducialSheets = await FiducialPointService.GetSheetsAsync(
SelectedFiducialProvinceCode,
SelectedFiducialMunicipalityCode);
FiducialMessage = string.Empty;
}
catch (Exception error)
{
FiducialMessage = $"Fogli non disponibili: {error.GetBaseException().Message}";
}
finally
{
FiducialBusy = false;
}
}
private async Task SearchFiducials()
{
var municipality = FiducialMunicipalities.FirstOrDefault(item => item.Code == SelectedFiducialMunicipalityCode);
if (municipality is null || string.IsNullOrWhiteSpace(SelectedFiducialSheet))
{
return;
}
await LoadFiducialsForSheetAsync(
municipality.Code,
SelectedFiducialSheet,
municipality.Name);
}
private async Task LoadFiducialsForSheetAsync(
string municipalityCode,
string sheet,
string? municipalityName = null)
{
FiducialBusy = true;
FiducialPoints = [];
MappedFiducialPoints = [];
FiducialMessage = "Recupero monografie e coordinate dei PF...";
await InvokeAsync(StateHasChanged);
try
{
string? monographError = null;
try
{
FiducialPoints = await FiducialPointService.GetFiducialsAsync(
municipalityCode,
sheet);
}
catch (Exception error)
{
monographError = error.GetBaseException().Message;
}
var mapped = await FiducialPointService.GetMappedFiducialsAsync(
municipalityCode,
sheet);
var monographs = FiducialPoints.ToDictionary(
item => item.Identifier,
item => item.MonographUrl,
StringComparer.OrdinalIgnoreCase);
var currentMappedPoints = monographs.Count == 0
? mapped
: mapped.Where(point => monographs.ContainsKey(point.Identifier)).ToArray();
MappedFiducialPoints = currentMappedPoints
.Select(point => point with
{
MonographUrl = monographs.GetValueOrDefault(point.Identifier, string.Empty)
})
.ToArray();
await JS.InvokeVoidAsync(
"lmeMap.setFiducialPoints",
MappedFiducialPoints,
FiducialColor,
ShowFiducialPoints,
KeepPreviousFiducialPoints,
municipalityCode,
sheet,
string.IsNullOrWhiteSpace(municipalityName) ? municipalityCode : municipalityName);
FiducialMessage = MappedFiducialPoints.Count == 0
? "Nessun Punto Fiduciale georiferito per il foglio selezionato."
: $"Caricati {MappedFiducialPoints.Count} PF del foglio {NormalizeSheetLabel(sheet)} e {FiducialPoints.Count} monografie ufficiali."
+ (monographError is null ? string.Empty : " Le monografie non sono al momento raggiungibili.");
await JS.InvokeVoidAsync("lmeMap.showInfo", FiducialMessage);
}
catch (Exception error)
{
FiducialMessage = $"Ricerca PF non riuscita: {error.GetBaseException().Message}";
await JS.InvokeVoidAsync("lmeMap.showInfo", FiducialMessage);
}
finally
{
FiducialBusy = false;
await InvokeAsync(StateHasChanged);
}
}
[JSInvokable]
public async Task DownloadFiducialsForMapSelection(
string municipalityCode,
string sheet,
string contextLabel)
{
if (!CanUse(AppFeatures.FiducialPoints))
{
await ShowProRequiredAsync();
return;
}
if (FiducialBusy)
{
await JS.InvokeVoidAsync("lmeMap.showInfo", "È già in corso una ricerca dei Punti Fiduciali.");
return;
}
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Scarico i PF per {contextLabel}...");
var municipality = await CatalogService.FindMunicipalityAsync(municipalityCode);
await LoadFiducialsForSheetAsync(municipalityCode, sheet, municipality?.Name);
}
private static string NormalizeSheetLabel(string sheet) =>
int.TryParse(new string((sheet ?? string.Empty).Where(char.IsDigit).ToArray()), out var number)
? number.ToString(System.Globalization.CultureInfo.InvariantCulture)
: sheet ?? string.Empty;
private async Task DownloadFiducialMonograph(string monographUrl)
{
try
{
await Launcher.Default.OpenAsync(new Uri(monographUrl));
}
catch (Exception error)
{
FiducialMessage = $"Impossibile aprire la monografia: {error.GetBaseException().Message}";
}
}
private async Task ApplyFiducialVisibility()
{
SaveFlagPreferences();
await JS.InvokeVoidAsync("lmeMap.setFiducialPointsVisible", ShowFiducialPoints);
}
private async Task ApplyFiducialColor()
{
SaveFlagPreferences();
await JS.InvokeVoidAsync("lmeMap.setFiducialColor", FiducialColor);
}
private Task ApplyKeepPreviousFiducials()
{
SaveFlagPreferences();
return Task.CompletedTask;
}
private async Task FitFiducialPoints() =>
await JS.InvokeVoidAsync("lmeMap.fitFiducialPoints");
private static string FormatCoordinate(double? value) =>
value?.ToString("0.000", System.Globalization.CultureInfo.InvariantCulture) ?? "-";
private async Task OnMunicipalityChanged(ChangeEventArgs args)
{
SelectedMunicipalityCode = args.Value?.ToString() ?? string.Empty;
@@ -1286,7 +1693,7 @@
Interlocked.Exchange(ref LastGpsLookupAt, now);
try
{
var radius = Math.Clamp(location.Accuracy ?? 5, 2, 25);
var radius = Math.Clamp(location.Accuracy ?? 12, 12, 30);
var xml = await OfficialCadastreService.GetParcelsNearAsync(
location.Latitude,
location.Longitude,
@@ -1396,6 +1803,15 @@
return InvokeAsync(StateHasChanged);
}
[JSInvokable]
public async Task SetCompassFromLayerManager(bool enabled)
{
ShowCompass = enabled;
SaveFlagPreferences();
await ConfigureCompassSensorAsync();
await InvokeAsync(StateHasChanged);
}
[JSInvokable]
public Task<string> GetOfficialCadastralSheetsNear(double latitude, double longitude) =>
OfficialCadastreService.GetSheetsNearAsync(latitude, longitude);
@@ -1906,6 +2322,8 @@
ShowDrawingMeasurements = options.ShowDrawingMeasurements;
DrawToolsVisible = options.DrawToolsVisible;
ShowGpsPosition = options.GpsPositionVisible;
ShowCompass = options.CompassVisible;
CompassMode = options.CompassMode is "heading" ? "heading" : "fixed";
ShowSelectedParcels = options.SelectedParcelsVisible;
TrailEnabled = options.TrailEnabled;
@@ -1915,6 +2333,7 @@
layer.Enabled = activeLayers.Contains(layer.Key);
}
SaveFlagPreferences();
_ = ConfigureCompassSensorAsync();
}
private async Task DeleteProject()
@@ -2075,6 +2494,12 @@
private Task CenterOnGps() => JS.InvokeVoidAsync("lmeMap.centerOnGps").AsTask();
private void ToggleGpsOverlay()
{
GpsOverlayCollapsed = !GpsOverlayCollapsed;
SaveFlagPreferences();
}
private string L(string key) => L10n.T(key);
private bool CanUse(string feature) => Entitlements.CanUse(feature);
@@ -2112,9 +2537,28 @@
private Task ApplyEntitlementsToMapAsync() => JS.InvokeVoidAsync("lmeMap.setEntitlements", new
{
advancedGeometry = CanUse(AppFeatures.AdvancedGeometry)
advancedGeometry = CanUse(AppFeatures.AdvancedGeometry),
fiducialPoints = CanUse(AppFeatures.FiducialPoints),
multipleNotes = CanUse(AppFeatures.MultipleNotes),
maxDrawingObjects = Entitlements.HasProAccess ? (int?)null : EntitlementService.BaseDrawingObjectLimit,
drawingLimitMessage = L("drawings.base.limit.reached")
}).AsTask();
private async Task RefreshBillingStateAsync()
{
var plans = await Entitlements.GetSubscriptionPlansAsync();
MonthlyPrice = plans.FirstOrDefault(plan => plan.BasePlanId == EntitlementService.MonthlyBasePlanId)?.FormattedPrice
?? string.Empty;
YearlyPrice = plans.FirstOrDefault(plan => plan.BasePlanId == EntitlementService.YearlyBasePlanId)?.FormattedPrice
?? string.Empty;
if (DeviceInfo.Current.Platform == DevicePlatform.Android)
{
await Entitlements.RestorePurchasesAsync();
await ApplyEntitlementsToMapAsync();
}
}
private async Task RestorePurchases()
{
BillingBusy = true;
@@ -2302,7 +2746,12 @@
ShowDrawingMeasurements = GetFlagPreference("show_drawing_measurements", true);
DrawToolsVisible = GetFlagPreference("draw_tools_visible", false);
ShowGpsPosition = GetFlagPreference("show_gps_position", true);
ShowCompass = GetFlagPreference("show_compass", false);
CompassMode = Preferences.Default.Get($"{FlagPreferencePrefix}compass_mode", "fixed") is "heading" ? "heading" : "fixed";
ShowSelectedParcels = GetFlagPreference("show_selected_parcels", true);
ShowFiducialPoints = GetFlagPreference("show_fiducial_points", true);
KeepPreviousFiducialPoints = GetFlagPreference("keep_previous_fiducials", true);
FiducialColor = Preferences.Default.Get($"{FlagPreferencePrefix}fiducial_color", "#dc2626");
TrailEnabled = GetFlagPreference("trail_enabled", false);
ExportImported = GetFlagPreference("export_imported", true);
ExportImportedSheets = GetFlagPreference("export_imported_sheets", true);
@@ -2320,6 +2769,11 @@
OfflineExtraZoomLevels = Preferences.Default.Get($"{FlagPreferencePrefix}offline_zoom_levels", 2);
GpsProximityEnabled = GetFlagPreference("gps_proximity", true);
GpsBoundaryAlertEnabled = GetFlagPreference("gps_boundary_alert", false);
GpsOverlayCollapsed = GetFlagPreference("gps_overlay_collapsed", false);
MapMessageTimeoutSeconds = Math.Clamp(
Preferences.Default.Get($"{FlagPreferencePrefix}map_message_timeout", 3),
0,
30);
foreach (var layer in CadastralLayers)
{
@@ -2337,7 +2791,12 @@
SetFlagPreference("show_drawing_measurements", ShowDrawingMeasurements);
SetFlagPreference("draw_tools_visible", DrawToolsVisible);
SetFlagPreference("show_gps_position", ShowGpsPosition);
SetFlagPreference("show_compass", ShowCompass);
Preferences.Default.Set($"{FlagPreferencePrefix}compass_mode", CompassMode);
SetFlagPreference("show_selected_parcels", ShowSelectedParcels);
SetFlagPreference("show_fiducial_points", ShowFiducialPoints);
SetFlagPreference("keep_previous_fiducials", KeepPreviousFiducialPoints);
Preferences.Default.Set($"{FlagPreferencePrefix}fiducial_color", FiducialColor);
SetFlagPreference("trail_enabled", TrailEnabled);
SetFlagPreference("export_imported", ExportImported);
SetFlagPreference("export_imported_sheets", ExportImportedSheets);
@@ -2355,6 +2814,8 @@
Preferences.Default.Set($"{FlagPreferencePrefix}offline_zoom_levels", OfflineExtraZoomLevels);
SetFlagPreference("gps_proximity", GpsProximityEnabled);
SetFlagPreference("gps_boundary_alert", GpsBoundaryAlertEnabled);
SetFlagPreference("gps_overlay_collapsed", GpsOverlayCollapsed);
Preferences.Default.Set($"{FlagPreferencePrefix}map_message_timeout", MapMessageTimeoutSeconds);
foreach (var layer in CadastralLayers)
{
@@ -2364,6 +2825,7 @@
private async Task ApplyFlagPreferencesToMapAsync()
{
await JS.InvokeVoidAsync("lmeMap.setInfoTimeout", MapMessageTimeoutSeconds);
await JS.InvokeVoidAsync("lmeMap.setIncludeKmlSheetLayer", IncludeKmlSheetLayer);
await JS.InvokeVoidAsync("lmeMap.setImportedLayerVisible", ShowImportedData);
await JS.InvokeVoidAsync("lmeMap.setImportedSheetLayerVisible", ShowImportedSheetLayer);
@@ -2371,7 +2833,11 @@
await JS.InvokeVoidAsync("lmeMap.setDrawingMeasurementsVisible", ShowDrawingMeasurements);
await JS.InvokeVoidAsync("lmeMap.setDrawToolsVisible", DrawToolsVisible);
await JS.InvokeVoidAsync("lmeMap.setGpsPositionVisible", ShowGpsPosition);
await JS.InvokeVoidAsync("lmeMap.setCompassOptions", ShowCompass, CompassMode);
await ConfigureCompassSensorAsync();
await JS.InvokeVoidAsync("lmeMap.setSelectedParcelsVisible", ShowSelectedParcels);
await JS.InvokeVoidAsync("lmeMap.setFiducialColor", FiducialColor);
await JS.InvokeVoidAsync("lmeMap.setFiducialPointsVisible", ShowFiducialPoints);
await JS.InvokeVoidAsync("lmeMap.toggleTrail", TrailEnabled);
foreach (var layer in CadastralLayers)
@@ -2380,6 +2846,75 @@
}
}
private async Task ApplyMapMessageOptions()
{
MapMessageTimeoutSeconds = Math.Clamp(MapMessageTimeoutSeconds, 0, 30);
SaveFlagPreferences();
await JS.InvokeVoidAsync("lmeMap.setInfoTimeout", MapMessageTimeoutSeconds);
}
private async Task ApplyCompassOptions()
{
CompassMode = CompassMode is "heading" ? "heading" : "fixed";
SaveFlagPreferences();
await JS.InvokeVoidAsync("lmeMap.setCompassOptions", ShowCompass, CompassMode);
await ConfigureCompassSensorAsync();
}
private Task ConfigureCompassSensorAsync()
{
var compass = Microsoft.Maui.Devices.Sensors.Compass.Default;
var shouldMonitor = ShowCompass && compass.IsSupported;
try
{
if (shouldMonitor && !CompassSensorSubscribed)
{
compass.ReadingChanged += OnCompassReadingChanged;
CompassSensorSubscribed = true;
compass.Start(Microsoft.Maui.Devices.Sensors.SensorSpeed.Game, applyLowPassFilter: true);
}
else if (!shouldMonitor && CompassSensorSubscribed)
{
compass.ReadingChanged -= OnCompassReadingChanged;
CompassSensorSubscribed = false;
if (compass.IsMonitoring)
{
compass.Stop();
}
}
}
catch
{
if (CompassSensorSubscribed)
{
compass.ReadingChanged -= OnCompassReadingChanged;
CompassSensorSubscribed = false;
}
}
return Task.CompletedTask;
}
private async void OnCompassReadingChanged(
object? sender,
Microsoft.Maui.Devices.Sensors.CompassChangedEventArgs args)
{
if (!MapReady || !ShowCompass)
{
return;
}
try
{
await InvokeAsync(() => JS.InvokeVoidAsync(
"lmeMap.setCompassHeading",
args.Reading.HeadingMagneticNorth).AsTask());
}
catch
{
}
}
private async Task ApplyAdvancedOptions()
{
SaveFlagPreferences();
@@ -2431,6 +2966,9 @@
private Task OpenOfficialReservedArea() => Microsoft.Maui.ApplicationModel.Launcher.Default.OpenAsync(
new Uri("https://www.agenziaentrate.gov.it/portale/area-riservata"));
private Task OpenPrivacyPolicy() => Microsoft.Maui.ApplicationModel.Launcher.Default.OpenAsync(
new Uri("https://gitea.lmcloud.it/livio/LMEMappeCatastaliProApp/raw/branch/play-store-pro/PRIVACY_POLICY.md"));
private sealed class CadastralLayer(string key, string name, bool enabled, string detail = "")
{
public string Key { get; } = key;
@@ -2451,6 +2989,8 @@
public bool ShowDrawingMeasurements { get; init; } = true;
public bool DrawToolsVisible { get; init; }
public bool GpsPositionVisible { get; init; } = true;
public bool CompassVisible { get; init; }
public string CompassMode { get; init; } = "fixed";
public bool SelectedParcelsVisible { get; init; } = true;
public bool TrailEnabled { get; init; }
}
@@ -2480,6 +3020,16 @@
GpsLookupCancellation?.Dispose();
ProjectAutoSaveCancellation?.Cancel();
ProjectAutoSaveCancellation?.Dispose();
var compass = Microsoft.Maui.Devices.Sensors.Compass.Default;
if (CompassSensorSubscribed)
{
compass.ReadingChanged -= OnCompassReadingChanged;
if (compass.IsMonitoring)
{
compass.Stop();
}
CompassSensorSubscribed = false;
}
MapCallbackReference?.Dispose();
return ValueTask.CompletedTask;
}
+20 -2
View File
@@ -20,6 +20,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<EnableDefaultCssItems>false</EnableDefaultCssItems>
<Nullable>enable</Nullable>
<DefaultItemExcludes>$(DefaultItemExcludes);obj/**;bin/**;obj-api36*/**;artifacts/**</DefaultItemExcludes>
<!-- Display name -->
<ApplicationTitle>LME Mappe Catastali Pro</ApplicationTitle>
@@ -28,8 +29,8 @@
<ApplicationId>it.liviomerola.lmemappecatastali.pro</ApplicationId>
<!-- Versions -->
<ApplicationDisplayVersion>1.0.2</ApplicationDisplayVersion>
<ApplicationVersion>102</ApplicationVersion>
<ApplicationDisplayVersion>1.0.25</ApplicationDisplayVersion>
<ApplicationVersion>125</ApplicationVersion>
<Version>$(ApplicationDisplayVersion)</Version>
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
@@ -43,8 +44,25 @@
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tizen'">6.5</SupportedOSPlatformVersion>
</PropertyGroup>
<PropertyGroup>
<PlayBillingVerificationUrl Condition="'$(PlayBillingVerificationUrl)' == ''"></PlayBillingVerificationUrl>
<PlayBillingVerificationRequired Condition="'$(PlayBillingVerificationRequired)' == ''">false</PlayBillingVerificationRequired>
</PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>PlayBillingVerificationUrl</_Parameter1>
<_Parameter2>$(PlayBillingVerificationUrl)</_Parameter2>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>PlayBillingVerificationRequired</_Parameter1>
<_Parameter2>$(PlayBillingVerificationRequired)</_Parameter2>
</AssemblyAttribute>
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release' and $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">
<AndroidTargetSdkVersion>36</AndroidTargetSdkVersion>
<RuntimeIdentifiers>android-arm;android-arm64</RuntimeIdentifiers>
<AndroidPackageFormat>aab</AndroidPackageFormat>
<AndroidUseAapt2>true</AndroidUseAapt2>
<AndroidCreatePackagePerAbi>false</AndroidCreatePackagePerAbi>
+2
View File
@@ -19,6 +19,7 @@ public static class MauiProgram
builder.Services.AddSingleton<PreciseLocationService>();
builder.Services.AddSingleton<CadastralCatalogService>();
builder.Services.AddSingleton<OfficialCadastreService>();
builder.Services.AddSingleton<FiducialPointService>();
builder.Services.AddSingleton<LocationSearchService>();
builder.Services.AddSingleton<NetworkLocationService>();
builder.Services.AddSingleton<AzureMapsService>();
@@ -29,6 +30,7 @@ public static class MauiProgram
builder.Services.AddSingleton(IncomingFileService.Shared);
builder.Services.AddSingleton<AppLocalizationService>();
builder.Services.AddSingleton<ManualLicenseService>();
builder.Services.AddSingleton<PlayPurchaseVerificationService>();
#if ANDROID
builder.Services.AddSingleton<IPlayBillingService, Platforms.Android.AndroidPlayBillingService>();
#else
+6 -3
View File
@@ -4,7 +4,7 @@
- Nome: LME Mappe Catastali Pro.
- Package name Android: `it.liviomerola.lmemappecatastali.pro`.
- Versione iniziale Play: `1.0.0` / version code `100`.
- Versione corrente da pubblicare: `1.0.11` / version code `111`.
- Formato Release Android: Android App Bundle (`.aab`).
- Target SDK Release: Android 16 / API 36.
@@ -17,7 +17,7 @@ Base gratuita:
- consultazione mappa catastale;
- ricerca localita e ricerca catastale;
- GPS live;
- disegni e misure base;
- fino a 15 disegni, misure, POI e punti GPS creati dall'utente;
- apertura e consultazione dei dati gia presenti.
Pro:
@@ -28,6 +28,7 @@ Pro:
- mappe offline;
- strumenti geometrici avanzati;
- traccia GPS.
- disegni, misure, POI e punti GPS senza limite applicativo.
Se l'abbonamento non risulta verificabile offline, l'app conserva una tolleranza locale e non elimina, sequestra o altera i dati dell'utente. Le funzioni Pro possono essere disabilitate, ma i dati esistenti devono restare consultabili.
@@ -43,7 +44,9 @@ Il codice contiene solo identificativi di prodotto e base plan. Non inserire chi
Il client Android usa Google Play Billing 9.1 tramite `Xamarin.Android.Google.BillingClient 9.1.0.2`. Acquisto, ripristino, acknowledgement e interrogazione delle sottoscrizioni sono collegati a `EntitlementService`.
Prima della produzione serve ancora la verifica server-side dei purchase token. La verifica solo sul dispositivo e adatta ai test interni, ma non e sufficiente come protezione definitiva.
Il client supporta la verifica server-side dei purchase token tramite un endpoint HTTPS configurato in fase di build. Il servizio di riferimento e in `tools/LmePlayVerifier` e usa la Google Play Developer API senza incorporare credenziali nell'app.
Per il test chiuso l'endpoint puo restare disattivato. Prima della produzione occorre pubblicare il servizio, autorizzare il relativo service account nella Play Console e compilare l'app con `PlayBillingVerificationUrl` e `PlayBillingVerificationRequired=true`.
## Licenze manuali
+87
View File
@@ -0,0 +1,87 @@
# Informativa sulla privacy di LME Mappe Catastali Pro
Data di efficacia: 30 settembre 2026
## Titolare e contatti
LME Mappe Catastali Pro e pubblicata da **LME Software - Livio Merola**.
Per richieste relative alla privacy, alla rettifica o alla cancellazione dei dati:
- email: `livio.merola@gmail.com`
## Ambito dell'informativa
Questa informativa descrive il trattamento dei dati effettuato dall'app LME Mappe Catastali Pro. L'app non richiede la creazione di un account LME e non invia automaticamente al titolare progetti, disegni, file importati o tracce GPS.
I servizi esterni scelti o utilizzati dall'utente applicano anche le rispettive informative sulla privacy.
## Posizione del dispositivo
Con il consenso dell'utente, l'app accede alla posizione approssimativa e precisa mentre e in uso per:
- mostrare la posizione sulla mappa;
- verificare la posizione rispetto a particelle e geometrie;
- eseguire misure e salvare punti GPS;
- registrare una traccia esclusivamente quando l'utente attiva questa funzione.
La posizione GPS e le tracce sono elaborate e conservate localmente nel dispositivo o nei progetti e backup scelti dall'utente. L'app non richiede la posizione in background.
All'avvio l'app puo richiedere a `ipapi.co` una posizione approssimativa basata sull'indirizzo IP per centrare la mappa senza avviare il GPS. Il servizio riceve necessariamente l'indirizzo IP e dati tecnici della richiesta.
## Ricerche, mappe e dati catastali
Per fornire mappe, geocodifica e informazioni catastali, l'app puo comunicare con:
- servizi cartografici e WFS/WMS dell'Agenzia delle Entrate;
- OpenStreetMap e Nominatim;
- ArcGIS Geocoding;
- Microsoft Azure Maps, solo quando configurato dall'utente;
- servizi nativi di geocodifica del sistema operativo;
- cataloghi catastali pubblici ospitati su GitHub.
In base alla funzione usata, tali servizi possono ricevere testo di ricerca, coordinate, area visualizzata, indirizzo IP e informazioni tecniche necessarie a rispondere. Il titolare non usa questi dati per profilazione o pubblicita.
## Progetti, importazioni, esportazioni e backup
Progetti, geometrie, disegni, punti di interesse, tracce, mappe offline, preferenze e backup sono conservati sul dispositivo o nella destinazione selezionata dall'utente.
L'utente puo importare ed esportare file e condividerli mediante le applicazioni installate. In questi casi il trattamento successivo dipende dalla destinazione scelta dall'utente.
La sincronizzazione WebDAV e facoltativa. Endpoint, nome utente e password sono forniti dall'utente; la password viene conservata nell'archivio sicuro del dispositivo. I backup vengono trasmessi al server WebDAV indicato soltanto su comando dell'utente o secondo le opzioni di backup da lui attivate. L'app accetta esclusivamente endpoint WebDAV HTTPS.
## Licenze e acquisti
Gli acquisti e gli abbonamenti Google Play sono gestiti da Google. L'app riceve da Google Play le informazioni tecniche necessarie a determinare lo stato del prodotto acquistato e ad abilitare le funzioni Pro. Quando il servizio di verifica LME e configurato, l'app gli trasmette il token tecnico dell'acquisto, il pacchetto dell'app e l'identificativo del prodotto per verificare presso Google Play lo stato e la scadenza dell'abbonamento. Il token non contiene i dati della carta di pagamento e non viene usato per profilazione o pubblicita. Le informazioni dell'account e del pagamento sono trattate da Google secondo la sua informativa.
Le licenze manuali contengono nome, cognome, email, identificativo e scadenza in un codice cifrato e firmato. Il codice viene conservato nell'archivio sicuro del dispositivo. Il titolare registra localmente gli stessi dati, insieme a data di emissione, scadenza, note e stato amministrativo, per gestire la licenza e l'assistenza.
## Credenziali e chiavi configurate dall'utente
Le password WebDAV, le chiavi personali per servizi cartografici e il codice di licenza manuale sono conservati tramite l'archivio sicuro del sistema operativo. Non sono incorporati nel pacchetto dell'app e non vengono inviati al titolare.
## Aggiornamenti e dati tecnici
Quando l'utente controlla la disponibilita di aggiornamenti, l'app contatta il repository pubblico LME su `gitea.lmcloud.it`. Come per ogni richiesta Internet, il server puo ricevere indirizzo IP, data e ora e informazioni tecniche della connessione.
L'app non integra sistemi pubblicitari e non utilizza i dati per pubblicita comportamentale. Alla data di efficacia non integra strumenti LME di analisi o telemetria degli utenti.
## Conservazione e cancellazione
I dati dell'app rimangono sul dispositivo finche l'utente non elimina i singoli contenuti, cancella i dati dell'app o disinstalla l'app. La perdita o la scadenza della licenza Pro non elimina i contenuti dell'utente.
I dati inviati a un server WebDAV restano disponibili secondo le impostazioni e le regole del server scelto dall'utente e devono essere eliminati presso tale servizio.
Il registro amministrativo delle licenze manuali e conservato per il tempo necessario alla gestione della licenza, dell'assistenza e degli obblighi contabili o legali applicabili. L'interessato puo richiedere accesso, rettifica o cancellazione scrivendo all'indirizzo indicato sopra; eventuali dati soggetti a obblighi di conservazione saranno eliminati alla scadenza di tali obblighi.
## Sicurezza
L'app usa gli strumenti di sicurezza del sistema operativo per proteggere credenziali, chiavi e licenze memorizzate localmente. Le comunicazioni con i servizi preconfigurati avvengono tramite HTTPS. La sicurezza e la conservazione dei file condivisi o dei servizi configurati dall'utente dipendono anche dalle applicazioni e dai fornitori scelti.
## Minori
L'app e uno strumento cartografico professionale e non e destinata specificamente a minori di 13 anni. Il titolare non raccoglie consapevolmente dati personali di minori tramite l'app.
## Modifiche
Questa informativa puo essere aggiornata quando cambiano le funzioni dell'app, i servizi utilizzati o gli obblighi normativi. La versione aggiornata sara pubblicata allo stesso indirizzo, con la nuova data di efficacia.
-27
View File
@@ -1,27 +0,0 @@
# Informativa privacy - bozza
Questa bozza deve essere completata con i dati del titolare, i contatti, la data di efficacia e un URL pubblico prima della pubblicazione su Google Play.
## Dati trattati
LME Mappe Catastali usa la posizione del dispositivo per mostrare la posizione sulla mappa, verificare l'inclusione nelle geometrie e, solo su comando dell'utente, registrare una traccia GPS. Progetti, disegni, mappe offline, preferenze e backup sono conservati sul dispositivo o nelle destinazioni scelte dall'utente.
Le licenze manuali contengono nome, cognome, email, identificativo e scadenza. Questi dati sono cifrati nel codice licenza e conservati localmente nell'area sicura dell'app. Il gestore usato dal titolare registra gli stessi dati in un database amministrativo locale.
Gli acquisti Google Play sono gestiti da Google. L'app interroga lo stato della sottoscrizione per abilitare le funzioni Pro.
## Servizi di rete
L'app puo contattare servizi cartografici e catastali pubblici, provider di geocoding e mappe, Azure Maps quando configurato dall'utente, e un server WebDAV scelto dall'utente. Indirizzi di ricerca, coordinate, indirizzi IP e dati tecnici possono quindi essere trattati dai rispettivi fornitori secondo le loro informative.
Credenziali WebDAV e chiavi personali configurate nell'app sono conservate nel SecureStorage del dispositivo e non sono incluse nel pacchetto applicativo.
## Conservazione e controllo
L'utente puo eliminare progetti, cache, mappe offline, credenziali e licenza manuale dall'app o cancellando i dati applicativi. La scadenza o perdita della licenza Pro non cancella i contenuti esistenti.
Il registro amministrativo delle licenze manuali deve essere conservato solo per il tempo necessario alla gestione contrattuale, fiscale e di assistenza, con accesso limitato e backup protetti.
## Permessi Android
L'app richiede accesso a Internet e stato della rete, posizione approssimativa e precisa e Google Play Billing. La registrazione della traccia viene avviata dall'utente.
+93 -18
View File
@@ -9,6 +9,7 @@ public sealed class AndroidPlayBillingService : Java.Lang.Object, IPlayBillingSe
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private readonly BillingClient _client;
private TaskCompletionSource<PlayBillingResult>? _purchaseCompletion;
private string? _lastCatalogFailure;
public AndroidPlayBillingService()
{
@@ -24,6 +25,40 @@ public sealed class AndroidPlayBillingService : Java.Lang.Object, IPlayBillingSe
.Build();
}
public async Task<IReadOnlyList<PlaySubscriptionPlan>> GetSubscriptionPlansAsync(
CancellationToken cancellationToken = default)
{
try
{
var details = await GetProductDetailsAsync(cancellationToken);
return details?.GetSubscriptionOfferDetails()?
.Where(item => !string.IsNullOrWhiteSpace(item.BasePlanId)
&& !string.IsNullOrWhiteSpace(item.OfferToken))
.GroupBy(item => item.BasePlanId, StringComparer.Ordinal)
.Select(group => group
.OrderBy(item => string.IsNullOrEmpty(item.OfferId) ? 0 : 1)
.First())
.Select(item =>
{
var phase = item.PricingPhases.PricingPhaseList.LastOrDefault();
return phase is null
? null
: new PlaySubscriptionPlan(item.BasePlanId, phase.FormattedPrice, phase.BillingPeriod);
})
.Where(item => item is not null)
.Cast<PlaySubscriptionPlan>()
.ToArray() ?? [];
}
catch (OperationCanceledException)
{
throw;
}
catch
{
return [];
}
}
public async Task<PlayBillingResult> RestoreAsync(CancellationToken cancellationToken = default)
{
try
@@ -45,7 +80,9 @@ public sealed class AndroidPlayBillingService : Java.Lang.Object, IPlayBillingSe
}
var acknowledgement = await AcknowledgeIfNeededAsync(purchase);
return acknowledgement ?? PlayBillingResult.Active("Abbonamento LME Pro ripristinato da Google Play.");
return acknowledgement ?? PlayBillingResult.Active(
"Abbonamento LME Pro ripristinato da Google Play.",
purchase.PurchaseToken);
}
catch (OperationCanceledException)
{
@@ -61,26 +98,32 @@ public sealed class AndroidPlayBillingService : Java.Lang.Object, IPlayBillingSe
{
try
{
await EnsureConnectedAsync(cancellationToken);
var product = QueryProductDetailsParams.Product.NewBuilder()
.SetProductId(EntitlementService.ProductId)
.SetProductType(BillingClient.ProductType.Subs)
.Build();
var query = QueryProductDetailsParams.NewBuilder()
.SetProductList([product])
.Build();
var detailsResult = await _client.QueryProductDetailsAsync(query);
if (detailsResult.Result.ResponseCode != BillingResponseCode.Ok)
if (string.IsNullOrWhiteSpace(basePlanId))
{
return PlayBillingResult.Failed(ToMessage(detailsResult.Result));
return PlayBillingResult.Failed("Piano LME Pro non valido.");
}
var details = detailsResult.ProductDetailsList.FirstOrDefault();
var details = await GetProductDetailsAsync(cancellationToken);
var offer = details?.GetSubscriptionOfferDetails()?
.FirstOrDefault(item => item.BasePlanId == basePlanId && string.IsNullOrEmpty(item.OfferId));
if (details is null || offer is null)
.Where(item => item.BasePlanId == basePlanId
&& !string.IsNullOrWhiteSpace(item.OfferToken))
.OrderBy(item => string.IsNullOrEmpty(item.OfferId) ? 0 : 1)
.FirstOrDefault();
if (details is null)
{
return PlayBillingResult.Failed("Piano LME Pro non disponibile. Verifica prodotto e base plan nella Play Console.");
return PlayBillingResult.Failed(_lastCatalogFailure
?? "Google Play non ha restituito l'abbonamento lme_pro.");
}
if (offer is null)
{
var availablePlans = string.Join(", ", details.GetSubscriptionOfferDetails()?
.Select(item => item.BasePlanId)
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.Ordinal) ?? []);
return PlayBillingResult.Failed(string.IsNullOrWhiteSpace(availablePlans)
? $"Google Play ha restituito lme_pro senza offerte acquistabili per il piano {basePlanId}."
: $"Piano {basePlanId} non restituito da Google Play. Piani ricevuti: {availablePlans}.");
}
var productParams = BillingFlowParams.ProductDetailsParams.NewBuilder()
@@ -96,7 +139,8 @@ public sealed class AndroidPlayBillingService : Java.Lang.Object, IPlayBillingSe
var completion = new TaskCompletionSource<PlayBillingResult>(TaskCreationOptions.RunContinuationsAsynchronously);
_purchaseCompletion = completion;
using var registration = cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken));
var launchResult = _client.LaunchBillingFlow(activity, flow);
var launchResult = await MainThread.InvokeOnMainThreadAsync(() =>
_client.LaunchBillingFlow(activity, flow));
if (launchResult.ResponseCode != BillingResponseCode.Ok)
{
_purchaseCompletion = null;
@@ -144,7 +188,38 @@ public sealed class AndroidPlayBillingService : Java.Lang.Object, IPlayBillingSe
}
var acknowledgement = await AcknowledgeIfNeededAsync(purchase);
completion.TrySetResult(acknowledgement ?? PlayBillingResult.Active("Abbonamento LME Pro attivato."));
completion.TrySetResult(acknowledgement ?? PlayBillingResult.Active(
"Abbonamento LME Pro attivato.",
purchase.PurchaseToken));
}
private async Task<ProductDetails?> GetProductDetailsAsync(CancellationToken cancellationToken)
{
await EnsureConnectedAsync(cancellationToken);
var product = QueryProductDetailsParams.Product.NewBuilder()
.SetProductId(EntitlementService.ProductId)
.SetProductType(BillingClient.ProductType.Subs)
.Build();
var query = QueryProductDetailsParams.NewBuilder()
.SetProductList([product])
.Build();
var result = await _client.QueryProductDetailsAsync(query);
if (result.Result.ResponseCode != BillingResponseCode.Ok)
{
throw new InvalidOperationException(ToMessage(result.Result));
}
// The .NET async wrapper copies the Java result into the managed
// ProductDetails property; ProductDetailsList has no Java handle here.
var details = result.ProductDetails?.FirstOrDefault();
if (details is not null)
{
_lastCatalogFailure = null;
return details;
}
_lastCatalogFailure = "Google Play ha restituito un catalogo vuoto per lme_pro. Verifica account Play Store, adesione al test e origine dell'installazione.";
return null;
}
private async Task EnsureConnectedAsync(CancellationToken cancellationToken)
+30
View File
@@ -4,6 +4,8 @@ using Android.Content.PM;
using Android.Database;
using Android.OS;
using Android.Provider;
using Android.Views;
using AndroidX.Core.View;
using LMEMappeCatastaliApp.Services;
namespace LMEMappeCatastaliApp;
@@ -24,9 +26,18 @@ public class MainActivity : MauiAppCompatActivity
protected override void OnCreate(Bundle? savedInstanceState)
{
base.OnCreate(savedInstanceState);
ApplySystemBarInsets();
QueueViewedFile(Intent);
}
private void ApplySystemBarInsets()
{
var content = FindViewById<Android.Views.View>(Android.Resource.Id.Content);
if (content is null) return;
ViewCompat.SetOnApplyWindowInsetsListener(content, new SystemBarInsetsListener(content));
ViewCompat.RequestApplyInsets(content);
}
protected override void OnNewIntent(Intent? intent)
{
base.OnNewIntent(intent);
@@ -80,4 +91,23 @@ public class MainActivity : MauiAppCompatActivity
var index = cursor.GetColumnIndex(IOpenableColumns.DisplayName);
return index >= 0 ? cursor.GetString(index) : null;
}
private sealed class SystemBarInsetsListener(Android.Views.View content) : Java.Lang.Object, IOnApplyWindowInsetsListener
{
private readonly int _left = content.PaddingLeft;
private readonly int _top = content.PaddingTop;
private readonly int _right = content.PaddingRight;
public WindowInsetsCompat? OnApplyWindowInsets(Android.Views.View? view, WindowInsetsCompat? insets)
{
if (view is null || insets is null) return insets;
var systemBars = insets.GetInsets(WindowInsetsCompat.Type.SystemBars());
view.SetPadding(
_left,
systemBars?.Top ?? _top,
_right,
systemBars?.Bottom ?? 0);
return insets;
}
}
}
+48
View File
@@ -2,6 +2,53 @@
App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata per consultare mappe catastali e lavorare sul campo con GPS.
Informativa privacy: [PRIVACY_POLICY.md](PRIVACY_POLICY.md).
## Versione Pro 1.0.11 test
- Il flag Fogli catastali mostra il WMS di tutti i fogli agli zoom panoramici e, in dettaglio, il confine ufficiale WFS del foglio corrente anche senza dati caricati.
- Se il foglio corrente e gia presente in un progetto o in un'importazione, l'overlay di navigazione non ne crea un duplicato.
- La toolbar superiore rispetta l'area occupata dalla barra di stato e dalle notifiche Android.
## Versione Pro 1.0.10 test
- Migliora la compatibilita del servizio catastale AdE sui dispositivi Android meno recenti con HTTP/1.1, tentativi automatici e una ricerca GPS piu tollerante.
- I messaggi sulla mappa si chiudono automaticamente dopo un intervallo configurabile, predefinito a 3 secondi, e includono un pulsante di chiusura.
- La barra GPS mantiene sempre disponibili i comandi Avvia/Ferma e Centra, impilati sugli schermi stretti, e puo essere ridotta.
## Versione Pro 1.0.9 test
- Corregge la lettura del catalogo dal wrapper asincrono .NET di Google Play Billing, usando la raccolta managed `ProductDetails` popolata dal binding.
## Versione Pro 1.0.8 test
- Google Play Billing accetta tutte le offerte idonee restituite per i piani `monthly` e `yearly`, preferendo il piano base quando disponibile.
- In caso di catalogo vuoto o prodotto non caricato, il messaggio mostra una diagnostica specifica restituita da Google Play.
## Versione Pro 1.0.7 test
- I messaggi informativi su telefono vengono mostrati sopra i controlli zoom e possono occupare fino a tre righe senza sovrapporsi.
- Durante il rilevamento GPS la mappa si ricentra automaticamente quando la nuova posizione esce dall'area visibile, mantenendo lo zoom corrente.
## Versione Pro 1.0.6 test
- Il pannello Layer usa tutta la larghezza disponibile sui telefoni e le righe si adattano ai testi su piu linee senza sovrapporsi.
- La barra superiore tronca il titolo in modo controllato e il riepilogo GPS non rimane dietro al pannello Layer.
## Versione Pro 1.0.5 test
- Il flusso di acquisto Google Play gestisce correttamente prodotti o offerte non restituiti dal catalogo, senza mostrare eccezioni tecniche.
- L'apertura della schermata di acquisto viene eseguita sul thread principale Android.
## Versione Pro 1.0.4 test
- I prezzi mensile e annuale vengono letti direttamente dal catalogo Google Play.
- Il piano Base consente fino a 15 oggetti disegnati; LME Pro non applica limiti.
## Versione Pro 1.0.3 test
- I pannelli mobili rispettano la barra di navigazione Android e mantengono il pulsante Help completamente raggiungibile tramite scorrimento.
## Versione Pro 1.0.2 test
- Linee e punti KML/KMZ vengono sempre importati, anche quando il documento viene riconosciuto come catastale; il filtro particelle separa soltanto i poligoni di foglio o cornice.
@@ -24,6 +71,7 @@ App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata pe
- L'accesso Pro puo derivare da Google Play oppure da un codice manuale cifrato e firmato, con ritorno automatico a Base alla scadenza. Il gestore e le istruzioni sono in `tools/LmeLicenseManager` e `MANUAL_LICENSES.md`.
- Lingua e sistema di misura sono centralizzati in `Services/AppLocalizationService.cs` e propagati alla mappa JavaScript, incluse distanze, superfici e Leaflet Draw.
- La checklist Play Store, Billing, firma e privacy e in `PLAY_STORE_PRO_CHECKLIST.md`.
- Il piano Base consente fino a 15 disegni, misure, POI e punti GPS; LME Pro non applica questo limite.
- Il progetto usa .NET 10, target Android API 36 e Google Play Billing 9.1.
- La ricerca nel pannello Layer conserva correttamente posizione e selezione del cursore durante il filtraggio; digitando `pio` il testo non viene piu invertito in `oip`.
+4
View File
@@ -44,6 +44,8 @@ public sealed class AppLocalizationService
["settings.pro.monthly"] = "Pro mensile",
["settings.pro.yearly"] = "Pro annuale",
["settings.pro.note"] = "Il prodotto LME Pro si configura nella Play Console con piano mensile e annuale.",
["drawings.base.limit"] = "Piano Base: fino a 15 disegni, misure, POI e punti GPS. LME Pro non ha limiti.",
["drawings.base.limit.reached"] = "Hai raggiunto il limite di 15 oggetti del piano Base. Elimina un disegno o passa a LME Pro per continuare senza limiti.",
["settings.pro.manual.code"] = "Codice licenza manuale",
["settings.pro.manual.help"] = "Incolla il codice cifrato ricevuto. Nome, email e scadenza vengono letti solo dopo la verifica crittografica.",
["settings.pro.manual.activate"] = "Attiva codice",
@@ -92,6 +94,8 @@ public sealed class AppLocalizationService
["settings.pro.monthly"] = "Monthly Pro",
["settings.pro.yearly"] = "Yearly Pro",
["settings.pro.note"] = "Create the LME Pro product in Play Console with monthly and yearly base plans.",
["drawings.base.limit"] = "Base plan: up to 15 drawings, measurements, POIs and GPS points. LME Pro is unlimited.",
["drawings.base.limit.reached"] = "You reached the Base plan limit of 15 objects. Delete a drawing or upgrade to LME Pro to continue without limits.",
["settings.pro.manual.code"] = "Manual license code",
["settings.pro.manual.help"] = "Paste the encrypted code you received. Name, email and expiry are read only after cryptographic verification.",
["settings.pro.manual.activate"] = "Activate code",
+8
View File
@@ -40,6 +40,14 @@ public sealed class CadastralCatalogService
.ToArray();
}
public async Task<MunicipalityOption?> FindMunicipalityAsync(string municipalityCode)
{
var code = (municipalityCode ?? string.Empty).Trim();
var municipalities = await GetAllMunicipalitiesAsync();
return municipalities.FirstOrDefault(item =>
string.Equals(item.Code, code, StringComparison.OrdinalIgnoreCase));
}
public async Task<IReadOnlyList<string>> GetSheetsAsync(
MunicipalityOption municipality,
IProgress<CatalogProgress>? progress = null,
+2 -2
View File
@@ -36,9 +36,9 @@ public sealed class CloudSyncService
private static Uri BuildTarget(string endpoint)
{
if (!Uri.TryCreate(endpoint?.Trim(), UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp))
|| uri.Scheme != Uri.UriSchemeHttps)
{
throw new ArgumentException("Inserisci un indirizzo WebDAV http o https valido.");
throw new ArgumentException("Inserisci un indirizzo WebDAV HTTPS valido.");
}
return uri.AbsolutePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
? uri
+48 -8
View File
@@ -1,10 +1,14 @@
namespace LMEMappeCatastaliApp.Services;
public sealed class EntitlementService(ManualLicenseService manualLicenses, IPlayBillingService playBilling)
public sealed class EntitlementService(
ManualLicenseService manualLicenses,
IPlayBillingService playBilling,
PlayPurchaseVerificationService purchaseVerification)
{
public const string ProductId = "lme_pro";
public const string MonthlyBasePlanId = "monthly";
public const string YearlyBasePlanId = "yearly";
public const int BaseDrawingObjectLimit = 15;
private const string GoogleProActivePreference = "entitlement_google_pro_active";
private const string GoogleExpiresAtPreference = "entitlement_google_pro_expires_at";
@@ -17,7 +21,9 @@ public sealed class EntitlementService(ManualLicenseService manualLicenses, IPla
AppFeatures.CloudSync,
AppFeatures.OfflineMaps,
AppFeatures.AdvancedGeometry,
AppFeatures.GpsTrail
AppFeatures.GpsTrail,
AppFeatures.FiducialPoints,
AppFeatures.MultipleNotes
};
public bool IsGoogleProActive { get; private set; }
@@ -53,6 +59,10 @@ public sealed class EntitlementService(ManualLicenseService manualLicenses, IPla
public bool RequiresPro(string feature) => ProFeatures.Contains(feature);
public bool CanUse(string feature) => !RequiresPro(feature) || HasProAccess;
public Task<IReadOnlyList<PlaySubscriptionPlan>> GetSubscriptionPlansAsync(
CancellationToken cancellationToken = default) =>
playBilling.GetSubscriptionPlansAsync(cancellationToken);
public async Task LoadAsync()
{
IsGoogleProActive = Preferences.Default.Get(GoogleProActivePreference, false);
@@ -85,32 +95,60 @@ public sealed class EntitlementService(ManualLicenseService manualLicenses, IPla
public async Task<PlayBillingResult> RestorePurchasesAsync()
{
var result = await playBilling.RestoreAsync();
ApplyPlayBillingResult(result);
return result;
return await ApplyPlayBillingResultAsync(result);
}
public async Task<PlayBillingResult> PurchaseGoogleProAsync(string basePlanId)
{
var result = await playBilling.PurchaseAsync(basePlanId);
ApplyPlayBillingResult(result);
return result;
return await ApplyPlayBillingResultAsync(result);
}
private void ApplyPlayBillingResult(PlayBillingResult result)
private async Task<PlayBillingResult> ApplyPlayBillingResultAsync(PlayBillingResult result)
{
if (!result.Completed)
{
return;
return result;
}
if (result.HasActiveSubscription)
{
if (!string.IsNullOrWhiteSpace(result.PurchaseToken))
{
var verification = await purchaseVerification.VerifyAsync(result.PurchaseToken);
if (verification.Verified)
{
if (verification.Active)
{
ApplyVerifiedGooglePro(verification.ExpiresAt);
return result with
{
Message = string.IsNullOrWhiteSpace(verification.Message)
? result.Message
: verification.Message
};
}
ApplyGoogleBase();
return PlayBillingResult.Base(string.IsNullOrWhiteSpace(verification.Message)
? "L'abbonamento Google Play non risulta attivo."
: verification.Message);
}
if (purchaseVerification.IsRequired)
{
return PlayBillingResult.Failed(verification.Message);
}
}
ApplyVerifiedGooglePro(null);
}
else
{
ApplyGoogleBase();
}
return result;
}
private void SaveGoogleState()
@@ -149,4 +187,6 @@ public static class AppFeatures
public const string OfflineMaps = "offline-maps";
public const string AdvancedGeometry = "advanced-geometry";
public const string GpsTrail = "gps-trail";
public const string FiducialPoints = "fiducial-points";
public const string MultipleNotes = "multiple-notes";
}
+292
View File
@@ -0,0 +1,292 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace LMEMappeCatastaliApp.Services;
public sealed partial class FiducialPointService
{
private const string ServiceBaseUrl = "https://www1.agenziaentrate.gov.it/servizi/Monografie/";
private const string MappedPointsUrl = "https://cartografia.globogis.it/arcgis/rest/services/fiduciali/Punti_fiduciali/MapServer/2/query";
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) };
public FiducialPointService()
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/1.0 (Android)");
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html"));
}
public async Task<IReadOnlyList<FiducialAreaOption>> GetProvincesAsync(
CancellationToken cancellationToken = default)
{
var html = await GetHtmlAsync("ricerca.php", cancellationToken);
return ParseOptions(html, "provincia");
}
public async Task<IReadOnlyList<FiducialAreaOption>> GetMunicipalitiesAsync(
string provinceCode,
CancellationToken cancellationToken = default)
{
var code = NormalizeCode(provinceCode);
var html = await GetHtmlAsync($"ricerca.php?level=1&prov={Uri.EscapeDataString(code)}", cancellationToken);
return ParseOptions(html, "co");
}
public async Task<IReadOnlyList<FiducialAreaOption>> GetSheetsAsync(
string provinceCode,
string municipalityCode,
CancellationToken cancellationToken = default)
{
var province = NormalizeCode(provinceCode);
var municipality = NormalizeCode(municipalityCode);
var path = $"ricerca.php?level=2&prov={Uri.EscapeDataString(province)}";
var html = await PostFormAsync(path, new Dictionary<string, string> { ["co"] = municipality }, cancellationToken);
return ParseOptions(html, "foglio");
}
public async Task<IReadOnlyList<FiducialPointEntry>> GetFiducialsAsync(
string provinceName,
string municipalityName,
string municipalityCode,
string sheet,
CancellationToken cancellationToken = default)
{
var code = NormalizeCode(municipalityCode);
var normalizedSheet = sheet.Trim();
var query = string.Join("&",
[
$"provincia={Uri.EscapeDataString(provinceName.Trim())}",
$"comune={Uri.EscapeDataString(municipalityName.Trim())}",
$"co={Uri.EscapeDataString(code)}"
]);
var html = await PostFormAsync(
$"risultato.php?{query}",
new Dictionary<string, string> { ["foglio"] = normalizedSheet },
cancellationToken);
return ParseFiducialLinks(html);
}
public async Task<IReadOnlyList<FiducialPointEntry>> GetFiducialsAsync(
string municipalityCode,
string sheet,
CancellationToken cancellationToken = default)
{
var code = NormalizeCode(municipalityCode);
var normalizedSheet = NormalizeSheet(sheet);
var html = await GetHtmlAsync(
$"risultato.php?co={Uri.EscapeDataString(code)}&foglio={Uri.EscapeDataString(normalizedSheet)}",
cancellationToken);
return ParseFiducialLinks(html);
}
private static IReadOnlyList<FiducialPointEntry> ParseFiducialLinks(string html) =>
DownloadLinkRegex().Matches(html)
.Select(match =>
{
var relativeUrl = WebUtility.HtmlDecode(match.Groups["url"].Value);
var fileName = WebUtility.HtmlDecode(match.Groups["name"].Value);
return new FiducialPointEntry(
FormatIdentifier(fileName),
new Uri(new Uri(ServiceBaseUrl), relativeUrl).AbsoluteUri);
})
.DistinctBy(item => item.Identifier, StringComparer.OrdinalIgnoreCase)
.OrderBy(item => item.Identifier, StringComparer.OrdinalIgnoreCase)
.ToArray();
public async Task<IReadOnlyList<MappedFiducialPoint>> GetMappedFiducialsAsync(
string municipalityCode,
string sheet,
CancellationToken cancellationToken = default)
{
var municipality = NormalizeCode(municipalityCode);
if (!int.TryParse(NormalizeSheet(sheet), out var sheetNumber))
{
throw new ArgumentException("Numero di foglio non valido.", nameof(sheet));
}
var query = new Dictionary<string, string>
{
["f"] = "json",
["where"] = $"COMUNENAZI='{municipality}' AND FOGLIO={sheetNumber}",
["outFields"] = "ETICHETTA,PARTICELLA,NORD,EST,ATTENDIBIL,QUOTA,DATAAGGIOR,IDORIGINE,URL_AGENZI,FOGLIO,ALLEGATO,FIDUCIALE",
["returnGeometry"] = "true",
["outSR"] = "3857",
["resultRecordCount"] = "500"
};
var url = $"{MappedPointsUrl}?{string.Join('&', query.Select(item => $"{item.Key}={Uri.EscapeDataString(item.Value)}"))}";
using var response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
if (document.RootElement.TryGetProperty("error", out var error))
{
throw new InvalidOperationException(error.TryGetProperty("message", out var message)
? message.GetString()
: "Servizio cartografico PF non disponibile.");
}
if (!document.RootElement.TryGetProperty("features", out var features))
{
return [];
}
return features.EnumerateArray()
.Select(feature => ParseMappedPoint(feature, municipality))
.Where(point => point is not null)
.Cast<MappedFiducialPoint>()
.DistinctBy(point => point.Identifier, StringComparer.OrdinalIgnoreCase)
.OrderBy(point => point.Identifier, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static MappedFiducialPoint? ParseMappedPoint(JsonElement feature, string municipalityCode)
{
if (!feature.TryGetProperty("geometry", out var geometry)
|| !TryGetDouble(geometry, "x", out var x)
|| !TryGetDouble(geometry, "y", out var y))
{
return null;
}
var attributes = feature.GetProperty("attributes");
var label = GetString(attributes, "ETICHETTA");
var identifier = FormatIdentifier(label);
if (identifier == label)
{
var sheet = GetString(attributes, "FOGLIO").PadLeft(4, '0');
var number = GetString(attributes, "FIDUCIALE").PadLeft(2, '0');
identifier = $"PF{number}/{sheet}/{municipalityCode}";
}
const double earthRadius = 6378137d;
var longitude = x / earthRadius * 180d / Math.PI;
var latitude = (2d * Math.Atan(Math.Exp(y / earthRadius)) - Math.PI / 2d) * 180d / Math.PI;
return new MappedFiducialPoint(
identifier,
latitude,
longitude,
GetNullableDouble(attributes, "NORD"),
GetNullableDouble(attributes, "EST"),
GetString(attributes, "ATTENDIBIL"),
GetNullableDouble(attributes, "QUOTA"),
GetString(attributes, "PARTICELLA"),
GetString(attributes, "DATAAGGIOR"),
GetString(attributes, "IDORIGINE"));
}
private static bool TryGetDouble(JsonElement element, string name, out double value)
{
value = 0;
return element.TryGetProperty(name, out var property)
&& (property.ValueKind == JsonValueKind.Number
? property.TryGetDouble(out value)
: double.TryParse(property.ToString(), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out value));
}
private static double? GetNullableDouble(JsonElement element, string name) =>
TryGetDouble(element, name, out var value) ? value : null;
private static string GetString(JsonElement element, string name) =>
element.TryGetProperty(name, out var property) && property.ValueKind != JsonValueKind.Null
? property.ToString().Trim()
: string.Empty;
private static string NormalizeSheet(string sheet)
{
var digits = new string((sheet ?? string.Empty).Where(char.IsDigit).ToArray());
if (string.IsNullOrWhiteSpace(digits))
{
throw new ArgumentException("Numero di foglio non valido.", nameof(sheet));
}
return int.TryParse(digits, out var number)
? number.ToString(System.Globalization.CultureInfo.InvariantCulture)
: digits;
}
private async Task<string> GetHtmlAsync(string relativeUrl, CancellationToken cancellationToken)
{
using var response = await _httpClient.GetAsync(ServiceBaseUrl + relativeUrl, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken);
}
private async Task<string> PostFormAsync(
string relativeUrl,
IReadOnlyDictionary<string, string> values,
CancellationToken cancellationToken)
{
using var content = new FormUrlEncodedContent(values);
using var response = await _httpClient.PostAsync(ServiceBaseUrl + relativeUrl, content, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken);
}
private static IReadOnlyList<FiducialAreaOption> ParseOptions(string html, string selectId)
{
var select = SelectRegex().Matches(html)
.LastOrDefault(match => string.Equals(match.Groups["id"].Value, selectId, StringComparison.OrdinalIgnoreCase));
if (select is null)
{
return [];
}
return OptionRegex().Matches(select.Groups["body"].Value)
.Select(match => new FiducialAreaOption(
WebUtility.HtmlDecode(match.Groups["value"].Value).Trim(),
WebUtility.HtmlDecode(StripTagsRegex().Replace(match.Groups["label"].Value, string.Empty)).Trim()))
.Where(item => !string.IsNullOrWhiteSpace(item.Code) && !string.IsNullOrWhiteSpace(item.Name))
.DistinctBy(item => item.Code, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string NormalizeCode(string value)
{
var normalized = value.Trim().ToUpperInvariant();
if (normalized.Length is < 2 or > 4 || normalized.Any(character => !char.IsLetterOrDigit(character)))
{
throw new ArgumentException("Codice territoriale non valido.", nameof(value));
}
return normalized;
}
private static string FormatIdentifier(string fileName)
{
var parts = fileName.Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return parts.Length == 3 ? $"PF{parts[2]}/{parts[1]}/{parts[0]}" : fileName;
}
[GeneratedRegex("<select\\b[^>]*(?:id|name)=[\\\"'](?<id>[^\\\"']+)[\\\"'][^>]*>(?<body>[\\s\\S]*?)</select>", RegexOptions.IgnoreCase, matchTimeoutMilliseconds: 2000)]
private static partial Regex SelectRegex();
[GeneratedRegex("<option\\b[^>]*value=[\\\"'](?<value>[^\\\"']*)[\\\"'][^>]*>(?<label>[\\s\\S]*?)</option>", RegexOptions.IgnoreCase, matchTimeoutMilliseconds: 2000)]
private static partial Regex OptionRegex();
[GeneratedRegex("href=[\\\"'](?<url>download\\.php\\?[^\\\"']*namefile=(?<name>[A-Z0-9-]+))[^\\\"']*[\\\"']", RegexOptions.IgnoreCase, matchTimeoutMilliseconds: 2000)]
private static partial Regex DownloadLinkRegex();
[GeneratedRegex("<[^>]+>", RegexOptions.None, 2000)]
private static partial Regex StripTagsRegex();
}
public sealed record FiducialAreaOption(string Code, string Name);
public sealed record FiducialPointEntry(string Identifier, string MonographUrl);
public sealed record MappedFiducialPoint(
string Identifier,
double Latitude,
double Longitude,
double? CadastralNorth,
double? CadastralEast,
string Reliability,
double? Elevation,
string Parcel,
string UpdatedAt,
string OriginId,
string MonographUrl = "");
+14 -2
View File
@@ -2,13 +2,22 @@ namespace LMEMappeCatastaliApp.Services;
public interface IPlayBillingService
{
Task<IReadOnlyList<PlaySubscriptionPlan>> GetSubscriptionPlansAsync(CancellationToken cancellationToken = default);
Task<PlayBillingResult> RestoreAsync(CancellationToken cancellationToken = default);
Task<PlayBillingResult> PurchaseAsync(string basePlanId, CancellationToken cancellationToken = default);
}
public sealed record PlayBillingResult(bool Completed, bool HasActiveSubscription, bool UserCancelled, string Message)
public sealed record PlaySubscriptionPlan(string BasePlanId, string FormattedPrice, string BillingPeriod);
public sealed record PlayBillingResult(
bool Completed,
bool HasActiveSubscription,
bool UserCancelled,
string Message,
string? PurchaseToken = null)
{
public static PlayBillingResult Active(string message) => new(true, true, false, message);
public static PlayBillingResult Active(string message, string? purchaseToken = null) =>
new(true, true, false, message, purchaseToken);
public static PlayBillingResult Base(string message) => new(true, false, false, message);
public static PlayBillingResult Cancelled(string message) => new(false, false, true, message);
public static PlayBillingResult Failed(string message) => new(false, false, false, message);
@@ -16,6 +25,9 @@ public sealed record PlayBillingResult(bool Completed, bool HasActiveSubscriptio
public sealed class UnavailablePlayBillingService : IPlayBillingService
{
public Task<IReadOnlyList<PlaySubscriptionPlan>> GetSubscriptionPlansAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<PlaySubscriptionPlan>>([]);
public Task<PlayBillingResult> RestoreAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(PlayBillingResult.Failed("Google Play Billing è disponibile solo nell'app Android installata da Google Play."));
+77 -8
View File
@@ -1,4 +1,6 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Xml.Linq;
namespace LMEMappeCatastaliApp.Services;
@@ -13,8 +15,9 @@ public sealed class OfficialCadastreService
public OfficialCadastreService()
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2");
_httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/gml+xml");
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/1.0 (Android)");
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/gml+xml"));
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml", 0.9));
}
public async Task<string> GetParcelsNearAsync(
@@ -22,14 +25,30 @@ public sealed class OfficialCadastreService
double longitude,
double radiusMeters,
CancellationToken cancellationToken = default)
=> await GetFeaturesNearAsync(
{
var firstRadius = Math.Clamp(radiusMeters, 12, 30);
var xml = await GetFeaturesNearAsync(
latitude,
longitude,
radiusMeters,
firstRadius,
"CP:CadastralParcel",
100,
cancellationToken);
if (firstRadius >= 30 || ContainsFeatures(xml))
{
return xml;
}
return await GetFeaturesNearAsync(
latitude,
longitude,
30,
"CP:CadastralParcel",
100,
cancellationToken);
}
public async Task<string> GetSheetsNearAsync(
double latitude,
double longitude,
@@ -78,17 +97,67 @@ public sealed class OfficialCadastreService
var url = WfsEndpoint + "?" + string.Join("&", query.Select(item =>
$"{Uri.EscapeDataString(item.Key)}={Uri.EscapeDataString(item.Value)}"));
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseContentRead, cancellationToken);
if (!response.IsSuccessStatusCode)
for (var attempt = 0; attempt < 3; attempt++)
{
throw new HttpRequestException($"Il servizio WFS AdE ha risposto {((int)response.StatusCode)}.");
}
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, url)
{
Version = HttpVersion.Version11,
VersionPolicy = HttpVersionPolicy.RequestVersionOrLower
};
request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true };
using var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseContentRead,
cancellationToken);
if (response.IsSuccessStatusCode)
{
var xml = await response.Content.ReadAsStringAsync(cancellationToken);
ThrowIfExceptionReport(xml);
return xml;
}
if (attempt < 2 && IsTransient(response.StatusCode))
{
await Task.Delay(TimeSpan.FromMilliseconds(500 * (attempt + 1)), cancellationToken);
continue;
}
throw new HttpRequestException($"Il servizio WFS AdE ha risposto {((int)response.StatusCode)}.");
}
catch (HttpRequestException) when (attempt < 2)
{
await Task.Delay(TimeSpan.FromMilliseconds(500 * (attempt + 1)), cancellationToken);
}
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested && attempt < 2)
{
await Task.Delay(TimeSpan.FromMilliseconds(500 * (attempt + 1)), cancellationToken);
}
}
throw new HttpRequestException("Il servizio WFS AdE non ha risposto dopo tre tentativi.");
}
private static bool IsTransient(HttpStatusCode statusCode) =>
statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests
|| (int)statusCode >= 500;
private static bool ContainsFeatures(string xml)
{
try
{
var document = XDocument.Parse(xml);
return document.Descendants().Any(element =>
element.Name.LocalName is "member" or "featureMember");
}
catch (System.Xml.XmlException)
{
return false;
}
}
private static string Format(double value) => value.ToString("R", CultureInfo.InvariantCulture);
private static void ThrowIfExceptionReport(string xml)
@@ -0,0 +1,87 @@
using System.Net.Http.Json;
using System.Reflection;
namespace LMEMappeCatastaliApp.Services;
public sealed class PlayPurchaseVerificationService
{
private const string EndpointMetadataKey = "PlayBillingVerificationUrl";
private const string RequiredMetadataKey = "PlayBillingVerificationRequired";
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(20) };
public bool IsConfigured => VerificationEndpoint is not null;
public bool IsRequired => ReadMetadata(RequiredMetadataKey).Equals("true", StringComparison.OrdinalIgnoreCase);
private static Uri? VerificationEndpoint
{
get
{
var value = ReadMetadata(EndpointMetadataKey);
return Uri.TryCreate(value, UriKind.Absolute, out var endpoint)
&& endpoint.Scheme == Uri.UriSchemeHttps
? endpoint
: null;
}
}
public async Task<PlayPurchaseVerificationResult> VerifyAsync(
string purchaseToken,
CancellationToken cancellationToken = default)
{
var endpoint = VerificationEndpoint;
if (endpoint is null)
{
return PlayPurchaseVerificationResult.NotConfigured();
}
try
{
using var response = await _httpClient.PostAsJsonAsync(endpoint, new
{
packageName = AppInfo.Current.PackageName,
productId = EntitlementService.ProductId,
purchaseToken
}, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return PlayPurchaseVerificationResult.Failed(
$"Verifica acquisto non disponibile ({(int)response.StatusCode}).");
}
var result = await response.Content.ReadFromJsonAsync<VerificationResponse>(cancellationToken);
return result is null
? PlayPurchaseVerificationResult.Failed("Risposta di verifica acquisto non valida.")
: new(true, true, result.Active, result.ExpiresAt, result.Message ?? string.Empty);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return PlayPurchaseVerificationResult.Failed("Il server di verifica acquisti non ha risposto in tempo.");
}
catch (Exception error)
{
return PlayPurchaseVerificationResult.Failed(
$"Verifica acquisto non disponibile: {error.GetBaseException().Message}");
}
}
private static string ReadMetadata(string key) =>
typeof(PlayPurchaseVerificationService).Assembly
.GetCustomAttributes<AssemblyMetadataAttribute>()
.FirstOrDefault(attribute => attribute.Key == key)?.Value?.Trim() ?? string.Empty;
private sealed record VerificationResponse(bool Active, DateTimeOffset? ExpiresAt, string? Message);
}
public sealed record PlayPurchaseVerificationResult(
bool Configured,
bool Verified,
bool Active,
DateTimeOffset? ExpiresAt,
string Message)
{
public static PlayPurchaseVerificationResult NotConfigured() =>
new(false, false, false, null, "Server di verifica acquisti non configurato.");
public static PlayPurchaseVerificationResult Failed(string message) =>
new(true, false, false, null, message);
}
+104
View File
@@ -0,0 +1,104 @@
param(
[string]$Configuration = "Release"
)
$ErrorActionPreference = "Stop"
$projectRoot = Split-Path -Parent $PSScriptRoot
$dotnet = Join-Path $projectRoot "artifacts\tooling\dotnet10\dotnet.exe"
$androidSdk = Join-Path $env:LOCALAPPDATA "Android\Sdk"
$documentsDirectory = Split-Path -Parent (Split-Path -Parent $projectRoot)
$privateDirectory = Join-Path $documentsDirectory "LMEMappeCatastali-Private\GooglePlay"
$keyStore = Join-Path $privateDirectory "lme-mappe-catastali-pro-upload.jks"
$recoveryFile = Join-Path $privateDirectory "UPLOAD-KEY-RECOVERY.txt"
$outputDirectory = Join-Path $projectRoot "artifacts\play-store"
$projectPath = Join-Path $projectRoot "LMEMappeCatastaliApp.csproj"
foreach ($requiredPath in @($dotnet, $androidSdk, $keyStore, $recoveryFile, $projectPath)) {
if (-not (Test-Path -LiteralPath $requiredPath)) {
throw "Componente richiesto non trovato: $requiredPath"
}
}
[xml]$projectXml = Get-Content -Raw -LiteralPath $projectPath
$propertyGroup = $projectXml.Project.PropertyGroup |
Where-Object { $_.ApplicationDisplayVersion -and $_.ApplicationVersion } |
Select-Object -First 1
$displayVersion = [string]$propertyGroup.ApplicationDisplayVersion
$versionCode = [string]$propertyGroup.ApplicationVersion
if ([string]::IsNullOrWhiteSpace($displayVersion) -or [string]::IsNullOrWhiteSpace($versionCode)) {
throw "Versione applicazione non trovata nel progetto."
}
$recovery = Get-Content -LiteralPath $recoveryFile
function Read-RecoveryValue([string]$label) {
$line = $recovery | Where-Object { $_ -like "${label}:*" } | Select-Object -First 1
if ([string]::IsNullOrWhiteSpace($line)) {
throw "Valore $label non trovato nel file di recupero."
}
return ($line -split ":", 2)[1].Trim()
}
$storePassword = Read-RecoveryValue "Store password"
$keyPassword = Read-RecoveryValue "Key password"
$env:LME_ANDROID_UPLOAD_STORE_PASS = $storePassword
$env:LME_ANDROID_UPLOAD_KEY_PASS = $keyPassword
$packages = [System.Collections.Generic.List[System.IO.FileInfo]]::new()
New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null
& $dotnet restore $projectPath `
-p:Configuration=$Configuration `
-p:TargetFrameworks=net10.0-android `
-p:AndroidSdkDirectory=$androidSdk `
-p:UseDefaultPublishRuntimeIdentifier=false
if ($LASTEXITCODE -ne 0) {
throw "Ripristino delle dipendenze Android non riuscito."
}
try {
foreach ($format in @("aab", "apk")) {
& $dotnet publish $projectPath `
-f net10.0-android `
-c $Configuration `
--no-restore `
-p:AndroidSdkDirectory=$androidSdk `
-p:UseDefaultPublishRuntimeIdentifier=false `
-p:AndroidPackageFormat=$format `
-p:AndroidKeyStore=true `
-p:AndroidSigningKeyStore=$keyStore `
-p:AndroidSigningKeyAlias=lme-upload `
-p:AndroidSigningStorePass=env:LME_ANDROID_UPLOAD_STORE_PASS `
-p:AndroidSigningKeyPass=env:LME_ANDROID_UPLOAD_KEY_PASS
if ($LASTEXITCODE -ne 0) {
throw "Pubblicazione Android $format non riuscita."
}
$publishDirectory = Join-Path $projectRoot "bin\$Configuration\net10.0-android\publish"
$package = Get-ChildItem -LiteralPath $publishDirectory -Filter "*-Signed.$format" -File |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($null -eq $package) {
throw "Nessun pacchetto $format firmato prodotto in $publishDirectory"
}
$destination = Join-Path $outputDirectory "LME-Mappe-Catastali-Pro-$displayVersion-$versionCode.$format"
Copy-Item -LiteralPath $package.FullName -Destination $destination -Force
$packages.Add((Get-Item -LiteralPath $destination))
}
}
finally {
Remove-Item Env:LME_ANDROID_UPLOAD_STORE_PASS -ErrorAction SilentlyContinue
Remove-Item Env:LME_ANDROID_UPLOAD_KEY_PASS -ErrorAction SilentlyContinue
$storePassword = $null
$keyPassword = $null
}
$hashLines = $packages | ForEach-Object {
$hash = Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName
"$($hash.Hash.ToLowerInvariant()) $($_.Name)"
}
$hashFile = Join-Path $outputDirectory "LME-Mappe-Catastali-Pro-$displayVersion-$versionCode-SHA256SUMS.txt"
$hashLines | Set-Content -LiteralPath $hashFile
$packages | Select-Object Name, Length, LastWriteTime
Get-Item -LiteralPath $hashFile | Select-Object Name, Length, LastWriteTime
+30
View File
@@ -0,0 +1,30 @@
# Google Play Store assets
## Ready to upload
- `play-store-icon-512.png`: app icon, 512 x 512 px, PNG.
- `feature-graphic-1024x500.png`: feature graphic, 1024 x 500 px, PNG.
- `phone-01-map-1920x1080.png`: real app map view, landscape 16:9.
- `phone-02-data-drawings-1920x1080.png`: real data and drawing tools, landscape 16:9.
- `phone-03-projects-1920x1080.png`: real project archive, landscape 16:9.
The screenshots were captured from the Windows build, whose map and responsive panels match the Android app. The Windows title bar has been removed and the real app view is presented inside a 16:9 Store graphic.
## Optional Android replacements
For a later production listing, the Windows captures can be replaced with Android phone captures:
1. `phone-01-map.png`: main map with cadastral layer and one selected parcel.
2. `phone-02-project-tools.png`: project or data-and-drawings panel with useful content visible.
3. `phone-03-gps.png`: map with GPS position or a short track, without showing a private address.
4. `phone-04-export.png`: import/export tools or project archive.
Use portrait orientation and keep every image between 320 px and 3840 px per side. Do not include personal coordinates, credentials, license codes, notifications, or system dialogs.
## Store text
Short description:
`Mappe catastali, GPS, misure, progetti GIS, importazione e backup WebDAV.`
The complete Italian description is recorded in `PLAY_STORE_PRO_CHECKLIST.md` and can be pasted into Play Console.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+26
View File
@@ -5,6 +5,32 @@ internal static class Program
[STAThread]
private static void Main(string[] args)
{
if (args.Length == 3 && args[0].Equals("--issue-play-review", StringComparison.OrdinalIgnoreCase))
{
if (!DateTimeOffset.TryParse(
args[2],
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal,
out var expiresAt))
{
throw new InvalidOperationException("Scadenza non valida. Usa il formato ISO 8601.");
}
var reviewRepository = new LicenseRepository();
reviewRepository.Initialize();
var license = reviewRepository.Issue(
"Google",
"Play Review",
"livio.merola+play-review@gmail.com",
expiresAt,
"Accesso riutilizzabile per la revisione Google Play");
var outputPath = Path.GetFullPath(args[1]);
File.WriteAllText(outputPath, license.Token, new System.Text.UTF8Encoding(false));
Console.WriteLine($"Licenza di revisione creata in: {outputPath}");
Console.WriteLine($"Scadenza UTC: {license.ExpiresAtUtc:O}");
return;
}
if (args.Length == 2 && args[0].Equals("--export-key", StringComparison.OrdinalIgnoreCase))
{
var password = Environment.GetEnvironmentVariable("LME_LICENSE_BACKUP_PASSWORD");
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Google.Apis.AndroidPublisher.v3" Version="1.76.0.4277" />
</ItemGroup>
</Project>
+60
View File
@@ -0,0 +1,60 @@
using Google.Apis.AndroidPublisher.v3;
using Google.Apis.Auth.OAuth2;
using Google.Apis.Services;
const string PackageName = "it.liviomerola.lmemappecatastali.pro";
const string ProductId = "lme_pro";
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(_ =>
{
var credential = GoogleCredential.GetApplicationDefault()
.CreateScoped(AndroidPublisherService.Scope.Androidpublisher);
return new AndroidPublisherService(new BaseClientService.Initializer
{
HttpClientInitializer = credential,
ApplicationName = "LME Play subscription verifier"
});
});
var app = builder.Build();
app.UseHttpsRedirection();
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.MapPost("/v1/google-play/subscriptions/verify", async (
VerifySubscriptionRequest request,
AndroidPublisherService publisher,
CancellationToken cancellationToken) =>
{
if (request.PackageName != PackageName || request.ProductId != ProductId
|| string.IsNullOrWhiteSpace(request.PurchaseToken))
{
return Results.BadRequest(new VerifySubscriptionResponse(false, null, "Richiesta non valida."));
}
try
{
var purchase = await publisher.Purchases.Subscriptionsv2
.Get(PackageName, request.PurchaseToken)
.ExecuteAsync(cancellationToken);
var active = purchase.SubscriptionState is "SUBSCRIPTION_STATE_ACTIVE"
or "SUBSCRIPTION_STATE_IN_GRACE_PERIOD";
var expiresAt = purchase.LineItems?
.Select(item => item.ExpiryTimeDateTimeOffset)
.Where(value => value.HasValue)
.Max();
return Results.Ok(new VerifySubscriptionResponse(
active,
expiresAt,
active ? "Abbonamento LME Pro verificato." : "L'abbonamento Google Play non risulta attivo."));
}
catch (Google.GoogleApiException error) when ((int)error.HttpStatusCode is 400 or 404)
{
return Results.Ok(new VerifySubscriptionResponse(false, null, "Acquisto Google Play non riconosciuto."));
}
});
app.Run();
internal sealed record VerifySubscriptionRequest(string PackageName, string ProductId, string PurchaseToken);
internal sealed record VerifySubscriptionResponse(bool Active, DateTimeOffset? ExpiresAt, string Message);
+18
View File
@@ -0,0 +1,18 @@
# LME Play verifier
Servizio HTTPS per verificare i token degli abbonamenti `lme_pro` con la Google Play Developer API.
## Configurazione
1. Creare un service account in Google Cloud e concedergli nella Play Console il permesso di visualizzare ordini e abbonamenti dell'app.
2. Conservare il JSON del service account solo sul server.
3. Impostare `GOOGLE_APPLICATION_CREDENTIALS` con il percorso assoluto del JSON.
4. Pubblicare il servizio dietro HTTPS.
5. Compilare l'app con:
```powershell
-p:PlayBillingVerificationUrl=https://example.test/v1/google-play/subscriptions/verify
-p:PlayBillingVerificationRequired=true
```
Non inserire il JSON del service account, token di acquisto o credenziali nel repository o nell'AAB.
+173
View File
@@ -0,0 +1,173 @@
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "store-assets"
GREEN = "#075E54"
GREEN_LIGHT = "#83B6A5"
ORANGE = "#F97316"
INK = "#18342F"
PAPER = "#F7FAF5"
LINE = "#BCD3CB"
BLUE = "#DCEAF0"
def font(size: int, semibold: bool = False) -> ImageFont.FreeTypeFont:
name = "seguisb.ttf" if semibold else "segoeui.ttf"
return ImageFont.truetype(str(Path("C:/Windows/Fonts") / name), size)
def draw_mark(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int]) -> None:
left, top, right, bottom = box
width = right - left
height = bottom - top
map_points = [
(left + 0.04 * width, top + 0.30 * height),
(left + 0.32 * width, top + 0.18 * height),
(left + 0.64 * width, top + 0.30 * height),
(left + 0.94 * width, top + 0.18 * height),
(left + 0.94 * width, top + 0.76 * height),
(left + 0.64 * width, top + 0.88 * height),
(left + 0.32 * width, top + 0.76 * height),
(left + 0.04 * width, top + 0.88 * height),
]
draw.polygon(map_points, fill=PAPER, outline="#E1ECE5", width=max(4, width // 38))
draw.line(
[(left + 0.32 * width, top + 0.18 * height), (left + 0.32 * width, top + 0.76 * height)],
fill=GREEN_LIGHT,
width=max(4, width // 35),
)
draw.line(
[(left + 0.64 * width, top + 0.30 * height), (left + 0.64 * width, top + 0.88 * height)],
fill=GREEN_LIGHT,
width=max(4, width // 35),
)
cx = left + 0.50 * width
cy = top + 0.36 * height
radius = 0.20 * width
pin = [
(cx, cy + radius * 1.72),
(cx - radius * 0.86, cy + radius * 0.20),
(cx - radius, cy - radius * 0.18),
(cx - radius * 0.72, cy - radius * 0.72),
(cx - radius * 0.22, cy - radius),
(cx + radius * 0.22, cy - radius),
(cx + radius * 0.72, cy - radius * 0.72),
(cx + radius, cy - radius * 0.18),
(cx + radius * 0.86, cy + radius * 0.20),
]
draw.polygon(pin, fill=ORANGE)
draw.ellipse(
(cx - radius, cy - radius, cx + radius, cy + radius),
fill=ORANGE,
outline="white",
width=max(5, width // 32),
)
hole = radius * 0.34
draw.ellipse((cx - hole, cy - hole, cx + hole, cy + hole), fill="white")
def create_icon() -> None:
image = Image.new("RGB", (512, 512), GREEN)
draw = ImageDraw.Draw(image)
draw_mark(draw, (82, 78, 430, 434))
image.save(OUTPUT / "play-store-icon-512.png", optimize=True)
def create_feature_graphic() -> None:
image = Image.new("RGB", (1024, 500), "#F3F7F5")
draw = ImageDraw.Draw(image)
draw.rectangle((0, 0, 20, 500), fill=GREEN)
draw.rectangle((20, 0, 32, 500), fill=ORANGE)
draw.text((78, 107), "LME", font=font(34, True), fill=ORANGE)
draw.text((78, 154), "Mappe Catastali Pro", font=font(55, True), fill=INK)
draw.text((80, 234), "Mappe, GPS e progetti GIS", font=font(27), fill=GREEN)
draw.line((80, 290, 455, 290), fill=LINE, width=3)
draw.text((80, 315), "Consulta • misura • organizza", font=font(21), fill="#46635D")
panel = (595, 48, 965, 452)
draw.rounded_rectangle(panel, radius=8, fill="white", outline=LINE, width=3)
draw.polygon([(618, 86), (760, 65), (802, 164), (664, 190)], fill=BLUE, outline=GREEN_LIGHT)
draw.polygon([(806, 75), (940, 110), (923, 236), (784, 205)], fill="#E9EFE7", outline=GREEN_LIGHT)
draw.polygon([(625, 210), (779, 184), (817, 318), (665, 351)], fill="#F4E9D9", outline=GREEN_LIGHT)
draw.polygon([(820, 232), (929, 256), (946, 394), (853, 420), (798, 336)], fill=BLUE, outline=GREEN_LIGHT)
for x1, y1, x2, y2 in [
(630, 128, 932, 327),
(646, 310, 915, 128),
(704, 73, 857, 422),
(607, 267, 948, 181),
]:
draw.line((x1, y1, x2, y2), fill="#A4BDB5", width=3)
draw_mark(draw, (706, 136, 883, 340))
image.save(OUTPUT / "feature-graphic-1024x500.png", optimize=True)
def create_store_screenshot(raw_name: str, output_name: str, title: str, subtitle: str) -> None:
raw = Image.open(OUTPUT / raw_name).convert("RGB")
# Remove the Windows title bar and the desktop-only watermark/status area.
app_view = raw.crop((0, 32, raw.width, min(raw.height, 500)))
max_width, max_height = 1840, 760
scale = min(max_width / app_view.width, max_height / app_view.height)
app_view = app_view.resize(
(round(app_view.width * scale), round(app_view.height * scale)),
Image.Resampling.LANCZOS,
)
image = Image.new("RGB", (1920, 1080), "#F3F7F5")
draw = ImageDraw.Draw(image)
draw.rectangle((0, 0, 24, 1080), fill=GREEN)
draw.rectangle((24, 0, 38, 1080), fill=ORANGE)
draw.text((88, 54), title, font=font(52, True), fill=INK)
draw.text((90, 126), subtitle, font=font(26), fill="#46635D")
x = (1920 - app_view.width) // 2
y = 270
draw.rounded_rectangle(
(x - 8, y - 8, x + app_view.width + 8, y + app_view.height + 8),
radius=8,
fill="white",
outline=LINE,
width=4,
)
image.paste(app_view, (x, y))
image.save(OUTPUT / output_name, optimize=True)
def main() -> None:
OUTPUT.mkdir(exist_ok=True)
create_icon()
create_feature_graphic()
screenshots = [
(
"windows-map-raw.jpg",
"phone-01-map-1920x1080.png",
"Mappe catastali e cartografia",
"Consulta il territorio, cerca localita e lavora sulla mappa.",
),
(
"windows-data-drawings-raw.jpg",
"phone-02-data-drawings-1920x1080.png",
"Importa dati e disegna",
"KML, KMZ e GeoJSON insieme a misure e strumenti GIS.",
),
(
"windows-projects-raw.jpg",
"phone-03-projects-1920x1080.png",
"Organizza i tuoi progetti",
"Salva, riapri e condividi il lavoro sul campo.",
),
]
for raw_name, output_name, title, subtitle in screenshots:
if (OUTPUT / raw_name).exists():
create_store_screenshot(raw_name, output_name, title, subtitle)
if __name__ == "__main__":
main()
+426 -14
View File
@@ -122,10 +122,14 @@ input[type="checkbox"] {
align-items: baseline;
gap: 10px;
min-width: 0;
overflow: hidden;
}
.app-identity strong {
min-width: 0;
overflow: hidden;
font-size: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -263,7 +267,7 @@ input[type="checkbox"] {
left: auto;
bottom: calc(var(--nav-height) + 8px);
z-index: 1080;
display: grid;
display: block;
box-sizing: border-box;
width: min(480px, 50vw);
overflow-y: auto;
@@ -295,10 +299,10 @@ input[type="checkbox"] {
.map-layer-option {
display: flex;
align-items: center;
align-items: flex-start;
gap: 9px;
min-height: 45px;
padding: 6px 4px;
padding: 9px 4px;
border-bottom: 1px solid #e5eaed;
}
@@ -306,11 +310,19 @@ input[type="checkbox"] {
flex: 0 0 auto;
width: 19px;
height: 19px;
margin-top: 2px;
}
.map-layer-option span {
display: grid;
gap: 2px;
min-width: 0;
line-height: 1.25;
}
.map-layer-option strong,
.map-layer-option small {
overflow-wrap: anywhere;
}
.map-layer-option small {
@@ -894,6 +906,86 @@ input[type="checkbox"] {
padding: 0;
}
.object-table-wrap .object-notes-button {
width: auto;
min-width: 70px;
padding: 4px 8px;
white-space: nowrap;
}
.object-table-wrap tr.object-table-selected td {
background: #fce7f3;
box-shadow: inset 0 1px 0 #f0abfc, inset 0 -1px 0 #f0abfc;
}
.managed-notes-command {
display: grid;
gap: 5px;
}
.managed-notes-command small,
.managed-note-pro-message {
color: var(--muted);
font-size: 11px;
}
.managed-notes-list {
display: grid;
gap: 8px;
}
.managed-note {
padding: 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: #f8fafb;
}
.managed-note > header {
padding: 0 0 7px;
border: 0;
}
.managed-note time {
color: var(--muted);
font-size: 11px;
}
.managed-note p,
.lme-popup-notes p {
margin: 0 0 8px;
white-space: normal;
overflow-wrap: anywhere;
}
.managed-note-actions {
display: flex;
gap: 8px;
}
.managed-note-actions button {
min-height: 34px;
}
.managed-note-editor {
display: grid;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.managed-note-editor label {
display: grid;
gap: 6px;
font-weight: 700;
}
.managed-note-editor textarea {
min-height: 90px;
resize: vertical;
}
.object-table-search,
.coordinate-result {
display: grid;
@@ -1137,6 +1229,12 @@ input[type="checkbox"] {
font-size: 11px;
}
.object-table-legend button {
min-height: 28px;
padding: 4px 9px;
font-size: 11px;
}
.object-table-wrap input[type="checkbox"] {
width: 18px;
height: 18px;
@@ -1173,6 +1271,80 @@ input[type="checkbox"] {
text-decoration: none;
}
.lme-geometry-toolbar a.lme-fiducial-download {
font-size: 12px !important;
letter-spacing: 0;
}
.lme-geometry-toolbar a.is-disabled {
color: #8b949e;
background: #eef1f3;
opacity: 0.72;
}
.lme-geometry-toolbar a.lme-selection-mode-button.is-active {
background: #0f766e;
color: #fff;
box-shadow: inset 0 0 0 2px #99f6e4;
}
.lme-geometry-toolbar a.lme-selection-mode-button.is-multiple {
background: #a21caf;
box-shadow: inset 0 0 0 2px #f5d0fe;
}
.lme-geometry-toolbar {
position: relative;
}
.lme-comparison-actions,
.lme-selection-actions {
position: absolute;
right: calc(100% + 6px);
z-index: 1000;
display: grid;
min-width: 154px;
overflow: hidden;
border: 1px solid #b8c1c8;
border-radius: 4px;
background: #fff;
box-shadow: 0 2px 8px rgb(0 0 0 / 22%);
}
.lme-selection-actions {
min-width: 205px;
}
.lme-geometry-toolbar .lme-comparison-actions a,
.lme-geometry-toolbar .lme-selection-actions a {
display: flex !important;
width: auto !important;
height: 36px !important;
padding: 0 10px;
justify-content: flex-start;
border: 0;
border-bottom: 1px solid #d7dde1;
color: #1f2933;
font-size: 13px !important;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.lme-geometry-toolbar .lme-comparison-actions a:last-child,
.lme-geometry-toolbar .lme-selection-actions a:last-child {
border-bottom: 0;
}
.lme-geometry-toolbar .lme-comparison-actions a:hover,
.lme-geometry-toolbar .lme-comparison-actions a:focus-visible,
.lme-geometry-toolbar .lme-selection-actions a:hover,
.lme-geometry-toolbar .lme-selection-actions a:focus-visible,
.lme-geometry-toolbar .lme-selection-actions a.is-current {
background: #e8f4f1;
color: #075e54;
}
.leaflet-help {
display: grid;
gap: 14px;
@@ -1233,6 +1405,41 @@ input[type="checkbox"] {
transition: top 140ms ease;
}
.gps-overlay.is-collapsed {
grid-template-columns: auto;
right: auto;
width: 48px;
max-width: 48px;
padding: 5px;
}
.gps-overlay.is-collapsed .gps-state,
.gps-overlay.is-collapsed .gps-actions {
display: none;
}
.fiducial-list {
display: grid;
gap: 8px;
}
.fiducial-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid #d9e0e3;
}
.fiducial-item strong {
overflow-wrap: anywhere;
}
.fiducial-item .secondary-command {
margin: 0;
}
.gps-overlay.search-open {
top: 140px;
}
@@ -1255,8 +1462,14 @@ input[type="checkbox"] {
color: #a33227;
}
.gps-actions {
display: grid;
gap: 5px;
}
.gps-main,
.gps-center {
.gps-center,
.gps-collapse {
min-height: 38px;
padding: 0 10px;
white-space: nowrap;
@@ -1267,6 +1480,15 @@ input[type="checkbox"] {
color: var(--ink);
}
.gps-collapse {
width: 38px;
padding: 0;
background: #e7ecef;
color: var(--ink);
font-size: 22px;
line-height: 1;
}
.leaflet-top.leaflet-right {
top: 68px;
right: 2px;
@@ -1281,6 +1503,21 @@ input[type="checkbox"] {
height: 38px;
}
.cadastral-sheet-label {
width: auto !important;
height: auto !important;
border: 1px solid #9a5b00;
border-radius: 4px;
padding: 3px 6px;
background: rgba(255, 251, 235, 0.94);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.28);
color: #713f12;
font-size: 11px;
font-weight: 800;
line-height: 1.2;
white-space: nowrap;
}
.lme-drawing-scope-actions a[aria-disabled="true"] {
opacity: 0.45;
pointer-events: none;
@@ -1295,6 +1532,8 @@ input[type="checkbox"] {
z-index: 1100;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
width: min(430px, calc(100% - 48px));
border-left: 1px solid var(--line);
background: var(--panel);
@@ -1347,10 +1586,15 @@ h2 {
}
.panel-content {
flex: 1 1 auto;
min-height: 0;
display: grid;
align-content: start;
gap: 12px;
overflow-y: auto;
padding: 15px 16px 24px;
overscroll-behavior: contain;
padding: 15px 16px max(32px, calc(24px + env(safe-area-inset-bottom)));
scroll-padding-bottom: max(32px, calc(24px + env(safe-area-inset-bottom)));
}
.panel-section {
@@ -1547,11 +1791,134 @@ label {
z-index: 820;
max-width: min(620px, calc(100% - 460px));
border-radius: 8px;
padding: 9px 11px;
display: flex;
align-items: flex-start;
gap: 8px;
padding: 7px 7px 7px 11px;
background: rgba(22, 31, 38, 0.88);
color: #fff;
font-size: 12px;
pointer-events: none;
pointer-events: auto;
}
.map-info[hidden] {
display: none;
}
.map-info-text {
min-width: 0;
padding: 2px 0;
overflow-wrap: anywhere;
}
.map-info-close {
flex: 0 0 28px;
width: 28px;
min-height: 28px;
padding: 0;
border: 0;
background: transparent;
color: #fff;
font-size: 20px;
line-height: 1;
}
.map-compass {
--compass-rotation: 0deg;
--compass-radius: -31px;
position: absolute;
top: 50%;
left: 12px;
z-index: 810;
width: 82px;
height: 82px;
transform: translateY(-50%);
border: 2px solid rgba(255, 255, 255, 0.92);
border-radius: 50%;
background: rgba(22, 31, 38, 0.86);
box-shadow: 0 2px 9px rgba(0, 0, 0, 0.3);
color: #fff;
cursor: grab;
pointer-events: auto;
touch-action: none;
user-select: none;
}
.map-compass.is-dragging {
cursor: grabbing;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.42);
}
.map-compass-rose {
position: absolute;
inset: 0;
}
.map-compass-direction {
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 12px;
transform: translate(-50%, -50%) rotate(var(--direction-angle)) translateY(var(--compass-radius));
color: #f8fafc;
font-size: 9px;
font-weight: 800;
line-height: 1;
text-align: center;
}
.map-compass-direction > span {
display: block;
transform: rotate(var(--label-counter));
}
.map-compass-direction.is-intermediate {
color: #cbd5e1;
font-size: 7px;
}
.map-compass-direction.is-north {
color: #fca5a5;
font-size: 11px;
font-weight: 900;
}
.map-compass-needle {
position: absolute;
bottom: 50%;
left: 50%;
width: 4px;
height: 23px;
transform: translateX(-50%) rotate(var(--compass-rotation));
transform-origin: 50% 100%;
border-radius: 4px 4px 1px 1px;
background: #ef4444;
}
.map-compass-needle::after {
position: absolute;
top: -5px;
left: 50%;
width: 0;
height: 0;
transform: translateX(-50%);
border-right: 5px solid transparent;
border-bottom: 7px solid #ef4444;
border-left: 5px solid transparent;
content: "";
}
.map-compass-center {
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 8px;
transform: translate(-50%, -50%);
border: 2px solid #fff;
border-radius: 50%;
background: #17212b;
}
.wms-preview[hidden] {
@@ -1731,7 +2098,16 @@ footer {
right: 8px;
left: auto;
bottom: calc(var(--nav-height) + 8px);
width: 50vw;
width: calc(100vw - 16px);
}
.map-stage.layers-open .gps-overlay {
visibility: hidden;
pointer-events: none;
}
.app-status-line {
display: none;
}
.map-navigation-menu {
@@ -1788,13 +2164,19 @@ footer {
top: 136px;
}
.gps-overlay.is-collapsed {
right: auto;
width: 48px;
max-width: 48px;
}
.leaflet-top.leaflet-right {
top: 122px;
top: 174px;
right: 0;
}
.map-stage.search-active .leaflet-top.leaflet-right {
top: 190px;
top: 242px;
}
.gps-main,
@@ -1805,12 +2187,26 @@ footer {
.map-info {
right: 8px;
bottom: 72px;
bottom: calc(var(--nav-height) + 116px);
left: 8px;
max-width: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.35;
white-space: normal;
}
.map-compass {
--compass-radius: -27px;
left: 8px;
width: 72px;
height: 72px;
}
.map-info-text {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.bottom-tabs button {
@@ -1832,8 +2228,24 @@ footer {
min-width: 0;
}
.gps-overlay {
grid-template-columns: minmax(0, 1fr) auto auto;
}
.gps-overlay.is-collapsed {
grid-template-columns: auto;
right: auto;
width: 48px;
max-width: 48px;
}
.fiducial-item {
grid-template-columns: minmax(0, 1fr);
}
.gps-main,
.gps-center {
display: none;
min-width: 86px;
}
.bottom-tabs button {
+1
View File
@@ -27,6 +27,7 @@
<script src="_framework/blazor.webview.js" autostart="false"></script>
<script src="lib/leaflet/leaflet.js"></script>
<script src="lib/leaflet-rotate/leaflet-rotate.umd.min.js"></script>
<script src="lib/leaflet-draw/leaflet.draw.js"></script>
<script src="lib/googlemutant/Leaflet.GoogleMutant.js"></script>
<script src="lib/jszip/jszip.min.js"></script>
+1509 -125
View File
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Grzegorz Tomicki
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2
View File
@@ -0,0 +1,2 @@
/*! @tomickigrzegorz/leaflet-rotate v0.3.0 | MIT */
!function(t,i){"object"==typeof exports&&"undefined"!=typeof module?i(require("leaflet")):"function"==typeof define&&define.amd?define(["leaflet"],i):i((t="undefined"!=typeof globalThis?globalThis:t||self).L)}(this,function(t){"use strict";t.Point.prototype.rotate=function(i){var e=Math.cos(i),o=Math.sin(i);return new t.Point(this.x*e-this.y*o,this.x*o+this.y*e)},t.Point.prototype.rotateFrom=function(t,i){return i?this.subtract(i).rotate(t).add(i):this.rotate(t)};const i=Math.PI/180,e=180/Math.PI;function o(t){return(t%360+360)%360}function a(t){return o(t+180)-180}function n(t){var i=2*Math.PI;return((t+Math.PI)%i+i)%i-Math.PI}const s=1e3/60;function r(t,i){return 1-Math.pow(1-t,i/s)}function h(){return"undefined"!=typeof performance&&performance.now?performance.now():Date.now()}var _=t.Map.prototype;t.Map.mergeOptions({rotate:!1,bearing:0,touchRotate:!1,shiftKeyRotate:!1,dragRotate:!0,rotateControl:!1,rotateClockwise:!0});var p=_.initialize;_.initialize=function(t,i){i&&i.rotate&&(this._rotate=!0,this._bearing=0,this._bearingRad=0),p.call(this,t,i),this._rotate&&this.setBearing(i.bearing||0)};var l=_._initPanes;_._initPanes=function(){if(l.call(this),this._rotate){var i=this._mapPane;this._rotatePane=t.DomUtil.create("div","leaflet-rotate-pane",i),this._norotatePane=t.DomUtil.create("div","leaflet-norotate-pane",i),this._rotatePane.appendChild(this._panes.tilePane),this._rotatePane.appendChild(this._panes.overlayPane),this._norotatePane.appendChild(this._panes.shadowPane),this._norotatePane.appendChild(this._panes.markerPane),this._norotatePane.appendChild(this._panes.tooltipPane),this._norotatePane.appendChild(this._panes.popupPane),t.DomUtil.addClass(this._rotatePane,"leaflet-proxy leaflet-zoom-animated")}},_.setBearing=function(e){if(!this._rotate||!isFinite(e))return this;var a=this._bearing||0,n=o(e);if(n===a)return this;if(this._commitRotatePan(),this._bearing=n,this._bearingRad=n*i,this._updateRotatePaneTransform(),0===a!=(0===n))for(var s in this._layers){var r=this._layers[s];r instanceof t.Renderer&&r._update()}return this.fire("rotate")},_.getBearing=function(){return this._bearing||0},_._updateRotatePaneTransform=function(){if(this._rotatePane){if(!this._bearing)return this._rotatePane.style[t.DomUtil.TRANSFORM]="",void(this._rotatePane.style[t.DomUtil.TRANSFORM+"Origin"]="");var i=this.getSize().divideBy(2);this._rotatePane.style[t.DomUtil.TRANSFORM+"Origin"]=i.x+"px "+i.y+"px",this._rotatePane.style[t.DomUtil.TRANSFORM]="rotate("+this._bearingRad+"rad)"}},_._commitRotatePan=function(){if(this._rotate&&!this._committingRotatePan){var t=this._getMapPanePos();!t||0===t.x&&0===t.y||(this._committingRotatePan=!0,this._resetView(this.getCenter(),this.getZoom(),!0),this._committingRotatePan=!1)}};var m=_.containerPointToLayerPoint;_.containerPointToLayerPoint=function(i){if(!this._rotate||!this._bearing)return m.call(this,i);var e=t.point(i),o=this._getMapPanePos(),a=this.getSize().divideBy(2);return e.subtract(o).subtract(a).rotate(-this._bearingRad).add(a)};var d=_.layerPointToContainerPoint;_.layerPointToContainerPoint=function(i){if(!this._rotate||!this._bearing)return d.call(this,i);var e=t.point(i),o=this._getMapPanePos(),a=this.getSize().divideBy(2);return e.subtract(a).rotate(this._bearingRad).add(a).add(o)},_.rotatedPointToMapPanePoint=function(i){if(!this._bearing)return t.point(i);var e=this.getSize().divideBy(2);return t.point(i).rotateFrom(this._bearingRad,e)},_.mapPanePointToRotatedPoint=function(i){if(!this._bearing)return t.point(i);var e=this.getSize().divideBy(2);return t.point(i).rotateFrom(-this._bearingRad,e)};var c=_._getCenterOffset;_._getCenterOffset=function(t){return this._rotate&&this._bearing?this.project(t).subtract(this.project(this.getCenter())).rotate(this._bearingRad):c.call(this,t)};var u=_.getBounds;_.getBounds=function(){if(!this._rotate||!this._bearing)return u.call(this);var i=this.getSize(),e=t.latLngBounds();return e.extend(this.containerPointToLatLng(t.point(0,0))),e.extend(this.containerPointToLatLng(t.point(i.x,0))),e.extend(this.containerPointToLatLng(t.point(i.x,i.y))),e.extend(this.containerPointToLatLng(t.point(0,i.y))),e},_.mapBoundsToContainerBounds=function(i){return t.bounds([this.latLngToContainerPoint(i.getNorthWest()),this.latLngToContainerPoint(i.getNorthEast()),this.latLngToContainerPoint(i.getSouthEast()),this.latLngToContainerPoint(i.getSouthWest())])};var g=_.getBoundsZoom;_.getBoundsZoom=function(i,e,o){if(!this._rotate||!this._bearing)return g.call(this,i,e,o);i=t.latLngBounds(i),o=t.point(o||[0,0]);var a=this.getZoom()||0,n=this.getMinZoom(),s=this.getMaxZoom(),r=this.getSize().subtract(o);if(r.x<=0||r.y<=0)return a;var h=this.mapBoundsToContainerBounds(i).getSize(),_=this.options.zoomSnap,p=r.x/h.x,l=r.y/h.y,m=e?Math.max(p,l):Math.min(p,l);return a=this.getScaleZoom(m,a),_&&(a=Math.round(a/_)*_),Math.max(n,Math.min(s,a))},_._animateZoomNoDelay=function(t,i,e){this._mapPane&&(e&&(this._animatingZoom=!0,this._animateToCenter=t,this._animLine truncated