Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f27e47d83a | ||
|
|
bcc9e01179 | ||
|
|
8afb79f893 | ||
|
|
01da7efc2f | ||
|
|
6a675ce635 | ||
|
|
ad64d6def0 | ||
|
|
a7c8127091 | ||
|
|
480134538a | ||
|
|
f41d263a2e | ||
|
|
91fd60766d |
No files matched your search
@@ -18,7 +18,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">
|
||||
@@ -235,6 +235,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 +498,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>
|
||||
@@ -637,6 +641,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">
|
||||
@@ -719,6 +729,8 @@
|
||||
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 string Status { get; set; } = "online";
|
||||
private string SelectedBaseLayer { get; set; } = "satellite";
|
||||
@@ -778,6 +790,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; }
|
||||
@@ -886,6 +904,7 @@
|
||||
await ApplyEntitlementsToMapAsync();
|
||||
await ApplyFlagPreferencesToMapAsync();
|
||||
await ApplyAdvancedOptions();
|
||||
await RefreshBillingStateAsync();
|
||||
await CenterOnInitialLocationAsync();
|
||||
await MigrateLegacyProjectAsync();
|
||||
var offlineCount = await JS.InvokeAsync<int>("lmeMap.getOfflineTileStats");
|
||||
@@ -2112,9 +2131,26 @@
|
||||
|
||||
private Task ApplyEntitlementsToMapAsync() => JS.InvokeVoidAsync("lmeMap.setEntitlements", new
|
||||
{
|
||||
advancedGeometry = CanUse(AppFeatures.AdvancedGeometry)
|
||||
advancedGeometry = CanUse(AppFeatures.AdvancedGeometry),
|
||||
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;
|
||||
@@ -2431,6 +2467,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;
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
<ApplicationId>it.liviomerola.lmemappecatastali.pro</ApplicationId>
|
||||
|
||||
<!-- Versions -->
|
||||
<ApplicationDisplayVersion>1.0.2</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>102</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>1.0.8</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>108</ApplicationVersion>
|
||||
<Version>$(ApplicationDisplayVersion)</Version>
|
||||
|
||||
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
|
||||
@@ -43,6 +43,22 @@
|
||||
<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>
|
||||
<AndroidPackageFormat>aab</AndroidPackageFormat>
|
||||
|
||||
@@ -29,6 +29,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
|
||||
|
||||
@@ -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.8` / version code `108`.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,39 @@ 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));
|
||||
}
|
||||
|
||||
var details = result.ProductDetailsList?.FirstOrDefault();
|
||||
if (details is not null)
|
||||
{
|
||||
_lastCatalogFailure = null;
|
||||
return details;
|
||||
}
|
||||
|
||||
var unfetched = result.UnfetchedProductList?.FirstOrDefault();
|
||||
_lastCatalogFailure = unfetched is null
|
||||
? "Google Play ha restituito un catalogo vuoto per lme_pro. Verifica account Play Store, adesione al test e origine dell'installazione."
|
||||
: $"Google Play non ha caricato lme_pro (codice catalogo {unfetched.StatusCodeValue}).";
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task EnsureConnectedAsync(CancellationToken cancellationToken)
|
||||
|
||||
@@ -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,19 @@ 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, _top, _right, systemBars?.Bottom ?? 0);
|
||||
return insets;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,37 @@
|
||||
|
||||
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.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 +55,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`.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
@@ -53,6 +57,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 +93,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()
|
||||
|
||||
@@ -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."));
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
+40
-8
@@ -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 {
|
||||
@@ -1295,6 +1307,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 +1361,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 {
|
||||
@@ -1731,7 +1750,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 {
|
||||
@@ -1805,12 +1833,16 @@ footer {
|
||||
|
||||
.map-info {
|
||||
right: 8px;
|
||||
bottom: 72px;
|
||||
bottom: calc(var(--nav-height) + 116px);
|
||||
left: 8px;
|
||||
display: -webkit-box;
|
||||
max-width: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.bottom-tabs button {
|
||||
|
||||
+38
-1
@@ -96,7 +96,9 @@ const lmeState = {
|
||||
culture: "it-IT",
|
||||
measurementSystem: "metric",
|
||||
entitlements: {
|
||||
advancedGeometry: false
|
||||
advancedGeometry: false,
|
||||
maxDrawingObjects: 15,
|
||||
drawingLimitMessage: "Hai raggiunto il limite di 15 oggetti del piano Base."
|
||||
},
|
||||
labels: {
|
||||
measureArea: "Misura area",
|
||||
@@ -213,6 +215,20 @@ function requireProFeature(feature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function drawingObjectCount() {
|
||||
return lmeState.drawings ? managedRecordsForCategory("drawings").length : 0;
|
||||
}
|
||||
|
||||
function canAddDrawingObjects(amount = 1) {
|
||||
const configuredMaximum = lmeState.entitlements?.maxDrawingObjects;
|
||||
if (configuredMaximum === null || configuredMaximum === undefined || configuredMaximum === "") return true;
|
||||
const maximum = Number(configuredMaximum);
|
||||
if (!Number.isFinite(maximum) || maximum < 0) return true;
|
||||
if (drawingObjectCount() + Math.max(0, Number(amount) || 0) <= maximum) return true;
|
||||
setInfo(lmeState.entitlements?.drawingLimitMessage || `Limite di ${maximum} oggetti raggiunto.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
function init(dotNetReference) {
|
||||
if (dotNetReference) {
|
||||
lmeState.dotNetReference = dotNetReference;
|
||||
@@ -1775,6 +1791,10 @@ function moveManagedRecordsToGroup(records, targetCategory, targetSource, target
|
||||
setInfo("Sblocca il gruppo prima di spostarlo.");
|
||||
return;
|
||||
}
|
||||
const additionalDrawings = targetCategory === "drawings"
|
||||
? movable.filter(record => record.categoryKey !== "drawings").length
|
||||
: 0;
|
||||
if (!canAddDrawingObjects(additionalDrawings)) return;
|
||||
rememberMapChange();
|
||||
const features = movable.map(record => {
|
||||
const feature = record.layer.toGeoJSON();
|
||||
@@ -2684,6 +2704,7 @@ function storedFeatureStyle(feature) {
|
||||
function duplicateManagedRecords(records) {
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||||
if (records.some(record => record.layer?.feature?.properties?._lmeLocked)) { setInfo("Sblocca gli oggetti da duplicare."); return; }
|
||||
if (!canAddDrawingObjects(records.length)) return;
|
||||
rememberMapChange();
|
||||
records.forEach((record, index) => {
|
||||
const feature = record.layer.toGeoJSON();
|
||||
@@ -3036,6 +3057,11 @@ function restoreLastTrashItem() {
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; }
|
||||
const entry = lmeState.trash.pop();
|
||||
if (!entry) { setInfo("Il cestino e' vuoto."); return false; }
|
||||
const drawingsToRestore = entry.items.filter(item => !["imported", "importedSheets"].includes(item.categoryKey)).length;
|
||||
if (!canAddDrawingObjects(drawingsToRestore)) {
|
||||
lmeState.trash.push(entry);
|
||||
return false;
|
||||
}
|
||||
rememberMapChange();
|
||||
entry.items.forEach(item => {
|
||||
if (item.categoryKey === "imported") addImportedFeatures({ type: "FeatureCollection", features: [item.feature] });
|
||||
@@ -3825,6 +3851,7 @@ function clearImported() {
|
||||
}
|
||||
|
||||
function addProjectMarker(name) {
|
||||
if (!canAddDrawingObjects()) return false;
|
||||
rememberMapChange();
|
||||
const center = lmeState.map.getCenter();
|
||||
const label = (name || "Nuovo POI").trim();
|
||||
@@ -3840,6 +3867,7 @@ function addProjectMarker(name) {
|
||||
refreshLayerManager();
|
||||
selectDrawingLayer(marker);
|
||||
finishMapChange();
|
||||
return true;
|
||||
}
|
||||
|
||||
function configureDrawLabels() {
|
||||
@@ -3868,6 +3896,7 @@ function bindDrawingEvents() {
|
||||
setInfo("Il progetto e' in sola lettura.");
|
||||
return;
|
||||
}
|
||||
if (!canAddDrawingObjects()) return;
|
||||
snapDrawingLayer(event.layer);
|
||||
annotateDrawingLayer(event.layer, event.layerType);
|
||||
lmeState.drawings.addLayer(event.layer);
|
||||
@@ -4104,6 +4133,7 @@ function startMeasurementDrawing(mode) {
|
||||
if (!lmeState.map) {
|
||||
return;
|
||||
}
|
||||
if (!canAddDrawingObjects()) return;
|
||||
bindDrawingEvents();
|
||||
hideDrawingScopeMenu();
|
||||
if (lmeState.activeDrawHandler) {
|
||||
@@ -5607,6 +5637,12 @@ function onGpsPosition(position) {
|
||||
updateGpsPositionLayers(latlng, lmeState.lastAccuracy);
|
||||
if (firstLiveFix) {
|
||||
lmeState.map.setView(latlng, 19);
|
||||
} else if (!lmeState.map.getBounds().contains(latlng)) {
|
||||
lmeState.map.panTo(latlng, {
|
||||
animate: true,
|
||||
duration: 0.5,
|
||||
easeLinearity: 0.25
|
||||
});
|
||||
}
|
||||
|
||||
if (lmeState.gpsTrailEnabled) {
|
||||
@@ -5736,6 +5772,7 @@ function bearingToCardinal(value) {
|
||||
|
||||
function saveGpsMeasurementPoint() {
|
||||
if (!lmeState.lastPosition) { setInfo("Attendi una posizione GPS valida."); return; }
|
||||
if (!canAddDrawingObjects()) return;
|
||||
rememberMapChange();
|
||||
const now = new Date();
|
||||
addDrawingFeature({ type: "Feature", properties: {
|
||||
|
||||
Reference in new issue
Block a user