@page "/" @inject IJSRuntime JS @inject PreciseLocationService LocationService @inject CadastralCatalogService CatalogService @inject OfficialCadastreService OfficialCadastreService @inject FiducialPointService FiducialPointService @inject LocationSearchService LocationSearchService @inject NetworkLocationService NetworkLocationService @inject AzureMapsService AzureMapsService @inject ProjectStorageService ProjectStorageService @inject MapExportService MapExportService @inject CloudSyncService CloudSyncService @inject AppUpdateService AppUpdateService @inject IncomingFileService IncomingFiles @inject AppLocalizationService L10n @inject EntitlementService Entitlements @implements IAsyncDisposable LME Mappe Catastali Pro
LME Catasto @Status @if (HasUnsavedChanges) { }
@if (NavigationMenuOpen) { } @if (LayerPanelOpen) {
Layer catastali
@foreach (var layer in CadastralLayers) { }
Ordine livelli e oggetti Trascina dall'alto verso il basso
}
GPS non attivo Nessuna particella selezionata
@if (ActiveTab is not "map" and not "search") { }
Pronto
@code { private const string FlagPreferencePrefix = "lme_flag_"; private string ActiveTab { get; set; } = "map"; private string SearchText { get; set; } = string.Empty; private string NewProjectName { get; set; } = string.Empty; private string ExportFileName { get; set; } = "Mappa"; private string ExportFormat { get; set; } = "geojson"; private string PoiName { get; set; } = string.Empty; private string SelectedProjectId { get; set; } = string.Empty; private string ProjectArchiveMessage { get; set; } = "Nessun progetto salvato."; private string ProjectFolder { get; set; } = string.Empty; private string SelectedVersionId { get; set; } = string.Empty; private string LanguagePreference { get; set; } = "auto"; 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 Status { get; set; } = "online"; private string SelectedBaseLayer { get; set; } = "satellite"; private string GoogleMapsApiKey { get; set; } = string.Empty; private string AzureMapsSubscriptionKey { get; set; } = string.Empty; private string SelectedRegionFile { get; set; } = string.Empty; private string SelectedMunicipalityCode { get; set; } = string.Empty; private string SelectedSheet { get; set; } = string.Empty; private string SelectedParcelNumber { get; set; } = string.Empty; 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 ShowSelectedParcels { get; set; } = true; private bool ShowImportedData { get; set; } = true; private bool DrawToolsVisible { get; set; } private bool TrailEnabled { get; set; } private bool LayerPanelOpen { get; set; } private bool NavigationMenuOpen { get; set; } private bool HasUnsavedChanges { get; set; } private DotNetObjectReference? MapCallbackReference { get; set; } private bool MapReady { get; set; } private bool IncludeCadastralParcels { get; set; } = true; private bool IncludeKmlSheetLayer { get; set; } private bool ShowImportedSheetLayer { get; set; } private bool ShowImportedMeasurements { get; set; } private bool ShowDrawingMeasurements { get; set; } = true; private bool ProjectBusy { get; set; } private bool ExportBusy { get; set; } private bool ExportImported { get; set; } = true; private bool ExportImportedSheets { get; set; } = true; private bool ExportCadastral { get; set; } = true; private bool ExportDrawings { get; set; } = true; private bool ExportPoi { get; set; } = true; private bool ExportTrail { get; set; } = true; private bool ExportSelectedOnly { get; set; } private bool AutoSaveEnabled { get; set; } = true; private bool AutoSaveOpenProject { get; set; } private string AutomaticBackupFrequency { get; set; } = "daily"; private bool SnapEnabled { get; set; } = true; private bool AutoFitSelectedObject { get; set; } private int SnapDistancePixels { get; set; } = 14; private bool GpsProximityEnabled { get; set; } = true; private bool GpsBoundaryAlertEnabled { get; set; } private int OfflineExtraZoomLevels { get; set; } = 2; private bool OfflineBusy { get; set; } private string OfflineStatus { get; set; } = "Nessuna area verificata."; private string WebDavEndpoint { get; set; } = string.Empty; private string WebDavUserName { get; set; } = string.Empty; private string WebDavPassword { get; set; } = string.Empty; private bool SyncBusy { get; set; } private string SyncStatus { get; set; } = "Sincronizzazione non configurata."; private bool UpdateBusy { get; set; } private string UpdateStatus { get; set; } = $"Versione installata: {AppUpdateService.CurrentVersion}"; 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; } private long LastGpsLookupAt; private int GpsLookupInProgress; private IReadOnlyList Regions { get; set; } = []; private IReadOnlyList Municipalities { get; set; } = []; private IReadOnlyList Sheets { get; set; } = []; private IReadOnlyList Parcels { get; set; } = []; private IReadOnlyList FiducialProvinces { get; set; } = []; private IReadOnlyList FiducialMunicipalities { get; set; } = []; private IReadOnlyList FiducialSheets { get; set; } = []; private IReadOnlyList FiducialPoints { get; set; } = []; private IReadOnlyList Projects { get; set; } = []; private IReadOnlyList ProjectVersions { get; set; } = []; private MapProjectSummary? SelectedProject => Projects.FirstOrDefault(item => item.Id == SelectedProjectId); private MunicipalityOption? SelectedMunicipality => Municipalities.FirstOrDefault(item => item.Code == SelectedMunicipalityCode); private ParcelOption? SelectedParcel => Parcels.FirstOrDefault(item => item.Number == SelectedParcelNumber); private string PanelTitle => ActiveTab switch { "cadastre" => "Ricerca catastale", "fiducials" => "Punti fiduciali", "data" => "Dati e disegni", "projects" => "Archivio progetti", "save" => "Salvataggio ed esportazione", "tools" => "Strumenti avanzati", "settings" => L("panel.settings.title"), _ => string.Empty }; 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", "tools" => "Oggetti, geometrie, coordinate e offline", "settings" => L("panel.settings.eyebrow"), _ => string.Empty }; private readonly List CadastralLayers = [ new("CP.CadastralParcel", "Confini particelle", true), new("codice_plla", "Numeri particelle", true, "Visibili circa da zoom 17"), new("CP.CadastralZoning", "Fogli catastali", true), new("fabbricati", "Fabbricati", true), new("strade", "Strade catastali", false), new("acque", "Acque", false) ]; protected override async Task OnInitializedAsync() { IncomingFiles.FileAvailable += OnIncomingFileAvailable; L10n.Load(); L10n.ApplyCulture(); await Entitlements.LoadAsync(); LanguagePreference = L10n.LanguagePreference; MeasurementPreference = L10n.MeasurementPreference; LoadFlagPreferences(); try { GoogleMapsApiKey = await SecureStorage.Default.GetAsync("google_maps_api_key") ?? string.Empty; } catch { GoogleMapsApiKey = string.Empty; } try { AzureMapsSubscriptionKey = await SecureStorage.Default.GetAsync("azure_maps_subscription_key") ?? string.Empty; } catch { AzureMapsSubscriptionKey = string.Empty; } WebDavEndpoint = Preferences.Default.Get("webdav_endpoint", string.Empty); WebDavUserName = Preferences.Default.Get("webdav_username", string.Empty); try { WebDavPassword = await SecureStorage.Default.GetAsync("webdav_password") ?? string.Empty; } catch { WebDavPassword = string.Empty; } try { Regions = await CatalogService.GetRegionsAsync(); } catch (Exception error) { CatalogMessage = $"Catalogo Comuni non disponibile: {error.Message}"; } await RefreshProjectsAsync(); } protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { MapCallbackReference = DotNetObjectReference.Create(this); await JS.InvokeVoidAsync("lmeMap.init", MapCallbackReference); MapReady = true; await ApplyLocalizationToMapAsync(); await ApplyEntitlementsToMapAsync(); await ApplyFlagPreferencesToMapAsync(); await ApplyAdvancedOptions(); await RefreshBillingStateAsync(); await CenterOnInitialLocationAsync(); await MigrateLegacyProjectAsync(); var offlineCount = await JS.InvokeAsync("lmeMap.getOfflineTileStats"); OfflineStatus = offlineCount == 0 ? "Nessuna tessera salvata." : $"{offlineCount} tessere disponibili offline."; await ProcessIncomingFilesAsync(); await InvokeAsync(StateHasChanged); } } private async Task ConfigureProjectMapProviderAsync(string snapshot) { string? baseLayer = null; try { using var document = System.Text.Json.JsonDocument.Parse(snapshot); if (document.RootElement.TryGetProperty("displayOptions", out var displayOptions) && displayOptions.TryGetProperty("baseLayer", out var baseLayerProperty)) { baseLayer = baseLayerProperty.GetString(); } } catch { return; } try { if (baseLayer?.StartsWith("google-", StringComparison.Ordinal) == true && !string.IsNullOrWhiteSpace(GoogleMapsApiKey)) { await JS.InvokeVoidAsync("lmeMap.configureGoogleMaps", GoogleMapsApiKey.Trim()); } if (baseLayer?.StartsWith("azure-", StringComparison.Ordinal) == true && !string.IsNullOrWhiteSpace(AzureMapsSubscriptionKey)) { await JS.InvokeVoidAsync("lmeMap.configureAzureMaps", AzureMapsSubscriptionKey.Trim()); } } catch { // Loading continues with the default map if the saved provider is unavailable. } } private async Task CenterOnInitialLocationAsync() { NetworkLocationSnapshot? networkLocation = null; try { using var networkTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(8)); networkLocation = await NetworkLocationService.GetApproximateLocationAsync(networkTimeout.Token); } catch { // An unavailable IP service must not prevent the GPS fallback. } if (networkLocation is not null) { await JS.InvokeVoidAsync( "lmeMap.showInitialPosition", networkLocation.Latitude, networkLocation.Longitude, true, networkLocation.Label); return; } try { using var gpsTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(18)); var location = await LocationService.GetCurrentLocationAsync(gpsTimeout.Token); if (location is not null) { await JS.InvokeVoidAsync( "lmeMap.showInitialPosition", location.Latitude, location.Longitude, false, string.Empty); } else { await JS.InvokeVoidAsync("lmeMap.showInfo", "Posizione iniziale non disponibile. La mappa resta utilizzabile normalmente."); } } catch (OperationCanceledException) { await JS.InvokeVoidAsync("lmeMap.showInfo", "Posizione iniziale non disponibile. Usa Avvia GPS per riprovare."); } catch (Exception error) { await JS.InvokeVoidAsync("lmeMap.showInfo", $"Posizione iniziale non disponibile: {error.GetBaseException().Message}"); } } private async Task MigrateLegacyProjectAsync() { try { var legacySnapshot = await JS.InvokeAsync("lmeMap.getLegacyProjectSnapshot"); if (string.IsNullOrWhiteSpace(legacySnapshot)) { return; } var migrated = await ProjectStorageService.SaveAsync( "Progetto precedente", legacySnapshot, false); await JS.InvokeVoidAsync("lmeMap.clearLegacyProjectSnapshot"); await RefreshProjectsAsync(); SelectedProjectId = migrated.Id; await RefreshProjectVersionsAsync(true); await InvokeAsync(StateHasChanged); } catch { // The legacy value remains available and migration can be retried at the next start. } } private async Task SelectTab(string tab) { NavigationMenuOpen = false; ActiveTab = tab; if (tab != "map") { LayerPanelOpen = false; } if (tab == "projects") { await RefreshProjectsAsync(); } if (tab == "fiducials" && CanUse(AppFeatures.FiducialPoints) && FiducialProvinces.Count == 0) { await LoadFiducialProvincesAsync(); } await Task.Delay(30); await JS.InvokeVoidAsync("lmeMap.resize"); if (LayerPanelOpen) { await JS.InvokeVoidAsync("lmeMap.refreshLayerManager"); } } private void ToggleNavigationMenu() { NavigationMenuOpen = !NavigationMenuOpen; if (NavigationMenuOpen) LayerPanelOpen = false; } private async Task ToggleLayerPanel() { LayerPanelOpen = !LayerPanelOpen; if (!LayerPanelOpen) { await JS.InvokeVoidAsync("lmeMap.clearLayerManagerSelection"); } if (LayerPanelOpen) { ActiveTab = "map"; } await Task.Delay(30); await JS.InvokeVoidAsync("lmeMap.resize"); if (LayerPanelOpen) { await JS.InvokeVoidAsync("lmeMap.refreshLayerManager"); } } private async Task SearchLocation() { if (string.IsNullOrWhiteSpace(SearchText)) { await JS.InvokeVoidAsync("lmeMap.showInfo", "Inserisci una localita, un indirizzo o due coordinate."); return; } try { Status = "ricerca..."; var result = await LocationSearchService.SearchAsync(SearchText); if (result is null) { Status = "online"; await JS.InvokeVoidAsync("lmeMap.showInfo", "Localita non trovata. Prova ad aggiungere Comune e Provincia."); return; } await JS.InvokeVoidAsync("lmeMap.showSearchResult", result.Latitude, result.Longitude, result.Label); ActiveTab = "map"; Status = "online"; } catch (Exception error) { Status = "errore ricerca"; await JS.InvokeVoidAsync("lmeMap.showInfo", $"Ricerca non riuscita: {error.Message}"); } } private async Task OnRegionChanged(ChangeEventArgs args) { CancelCatalogWork(); SelectedRegionFile = args.Value?.ToString() ?? string.Empty; SelectedMunicipalityCode = string.Empty; SelectedSheet = string.Empty; SelectedParcelNumber = string.Empty; Sheets = []; Parcels = []; CatalogMessage = string.Empty; Municipalities = string.IsNullOrWhiteSpace(SelectedRegionFile) ? [] : 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 = []; 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 = []; 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 province = FiducialProvinces.FirstOrDefault(item => item.Code == SelectedFiducialProvinceCode); var municipality = FiducialMunicipalities.FirstOrDefault(item => item.Code == SelectedFiducialMunicipalityCode); if (province is null || municipality is null || string.IsNullOrWhiteSpace(SelectedFiducialSheet)) { return; } FiducialBusy = true; FiducialPoints = []; FiducialMessage = "Interrogo il servizio ufficiale delle monografie..."; try { FiducialPoints = await FiducialPointService.GetFiducialsAsync( province.Name, municipality.Name, municipality.Code, SelectedFiducialSheet); FiducialMessage = FiducialPoints.Count == 0 ? "Nessun Punto Fiduciale pubblicato per il foglio selezionato." : $"Trovati {FiducialPoints.Count} Punti Fiduciali."; } catch (Exception error) { FiducialMessage = $"Ricerca PF non riuscita: {error.GetBaseException().Message}"; } finally { FiducialBusy = false; } } private async Task DownloadFiducialMonograph(FiducialPointEntry point) { try { await Launcher.Default.OpenAsync(new Uri(point.MonographUrl)); } catch (Exception error) { FiducialMessage = $"Impossibile aprire la monografia: {error.GetBaseException().Message}"; } } private async Task OnMunicipalityChanged(ChangeEventArgs args) { SelectedMunicipalityCode = args.Value?.ToString() ?? string.Empty; SelectedSheet = string.Empty; SelectedParcelNumber = string.Empty; Sheets = []; Parcels = []; var municipality = SelectedMunicipality; if (municipality is null) { return; } var token = BeginCatalogWork(); CatalogBusy = true; CatalogMessage = "Preparo il catalogo regionale..."; CatalogFraction = null; try { Sheets = await CatalogService.GetSheetsAsync(municipality, CreateProgress(), token); CatalogMessage = $"Seleziona uno dei {Sheets.Count} fogli disponibili."; var mapLocation = await LocationSearchService.SearchAsync($"{municipality.Name}, Italia", token); if (mapLocation is not null) { await JS.InvokeVoidAsync("lmeMap.showSearchResult", mapLocation.Latitude, mapLocation.Longitude, mapLocation.Label); } } catch (OperationCanceledException) { } catch (Exception error) { CatalogMessage = $"Impossibile caricare i fogli: {error.Message}"; await JS.InvokeVoidAsync("lmeMap.showInfo", CatalogMessage); } finally { CatalogBusy = false; CatalogFraction = null; } } private async Task OnSheetChanged(ChangeEventArgs args) { SelectedSheet = args.Value?.ToString() ?? string.Empty; SelectedParcelNumber = string.Empty; Parcels = []; var municipality = SelectedMunicipality; if (municipality is null || string.IsNullOrWhiteSpace(SelectedSheet)) { return; } var token = BeginCatalogWork(); CatalogBusy = true; CatalogMessage = "Carico le particelle del foglio..."; CatalogFraction = null; try { Parcels = await CatalogService.GetParcelsAsync(municipality, SelectedSheet, CreateProgress(), token); CatalogMessage = $"Seleziona una delle {Parcels.Count} particelle disponibili."; } catch (OperationCanceledException) { } catch (Exception error) { CatalogMessage = $"Impossibile caricare le particelle: {error.Message}"; await JS.InvokeVoidAsync("lmeMap.showInfo", CatalogMessage); } finally { CatalogBusy = false; CatalogFraction = null; } } private Task OnParcelChanged(ChangeEventArgs args) { SelectedParcelNumber = args.Value?.ToString() ?? string.Empty; return Task.CompletedTask; } private async Task LocateParcel() { var municipality = SelectedMunicipality; var parcel = SelectedParcel; if (municipality is null || parcel is null) { return; } try { var label = $"{municipality.Name} - foglio {DisplaySheet(SelectedSheet)}, particella {parcel.Number}"; CatalogBusy = true; CatalogMessage = "Scarico il confine dal servizio ufficiale AdE..."; ShowSelectedParcels = true; SaveFlagPreferences(); await JS.InvokeVoidAsync("lmeMap.setSelectedParcelsVisible", true); var xml = await OfficialCadastreService.GetParcelsNearAsync(parcel.Latitude, parcel.Longitude, 30); var displayResult = await JS.InvokeAsync("lmeMap.displayOfficialParcel", xml, parcel.Latitude, parcel.Longitude, parcel.Number, label, true); if (displayResult == "imported") { ShowImportedData = true; SaveFlagPreferences(); } ActiveTab = "map"; await Task.Delay(30); await JS.InvokeVoidAsync("lmeMap.resize"); } catch (Exception error) { CatalogMessage = $"Particella non visualizzata: {error.Message}"; await JS.InvokeVoidAsync("lmeMap.showInfo", CatalogMessage); } finally { CatalogBusy = false; } } private async Task ToggleGps() { if (GpsActive) { await StopGps(); } else { await StartGps(); } } private async Task ApplyGpsPositionVisibility() { SaveFlagPreferences(); await JS.InvokeVoidAsync("lmeMap.setGpsPositionVisible", ShowGpsPosition); } private async Task StartGps() { GpsActive = true; GpsLookupCancellation?.Cancel(); GpsLookupCancellation?.Dispose(); GpsLookupCancellation = new CancellationTokenSource(); var lookupToken = GpsLookupCancellation.Token; try { await LocationService.StartTrackingAsync( location => HandleGpsLocationAsync(location, lookupToken), async error => await InvokeAsync(() => JS.InvokeVoidAsync("lmeMap.setGpsError", error))); GpsActive = LocationService.IsTracking; } catch (Exception error) { GpsActive = false; await JS.InvokeVoidAsync("lmeMap.setGpsError", error.Message); } } private async Task StopGps() { LocationService.StopTracking(); GpsLookupCancellation?.Cancel(); GpsLookupCancellation?.Dispose(); GpsLookupCancellation = null; GpsActive = false; await JS.InvokeVoidAsync("lmeMap.stopGps"); } private async Task HandleGpsLocationAsync(LocationSnapshot location, CancellationToken cancellationToken) { await InvokeAsync(() => JS.InvokeVoidAsync("lmeMap.setNativeGpsPosition", location)); var now = Environment.TickCount64; if (now - Interlocked.Read(ref LastGpsLookupAt) < 3_500 || Interlocked.CompareExchange(ref GpsLookupInProgress, 1, 0) != 0) { return; } Interlocked.Exchange(ref LastGpsLookupAt, now); try { var radius = Math.Clamp(location.Accuracy ?? 12, 12, 30); var xml = await OfficialCadastreService.GetParcelsNearAsync( location.Latitude, location.Longitude, radius, cancellationToken); await InvokeAsync(() => JS.InvokeVoidAsync( "lmeMap.displayOfficialParcel", xml, location.Latitude, location.Longitude, null, "Posizione GPS", false)); } catch (OperationCanceledException) { } catch (Exception error) { await InvokeAsync(() => JS.InvokeVoidAsync( "lmeMap.showInfo", $"Aggiornamento particella GPS non disponibile: {error.GetBaseException().Message}")); } finally { Interlocked.Exchange(ref GpsLookupInProgress, 0); } } private IProgress CreateProgress() => new Progress(progress => { _ = InvokeAsync(() => { CatalogMessage = progress.Message; CatalogFraction = progress.Fraction; StateHasChanged(); }); }); private CancellationToken BeginCatalogWork() { CancelCatalogWork(); CatalogCancellation = new CancellationTokenSource(); return CatalogCancellation.Token; } private void CancelCatalogWork() { CatalogCancellation?.Cancel(); CatalogCancellation?.Dispose(); CatalogCancellation = null; } private static string DisplaySheet(string value) { var trimmed = value.TrimStart('0'); return string.IsNullOrEmpty(trimmed) ? "0" : trimmed; } private Task FitImportedLayers() => JS.InvokeVoidAsync("lmeMap.fitImported").AsTask(); private async Task ClearImportedLayers() { ShowImportedSheetLayer = false; SaveFlagPreferences(); await JS.InvokeVoidAsync("lmeMap.clearImported"); } private Task AddProjectMarker() => JS.InvokeVoidAsync("lmeMap.addProjectMarker", PoiName).AsTask(); private async Task ApplyKmlSheetLayerOption() { ShowImportedSheetLayer = IncludeKmlSheetLayer; SaveFlagPreferences(); await JS.InvokeVoidAsync("lmeMap.setIncludeKmlSheetLayer", IncludeKmlSheetLayer); await JS.InvokeVoidAsync("lmeMap.setImportedSheetLayerVisible", ShowImportedSheetLayer); } private Task ApplyImportedSheetLayerVisibility() { SaveFlagPreferences(); return JS.InvokeVoidAsync("lmeMap.setImportedSheetLayerVisible", ShowImportedSheetLayer).AsTask(); } private async Task ApplyImportedMeasurementsVisibility() { SaveFlagPreferences(); await JS.InvokeVoidAsync("lmeMap.setImportedMeasurementsVisible", ShowImportedMeasurements); } private async Task ApplyDrawingMeasurementsVisibility() { SaveFlagPreferences(); await JS.InvokeVoidAsync("lmeMap.setDrawingMeasurementsVisible", ShowDrawingMeasurements); } [JSInvokable] public Task SetMeasurementsFromLayerManager(string category, bool enabled) { if (category == "imported") { ShowImportedMeasurements = enabled; } else if (category == "drawings") { ShowDrawingMeasurements = enabled; } SaveFlagPreferences(); return InvokeAsync(StateHasChanged); } [JSInvokable] public Task GetOfficialCadastralSheetsNear(double latitude, double longitude) => OfficialCadastreService.GetSheetsNearAsync(latitude, longitude); [JSInvokable] public Task OnMapChanged(string action) { HasUnsavedChanges = true; if (AutoSaveOpenProject && SelectedProject is { IsReadOnly: false }) { ProjectAutoSaveCancellation?.Cancel(); ProjectAutoSaveCancellation?.Dispose(); ProjectAutoSaveCancellation = new CancellationTokenSource(); _ = AutoSaveCurrentProjectAsync(ProjectAutoSaveCancellation.Token); } return InvokeAsync(StateHasChanged); } [JSInvokable] public Task OnProjectSaved() { HasUnsavedChanges = false; return InvokeAsync(StateHasChanged); } private async Task AutoSaveCurrentProjectAsync(CancellationToken cancellationToken) { try { await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); var project = SelectedProject; if (project is null || project.IsReadOnly) return; var snapshot = await JS.InvokeAsync("lmeMap.createProjectSnapshot", IncludeCadastralParcels); await ProjectStorageService.SaveAsync(project.Name, snapshot, IncludeCadastralParcels, project.Id); await JS.InvokeVoidAsync("lmeMap.markProjectSaved"); await RefreshProjectsAsync(); } catch (OperationCanceledException) { } catch (Exception error) { await JS.InvokeVoidAsync("lmeMap.showInfo", $"Autosalvataggio progetto non riuscito: {error.GetBaseException().Message}"); } } private async Task ToggleDrawTools() { DrawToolsVisible = !DrawToolsVisible; SaveFlagPreferences(); await JS.InvokeVoidAsync("lmeMap.setDrawToolsVisible", DrawToolsVisible); if (DrawToolsVisible) { await SelectTab("map"); } } private Task ClearDrawings() => JS.InvokeVoidAsync("lmeMap.clearDrawings").AsTask(); private async Task RefreshProjectsAsync() { try { Projects = await ProjectStorageService.GetProjectsAsync(); if (Projects.All(project => project.Id != SelectedProjectId)) { SelectedProjectId = string.Empty; } await RefreshProjectVersionsAsync(); ProjectArchiveMessage = Projects.Count == 0 ? "Nessun progetto salvato." : $"{Projects.Count} {(Projects.Count == 1 ? "progetto salvato" : "progetti salvati")} nell'archivio interno."; } catch (Exception error) { ProjectArchiveMessage = $"Archivio non disponibile: {error.GetBaseException().Message}"; } } private async Task SelectProject(ChangeEventArgs args) { SelectedProjectId = args.Value?.ToString() ?? string.Empty; ProjectFolder = SelectedProject?.Folder ?? string.Empty; await RefreshProjectVersionsAsync(true); } private async Task RefreshProjectVersionsAsync(bool resetSelection = false) { ProjectVersions = string.IsNullOrWhiteSpace(SelectedProjectId) ? [] : await ProjectStorageService.GetVersionsAsync(SelectedProjectId); if (resetSelection || ProjectVersions.All(version => version.Id != SelectedVersionId)) { SelectedVersionId = string.Empty; } } private Task UndoMapChange() => JS.InvokeVoidAsync("lmeMap.undoMapChange").AsTask(); private Task RedoMapChange() => JS.InvokeVoidAsync("lmeMap.redoMapChange").AsTask(); private Task RestoreAutosave() => JS.InvokeVoidAsync("lmeMap.restoreAutosave").AsTask(); private Task SaveGpsMeasurementPoint() => JS.InvokeVoidAsync("lmeMap.saveGpsMeasurementPoint").AsTask(); private async Task ShowTrailStats() { var stats = await JS.InvokeAsync("lmeMap.getGpsTrailStats"); var duration = TimeSpan.FromSeconds(stats.DurationSeconds); await JS.InvokeVoidAsync("lmeMap.showInfo", $"Traccia: {stats.Points} punti, {stats.Length:0} m, durata {duration:hh\\:mm\\:ss}, {(stats.Recording ? "in registrazione" : "in pausa")}."); } private Task ClearGpsTrail() => JS.InvokeVoidAsync("lmeMap.clearGpsTrail").AsTask(); private async Task DuplicateProject() { if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; } if (SelectedProject is null) return; var copy = await ProjectStorageService.DuplicateAsync(SelectedProject.Id); await RefreshProjectsAsync(); SelectedProjectId = copy.Id; ProjectFolder = copy.Folder; await RefreshProjectVersionsAsync(true); await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto duplicato: {copy.Name}."); } private async Task RenameProject() { if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; } if (SelectedProject is not { } project) return; var requestedName = await JS.InvokeAsync("lmeMap.promptProjectName", project.Name); if (string.IsNullOrWhiteSpace(requestedName) || string.Equals(requestedName.Trim(), project.Name, StringComparison.CurrentCulture)) { return; } ProjectBusy = true; try { var renamed = await ProjectStorageService.RenameAsync(project.Id, requestedName); await RefreshProjectsAsync(); SelectedProjectId = renamed.Id; await RefreshProjectVersionsAsync(); NewProjectName = renamed.Name; if (!HasUnsavedChanges) { ExportFileName = renamed.Name; } await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto rinominato: {renamed.Name}."); } catch (Exception error) { await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto non rinominato: {error.GetBaseException().Message}"); } finally { ProjectBusy = false; } } private async Task UpdateProjectFolder() { if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; } if (SelectedProject is null) return; await ProjectStorageService.UpdateOrganizationAsync(SelectedProject.Id, ProjectFolder); await RefreshProjectsAsync(); await JS.InvokeVoidAsync("lmeMap.showInfo", "Cartella del progetto aggiornata."); } private async Task ToggleProjectArchive() { if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; } if (SelectedProject is not { } project) return; await ProjectStorageService.UpdateOrganizationAsync(project.Id, isArchived: !project.IsArchived); await RefreshProjectsAsync(); await JS.InvokeVoidAsync("lmeMap.showInfo", project.IsArchived ? "Progetto ripristinato." : "Progetto archiviato."); } private async Task ShareProject(bool readOnly) { if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; } if (SelectedProject is null) return; var path = await ProjectStorageService.CreateProjectPackageAsync(SelectedProject.Id, readOnly); await Share.Default.RequestAsync(new ShareFileRequest { Title = readOnly ? "Condividi progetto LME in sola lettura" : "Condividi progetto LME modificabile", File = new ShareFile(path, "application/json") }); } private async Task ShareFullBackup() { if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; } var path = await ProjectStorageService.CreateBackupAsync(); await Share.Default.RequestAsync(new ShareFileRequest { Title = "Backup completo LME Mappe Catastali", File = new ShareFile(path, "application/zip") }); } private async Task RestoreBackup() { if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; } var file = await FilePicker.Default.PickAsync(new PickOptions { PickerTitle = "Scegli backup LME (.lmebackup.zip)", FileTypes = BackupPickerFileTypes }); if (file is null) return; var path = await CopyPickedFileToCacheAsync(file); var count = await ProjectStorageService.RestoreBackupAsync(path); await RefreshProjectsAsync(); await JS.InvokeVoidAsync("lmeMap.showInfo", $"Backup ripristinato: {count} progetti disponibili."); } private async Task ImportProject() { if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; } var file = await FilePicker.Default.PickAsync(new PickOptions { PickerTitle = "Scegli progetto LME (.lmeproject.json)", FileTypes = ProjectPickerFileTypes }); if (file is null) return; var path = await CopyPickedFileToCacheAsync(file); var imported = await ProjectStorageService.ImportProjectAsync(path); await RefreshProjectsAsync(); SelectedProjectId = imported.Id; await RefreshProjectVersionsAsync(true); await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto importato: {imported.Name}."); } private static async Task CopyPickedFileToCacheAsync(FileResult file) { var path = Path.Combine(FileSystem.CacheDirectory, $"{Guid.NewGuid():N}-{file.FileName}"); await using var input = await file.OpenReadAsync(); await using var output = File.Create(path); await input.CopyToAsync(output); return path; } private void OnIncomingFileAvailable() { if (MapReady) { _ = InvokeAsync(ProcessIncomingFilesAsync); } } private async Task ProcessIncomingFilesAsync() { while (IncomingFiles.TryDequeue(out var incoming) && incoming is not null) { try { var lowerName = incoming.FileName.ToLowerInvariant(); if (lowerName.EndsWith(".kml", StringComparison.Ordinal) || lowerName.EndsWith(".kmz", StringComparison.Ordinal) || lowerName.EndsWith(".geojson", StringComparison.Ordinal) || await IsGeoJsonAsync(incoming.Path)) { var content = Convert.ToBase64String(await File.ReadAllBytesAsync(incoming.Path)); await JS.InvokeAsync("lmeMap.importExternalGisFile", incoming.FileName, content, incoming.ContentType); continue; } if (lowerName.EndsWith(".zip", StringComparison.Ordinal)) { if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); continue; } var count = await ProjectStorageService.RestoreBackupAsync(incoming.Path); await RefreshProjectsAsync(); await JS.InvokeVoidAsync("lmeMap.showInfo", $"Backup aperto: {count} progetti ripristinati."); continue; } if (lowerName.EndsWith(".json", StringComparison.Ordinal)) { if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); continue; } var imported = await ProjectStorageService.ImportProjectAsync(incoming.Path); await RefreshProjectsAsync(); SelectedProjectId = imported.Id; await RefreshProjectVersionsAsync(true); await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto aperto: {imported.Name}."); continue; } await JS.InvokeVoidAsync("lmeMap.showInfo", $"Formato non riconosciuto: {incoming.FileName}."); } catch (Exception error) { await JS.InvokeVoidAsync("lmeMap.showInfo", $"Impossibile aprire {incoming.FileName}: {error.GetBaseException().Message}"); } finally { try { File.Delete(incoming.Path); } catch { } } } } private static async Task IsGeoJsonAsync(string path) { if (!Path.GetExtension(path).Equals(".json", StringComparison.OrdinalIgnoreCase)) { return false; } try { await using var stream = File.OpenRead(path); using var document = await System.Text.Json.JsonDocument.ParseAsync(stream); if (!document.RootElement.TryGetProperty("type", out var typeProperty)) { return false; } var type = typeProperty.GetString(); return type is "Feature" or "FeatureCollection" or "Point" or "MultiPoint" or "LineString" or "MultiLineString" or "Polygon" or "MultiPolygon" or "GeometryCollection"; } catch { return false; } } private static readonly FilePickerFileType ProjectPickerFileTypes = new(new Dictionary> { [DevicePlatform.Android] = ["application/json", "text/json", "text/plain", "application/octet-stream"], [DevicePlatform.WinUI] = [".json"], [DevicePlatform.iOS] = ["public.json", "public.data"], [DevicePlatform.macOS] = ["public.json", "public.data"] }); private static readonly FilePickerFileType BackupPickerFileTypes = new(new Dictionary> { [DevicePlatform.Android] = ["application/zip", "application/x-zip-compressed", "application/octet-stream"], [DevicePlatform.WinUI] = [".zip"], [DevicePlatform.iOS] = ["public.zip-archive", "public.data"], [DevicePlatform.macOS] = ["public.zip-archive", "public.data"] }); private async Task LoadProjectVersion() { if (SelectedProject is null || string.IsNullOrWhiteSpace(SelectedVersionId)) return; if (!await JS.InvokeAsync("lmeMap.confirmDiscardChanges", "aprire la revisione")) return; var snapshot = await ProjectStorageService.LoadVersionAsync(SelectedProject.Id, SelectedVersionId); var options = await JS.InvokeAsync("lmeMap.loadProjectSnapshot", snapshot, $"{SelectedProject.Name} - revisione"); ApplyProjectDisplayOptions(options); ActiveTab = "map"; } private async Task CompareProjectVersion() { if (SelectedProject is null || string.IsNullOrWhiteSpace(SelectedVersionId)) return; var current = await ProjectStorageService.LoadAsync(SelectedProject.Id); var previous = await ProjectStorageService.LoadVersionAsync(SelectedProject.Id, SelectedVersionId); ProjectArchiveMessage = await JS.InvokeAsync("lmeMap.compareProjectSnapshots", current, previous); } private async Task ExportMapFile() { if (!CanUse(AppFeatures.Export)) { await ShowProRequiredAsync(); return; } if (!ExportImported && !ExportImportedSheets && !ExportCadastral && !ExportDrawings && !ExportPoi && !ExportTrail) { await JS.InvokeVoidAsync("lmeMap.showInfo", "Seleziona almeno un tipo di oggetto da esportare."); return; } ExportBusy = true; try { var geoJson = await JS.InvokeAsync("lmeMap.createMapExportGeoJson", new { includeImported = ExportImported, includeImportedSheets = ExportImportedSheets, includeCadastral = ExportCadastral, includeDrawings = ExportDrawings, includePoi = ExportPoi, includeTrail = ExportTrail, selectedOnly = ExportSelectedOnly }); var exported = await MapExportService.CreateAsync(ExportFileName, ExportFormat, geoJson); await Share.Default.RequestAsync(new ShareFileRequest { Title = $"Salva o condividi {exported.FileName}", File = new ShareFile(exported.Path, exported.MimeType) }); await JS.InvokeVoidAsync( "lmeMap.showInfo", $"File {exported.FileName} pronto: {exported.FeatureCount} oggetti esportati."); } catch (Exception error) { await JS.InvokeVoidAsync("lmeMap.showInfo", $"Esportazione non riuscita: {error.GetBaseException().Message}"); } finally { ExportBusy = false; } } private async Task SaveProject() { if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; } var name = NewProjectName.Trim(); if (string.IsNullOrWhiteSpace(name)) { await JS.InvokeVoidAsync("lmeMap.showInfo", "Inserisci il nome del progetto."); return; } var existing = Projects.FirstOrDefault(project => string.Equals(project.Name, name, StringComparison.CurrentCultureIgnoreCase)); if (existing is not null && !await JS.InvokeAsync("confirm", $"Il progetto '{existing.Name}' esiste gia. Sovrascriverlo?")) { return; } ProjectBusy = true; try { var snapshot = await JS.InvokeAsync("lmeMap.createProjectSnapshot", IncludeCadastralParcels); var saved = await ProjectStorageService.SaveAsync( name, snapshot, IncludeCadastralParcels, existing?.Id); await RefreshProjectsAsync(); SelectedProjectId = saved.Id; await RefreshProjectVersionsAsync(); NewProjectName = saved.Name; await JS.InvokeVoidAsync("lmeMap.setProjectReadOnly", false); await JS.InvokeVoidAsync("lmeMap.markProjectSaved"); HasUnsavedChanges = false; await CreateAutomaticBackupIfDue(); await JS.InvokeVoidAsync( "lmeMap.showInfo", IncludeCadastralParcels ? $"Progetto salvato: {saved.Name}, incluse le particelle catastali evidenziate." : $"Progetto salvato: {saved.Name}, senza particelle catastali evidenziate."); } catch (Exception error) { await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto non salvato: {error.GetBaseException().Message}"); } finally { ProjectBusy = false; } } private async Task LoadProject() { var project = Projects.FirstOrDefault(item => item.Id == SelectedProjectId); if (project is null) { await JS.InvokeVoidAsync("lmeMap.showInfo", "Seleziona il progetto da caricare."); return; } if (!await JS.InvokeAsync("lmeMap.confirmDiscardChanges", "aprire un altro progetto")) return; ProjectBusy = true; try { var snapshot = await ProjectStorageService.LoadAsync(project.Id); await ConfigureProjectMapProviderAsync(snapshot); var displayOptions = await JS.InvokeAsync("lmeMap.loadProjectSnapshot", snapshot, project.Name); ApplyProjectDisplayOptions(displayOptions); NewProjectName = project.Name; ExportFileName = project.Name; IncludeCadastralParcels = project.IncludesCadastralParcels; await JS.InvokeVoidAsync("lmeMap.setProjectReadOnly", project.IsReadOnly); await JS.InvokeVoidAsync("lmeMap.markProjectSaved"); HasUnsavedChanges = false; ActiveTab = "map"; await Task.Delay(30); await JS.InvokeVoidAsync("lmeMap.resize"); } catch (Exception error) { await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto non caricato: {error.GetBaseException().Message}"); } finally { ProjectBusy = false; } } private void ApplyProjectDisplayOptions(ProjectDisplayOptions options) { SelectedBaseLayer = options.BaseLayer; CadastralOpacity = options.CadastreOpacity; IncludeKmlSheetLayer = options.IncludeKmlSheetLayer; ShowImportedData = options.ImportedVisible; ShowImportedSheetLayer = options.ImportedSheetsVisible; ShowImportedMeasurements = options.ShowImportedMeasurements; ShowDrawingMeasurements = options.ShowDrawingMeasurements; DrawToolsVisible = options.DrawToolsVisible; ShowGpsPosition = options.GpsPositionVisible; ShowSelectedParcels = options.SelectedParcelsVisible; TrailEnabled = options.TrailEnabled; var activeLayers = (options.CadastreLayers ?? []).ToHashSet(StringComparer.Ordinal); foreach (var layer in CadastralLayers) { layer.Enabled = activeLayers.Contains(layer.Key); } SaveFlagPreferences(); } private async Task DeleteProject() { var project = Projects.FirstOrDefault(item => item.Id == SelectedProjectId); if (project is null) { return; } if (!await JS.InvokeAsync("confirm", $"Eliminare definitivamente il progetto '{project.Name}'?")) { return; } ProjectBusy = true; try { await ProjectStorageService.DeleteAsync(project.Id); SelectedProjectId = string.Empty; await RefreshProjectsAsync(); await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto eliminato: {project.Name}."); } catch (Exception error) { await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto non eliminato: {error.GetBaseException().Message}"); } finally { ProjectBusy = false; } } private Task ShowObjectTable() => JS.InvokeVoidAsync("lmeMap.showObjectTable").AsTask(); private Task ShowAuditLog() => JS.InvokeVoidAsync("lmeMap.showAuditLog").AsTask(); private Task ShowCoordinateInspector() => JS.InvokeVoidAsync("lmeMap.showCoordinateInspector").AsTask(); private Task ValidateSelectedGeometries() => JS.InvokeVoidAsync("lmeMap.validateAndRepairSelected").AsTask(); private Task CreateSelectedHole() => JS.InvokeVoidAsync("lmeMap.createHoleFromSelected").AsTask(); private Task CompareSelectedGeometries() => JS.InvokeVoidAsync("lmeMap.compareSelectedGeometries").AsTask(); private Task RestoreTrashItem() => JS.InvokeVoidAsync("lmeMap.restoreLastTrashItem").AsTask(); private Task EmptyTrash() => JS.InvokeVoidAsync("lmeMap.emptyTrash").AsTask(); private async Task DownloadOfflineArea() { if (!CanUse(AppFeatures.OfflineMaps)) { await ShowProRequiredAsync(); return; } OfflineBusy = true; try { var result = await JS.InvokeAsync("lmeMap.downloadOfflineArea", OfflineExtraZoomLevels); OfflineStatus = $"{result.Saved} tessere disponibili, zoom {result.StartZoom}-{result.EndZoom}."; } catch (Exception error) { OfflineStatus = $"Download non riuscito: {error.GetBaseException().Message}"; } finally { OfflineBusy = false; } } private async Task UseOfflineMap() { await JS.InvokeVoidAsync("lmeMap.setBaseLayer", "offline"); SelectedBaseLayer = "offline"; } private async Task ClearOfflineArea() { if (!await JS.InvokeAsync("confirm", "Svuotare tutte le mappe salvate offline?")) return; await JS.InvokeVoidAsync("lmeMap.clearOfflineTiles"); OfflineStatus = "Nessuna tessera salvata."; } private async Task SaveWebDavSettings() { Preferences.Default.Set("webdav_endpoint", WebDavEndpoint.Trim()); Preferences.Default.Set("webdav_username", WebDavUserName.Trim()); if (string.IsNullOrWhiteSpace(WebDavPassword)) SecureStorage.Default.Remove("webdav_password"); else await SecureStorage.Default.SetAsync("webdav_password", WebDavPassword); SyncStatus = "Configurazione WebDAV salvata sul dispositivo."; } private async Task SyncUpload() { if (!CanUse(AppFeatures.CloudSync)) { await ShowProRequiredAsync(); return; } SyncBusy = true; try { await SaveWebDavSettings(); var backup = await ProjectStorageService.CreateBackupAsync(); await CloudSyncService.UploadAsync(WebDavEndpoint, WebDavUserName, WebDavPassword, backup); SyncStatus = $"Archivio inviato al cloud il {DateTime.Now:dd/MM/yyyy HH:mm}."; } catch (Exception error) { SyncStatus = $"Invio non riuscito: {error.GetBaseException().Message}"; } finally { SyncBusy = false; } } private async Task SyncDownload() { if (!CanUse(AppFeatures.CloudSync)) { await ShowProRequiredAsync(); return; } if (!await JS.InvokeAsync("lmeMap.confirmDiscardChanges", "ripristinare l'archivio cloud")) return; SyncBusy = true; try { await SaveWebDavSettings(); var backup = await CloudSyncService.DownloadAsync(WebDavEndpoint, WebDavUserName, WebDavPassword); var count = await ProjectStorageService.RestoreBackupAsync(backup); await RefreshProjectsAsync(); SyncStatus = $"Sincronizzazione completata: {count} progetti ripristinati."; } catch (Exception error) { SyncStatus = $"Download non riuscito: {error.GetBaseException().Message}"; } finally { SyncBusy = false; } } private async Task CheckForUpdates() { UpdateBusy = true; UpdateDownloadUrl = string.Empty; UpdateReleaseNotes = string.Empty; try { var update = await AppUpdateService.CheckAsync(); UpdateReleaseNotes = update.ReleaseNotes; if (update.IsAvailable) { UpdateStatus = $"Installata {AppUpdateService.CurrentVersion}. Disponibile {update.Version}."; UpdateDownloadUrl = update.DownloadUrl; } else UpdateStatus = $"Versione {AppUpdateService.CurrentVersion} aggiornata. Ultima release Pro su Gitea: {update.Version}."; } catch (Exception error) { UpdateStatus = $"Controllo non riuscito: {error.GetBaseException().Message}"; } finally { UpdateBusy = false; } } private Task OpenUpdateDownload() => string.IsNullOrWhiteSpace(UpdateDownloadUrl) ? Task.CompletedTask : Launcher.Default.OpenAsync(new Uri(UpdateDownloadUrl)); private async Task CreateAutomaticBackupIfDue() { if (AutomaticBackupFrequency == "off") return; var latest = ProjectStorageService.GetLatestScheduledBackupTime(); var interval = AutomaticBackupFrequency == "weekly" ? TimeSpan.FromDays(7) : TimeSpan.FromDays(1); if (latest is null || DateTimeOffset.UtcNow - latest.Value.ToUniversalTime() >= interval) { await ProjectStorageService.CreateScheduledBackupAsync(); } } 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); private Task ShowProRequiredAsync() => JS.InvokeVoidAsync("lmeMap.showInfo", ProUpgradeText).AsTask(); private async Task ApplyLanguagePreference() { L10n.SetLanguage(LanguagePreference); LanguagePreference = L10n.LanguagePreference; await ApplyLocalizationToMapAsync(); } private async Task ApplyMeasurementPreference() { L10n.SetMeasurementSystem(MeasurementPreference); MeasurementPreference = L10n.MeasurementPreference; await ApplyLocalizationToMapAsync(); } private Task ApplyLocalizationToMapAsync() => JS.InvokeVoidAsync("lmeMap.setLocalization", new { culture = L10n.Culture.Name, measurementSystem = L10n.MeasurementSystem, labels = new { measureArea = L("measure.area"), measureDistance = L("measure.distance"), measureCircle = L("measure.circle"), drawRectangle = L("measure.rectangle"), addPoi = L("measure.poi"), proRequired = ProUpgradeText } }).AsTask(); private Task ApplyEntitlementsToMapAsync() => JS.InvokeVoidAsync("lmeMap.setEntitlements", new { 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; try { var result = await Entitlements.RestorePurchasesAsync(); await ApplyEntitlementsToMapAsync(); await JS.InvokeVoidAsync("lmeMap.showInfo", result.Message); } finally { BillingBusy = false; } } private async Task SubscribeGooglePro(string basePlanId) { BillingBusy = true; try { var result = await Entitlements.PurchaseGoogleProAsync(basePlanId); await ApplyEntitlementsToMapAsync(); await JS.InvokeVoidAsync("lmeMap.showInfo", result.Message); } finally { BillingBusy = false; } } private async Task ActivateManualLicense() { var result = await Entitlements.ActivateManualLicenseAsync(ManualLicenseCode); ManualLicenseMessage = result.IsValid ? L("settings.pro.manual.activated") : result.Error ?? L("settings.pro.manual.invalid"); if (result.IsValid) { ManualLicenseCode = string.Empty; } await ApplyEntitlementsToMapAsync(); await JS.InvokeVoidAsync("lmeMap.showInfo", ManualLicenseMessage); } private async Task RemoveManualLicense() { await Entitlements.RemoveManualLicenseAsync(); ManualLicenseCode = string.Empty; ManualLicenseMessage = L("settings.pro.manual.removed"); await ApplyEntitlementsToMapAsync(); await JS.InvokeVoidAsync("lmeMap.showInfo", ManualLicenseMessage); } private Task OpenSubscriptionManagement() => Launcher.Default.OpenAsync(new Uri( "https://play.google.com/store/account/subscriptions")); private async Task ApplyTrailOption() { if (!CanUse(AppFeatures.GpsTrail)) { TrailEnabled = false; SaveFlagPreferences(); await JS.InvokeVoidAsync("lmeMap.toggleTrail", false); await ShowProRequiredAsync(); return; } SaveFlagPreferences(); await JS.InvokeVoidAsync("lmeMap.toggleTrail", TrailEnabled); } private async Task ChangeBaseLayer(ChangeEventArgs args) { var requestedLayer = args.Value?.ToString() ?? "satellite"; try { if (requestedLayer.StartsWith("google-", StringComparison.Ordinal)) { if (string.IsNullOrWhiteSpace(GoogleMapsApiKey)) { await JS.InvokeVoidAsync("lmeMap.showInfo", "Inserisci e salva una chiave Google Maps Platform per usare questo sfondo."); return; } await JS.InvokeVoidAsync("lmeMap.configureGoogleMaps", GoogleMapsApiKey.Trim()); } if (requestedLayer.StartsWith("azure-", StringComparison.Ordinal)) { if (string.IsNullOrWhiteSpace(AzureMapsSubscriptionKey)) { await JS.InvokeVoidAsync("lmeMap.showInfo", "Inserisci e salva la chiave della sottoscrizione Azure Maps."); return; } await AzureMapsService.ValidateKeyAsync(AzureMapsSubscriptionKey); await JS.InvokeVoidAsync("lmeMap.configureAzureMaps", AzureMapsSubscriptionKey.Trim()); } await JS.InvokeVoidAsync("lmeMap.setBaseLayer", requestedLayer); SelectedBaseLayer = requestedLayer; } catch (Exception error) { await JS.InvokeVoidAsync("lmeMap.showInfo", $"Sfondo non disponibile: {error.GetBaseException().Message}"); } } private async Task SaveGoogleMapsApiKey() { try { if (string.IsNullOrWhiteSpace(GoogleMapsApiKey)) { SecureStorage.Default.Remove("google_maps_api_key"); await JS.InvokeVoidAsync("lmeMap.showInfo", "Chiave Google rimossa."); return; } GoogleMapsApiKey = GoogleMapsApiKey.Trim(); await SecureStorage.Default.SetAsync("google_maps_api_key", GoogleMapsApiKey); await JS.InvokeVoidAsync("lmeMap.configureGoogleMaps", GoogleMapsApiKey); await JS.InvokeVoidAsync("lmeMap.showInfo", "Chiave Google salvata sul dispositivo."); } catch (Exception error) { await JS.InvokeVoidAsync("lmeMap.showInfo", $"Chiave Google non salvata: {error.Message}"); } } private async Task SaveAzureMapsKey() { try { if (string.IsNullOrWhiteSpace(AzureMapsSubscriptionKey)) { SecureStorage.Default.Remove("azure_maps_subscription_key"); await JS.InvokeVoidAsync("lmeMap.clearAzureMaps"); if (SelectedBaseLayer.StartsWith("azure-", StringComparison.Ordinal)) { SelectedBaseLayer = "satellite"; } await JS.InvokeVoidAsync("lmeMap.showInfo", "Chiave Azure Maps rimossa."); return; } AzureMapsSubscriptionKey = AzureMapsSubscriptionKey.Trim(); await AzureMapsService.ValidateKeyAsync(AzureMapsSubscriptionKey); await SecureStorage.Default.SetAsync("azure_maps_subscription_key", AzureMapsSubscriptionKey); await JS.InvokeVoidAsync("lmeMap.configureAzureMaps", AzureMapsSubscriptionKey); await JS.InvokeVoidAsync("lmeMap.showInfo", "Chiave Azure Maps valida e salvata sul dispositivo."); } catch (Exception error) { await JS.InvokeVoidAsync("lmeMap.showInfo", $"Chiave Azure Maps non salvata: {error.GetBaseException().Message}"); } } private async Task ChangeCadastreOpacity(ChangeEventArgs args) { var text = args.Value?.ToString(); if (!double.TryParse(text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var value) && !double.TryParse(text, out value)) { return; } CadastralOpacity = Math.Clamp(value, 0, 1); await JS.InvokeVoidAsync("lmeMap.setCadastreOpacity", CadastralOpacity); } private async Task ToggleCadastreLayer(CadastralLayer layer, ChangeEventArgs args) { layer.Enabled = args.Value is true; SaveFlagPreferences(); await JS.InvokeVoidAsync("lmeMap.toggleCadastreLayer", layer.Key, layer.Enabled); } private void LoadFlagPreferences() { IncludeCadastralParcels = GetFlagPreference("include_cadastral_parcels", true); IncludeKmlSheetLayer = GetFlagPreference("include_kml_sheet_layer", false); ShowImportedData = GetFlagPreference("show_imported_data", true); ShowImportedSheetLayer = GetFlagPreference("show_imported_sheet_layer", false); ShowImportedMeasurements = GetFlagPreference("show_imported_measurements", false); ShowDrawingMeasurements = GetFlagPreference("show_drawing_measurements", true); DrawToolsVisible = GetFlagPreference("draw_tools_visible", false); ShowGpsPosition = GetFlagPreference("show_gps_position", true); ShowSelectedParcels = GetFlagPreference("show_selected_parcels", true); TrailEnabled = GetFlagPreference("trail_enabled", false); ExportImported = GetFlagPreference("export_imported", true); ExportImportedSheets = GetFlagPreference("export_imported_sheets", true); ExportCadastral = GetFlagPreference("export_cadastral", true); ExportDrawings = GetFlagPreference("export_drawings", true); ExportPoi = GetFlagPreference("export_poi", true); ExportTrail = GetFlagPreference("export_trail", true); ExportSelectedOnly = GetFlagPreference("export_selected_only", false); AutoSaveEnabled = GetFlagPreference("auto_save", true); AutoSaveOpenProject = GetFlagPreference("auto_save_project", false); AutomaticBackupFrequency = Preferences.Default.Get($"{FlagPreferencePrefix}backup_frequency", "daily"); SnapEnabled = GetFlagPreference("snap_enabled", true); AutoFitSelectedObject = GetFlagPreference("auto_fit_selected_object", false); SnapDistancePixels = Preferences.Default.Get($"{FlagPreferencePrefix}snap_distance", 14); 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) { layer.Enabled = GetFlagPreference($"cadastre_layer_{layer.Key}", layer.Enabled); } } private void SaveFlagPreferences() { SetFlagPreference("include_cadastral_parcels", IncludeCadastralParcels); SetFlagPreference("include_kml_sheet_layer", IncludeKmlSheetLayer); SetFlagPreference("show_imported_data", ShowImportedData); SetFlagPreference("show_imported_sheet_layer", ShowImportedSheetLayer); SetFlagPreference("show_imported_measurements", ShowImportedMeasurements); SetFlagPreference("show_drawing_measurements", ShowDrawingMeasurements); SetFlagPreference("draw_tools_visible", DrawToolsVisible); SetFlagPreference("show_gps_position", ShowGpsPosition); SetFlagPreference("show_selected_parcels", ShowSelectedParcels); SetFlagPreference("trail_enabled", TrailEnabled); SetFlagPreference("export_imported", ExportImported); SetFlagPreference("export_imported_sheets", ExportImportedSheets); SetFlagPreference("export_cadastral", ExportCadastral); SetFlagPreference("export_drawings", ExportDrawings); SetFlagPreference("export_poi", ExportPoi); SetFlagPreference("export_trail", ExportTrail); SetFlagPreference("export_selected_only", ExportSelectedOnly); SetFlagPreference("auto_save", AutoSaveEnabled); SetFlagPreference("auto_save_project", AutoSaveOpenProject); Preferences.Default.Set($"{FlagPreferencePrefix}backup_frequency", AutomaticBackupFrequency); SetFlagPreference("snap_enabled", SnapEnabled); SetFlagPreference("auto_fit_selected_object", AutoFitSelectedObject); Preferences.Default.Set($"{FlagPreferencePrefix}snap_distance", SnapDistancePixels); 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) { SetFlagPreference($"cadastre_layer_{layer.Key}", layer.Enabled); } } 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); await JS.InvokeVoidAsync("lmeMap.setImportedMeasurementsVisible", ShowImportedMeasurements); await JS.InvokeVoidAsync("lmeMap.setDrawingMeasurementsVisible", ShowDrawingMeasurements); await JS.InvokeVoidAsync("lmeMap.setDrawToolsVisible", DrawToolsVisible); await JS.InvokeVoidAsync("lmeMap.setGpsPositionVisible", ShowGpsPosition); await JS.InvokeVoidAsync("lmeMap.setSelectedParcelsVisible", ShowSelectedParcels); await JS.InvokeVoidAsync("lmeMap.toggleTrail", TrailEnabled); foreach (var layer in CadastralLayers) { await JS.InvokeVoidAsync("lmeMap.toggleCadastreLayer", layer.Key, layer.Enabled); } } private async Task ApplyMapMessageOptions() { MapMessageTimeoutSeconds = Math.Clamp(MapMessageTimeoutSeconds, 0, 30); SaveFlagPreferences(); await JS.InvokeVoidAsync("lmeMap.setInfoTimeout", MapMessageTimeoutSeconds); } private async Task ApplyAdvancedOptions() { SaveFlagPreferences(); await JS.InvokeVoidAsync("lmeMap.setAdvancedOptions", new { autoSaveEnabled = AutoSaveEnabled, snapEnabled = SnapEnabled, autoFitSelectedObject = AutoFitSelectedObject, snapDistancePixels = SnapDistancePixels, gpsProximityEnabled = GpsProximityEnabled, gpsBoundaryAlertEnabled = GpsBoundaryAlertEnabled }); } private static bool GetFlagPreference(string name, bool defaultValue) => Preferences.Default.Get($"{FlagPreferencePrefix}{name}", defaultValue); private static void SetFlagPreference(string name, bool value) => Preferences.Default.Set($"{FlagPreferencePrefix}{name}", value); private async Task ApplySelectedParcelsVisibility() { SaveFlagPreferences(); await JS.InvokeVoidAsync("lmeMap.setSelectedParcelsVisible", ShowSelectedParcels); } private async Task ApplyImportedDataVisibility() { SaveFlagPreferences(); await JS.InvokeVoidAsync("lmeMap.setImportedLayerVisible", ShowImportedData); } private async Task ExportCadastralImage() { ActiveTab = "map"; LayerPanelOpen = false; await Task.Delay(30); await JS.InvokeVoidAsync("lmeMap.resize"); await JS.InvokeVoidAsync("lmeMap.exportCadastreView"); } private Task ClearSelectedParcels() => JS.InvokeVoidAsync("lmeMap.clearSelectedParcels").AsTask(); private Task ClearMapElements() => JS.InvokeVoidAsync("lmeMap.clearMapElements").AsTask(); private Task OpenOfficialGeoportal() => Microsoft.Maui.ApplicationModel.Launcher.Default.OpenAsync( new Uri("https://geoportale.cartografia.agenziaentrate.gov.it/")); 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; public string Name { get; } = name; public bool Enabled { get; set; } = enabled; public string Detail { get; } = detail; } private sealed class ProjectDisplayOptions { public string BaseLayer { get; init; } = "satellite"; public double CadastreOpacity { get; init; } = 0.75; public string[] CadastreLayers { get; init; } = []; public bool IncludeKmlSheetLayer { get; init; } public bool ImportedVisible { get; init; } = true; public bool ImportedSheetsVisible { get; init; } public bool ShowImportedMeasurements { get; init; } public bool ShowDrawingMeasurements { get; init; } = true; public bool DrawToolsVisible { get; init; } public bool GpsPositionVisible { get; init; } = true; public bool SelectedParcelsVisible { get; init; } = true; public bool TrailEnabled { get; init; } } private sealed class OfflineDownloadResult { public int Saved { get; init; } public int Requested { get; init; } public int StartZoom { get; init; } public int EndZoom { get; init; } } private sealed class TrailStats { public int Points { get; init; } public double Length { get; init; } public int DurationSeconds { get; init; } public bool Recording { get; init; } } public ValueTask DisposeAsync() { IncomingFiles.FileAvailable -= OnIncomingFileAvailable; CancelCatalogWork(); LocationService.StopTracking(); GpsLookupCancellation?.Cancel(); GpsLookupCancellation?.Dispose(); ProjectAutoSaveCancellation?.Cancel(); ProjectAutoSaveCancellation?.Dispose(); MapCallbackReference?.Dispose(); return ValueTask.CompletedTask; } }