From 16e34b614f76c27fbbb878c7780fa8b340fa0c34 Mon Sep 17 00:00:00 2001 From: stopflock Date: Sat, 14 Mar 2026 17:18:33 -0500 Subject: [PATCH] Rework profile FOVs - 360 checkbox --- assets/changelog.json | 7 ++ lib/app_state.dart | 5 ++ lib/dev_config.dart | 3 + lib/localizations/de.json | 2 + lib/localizations/en.json | 2 + lib/localizations/es.json | 2 + lib/localizations/fr.json | 2 + lib/localizations/it.json | 2 + lib/localizations/nl.json | 2 + lib/localizations/pl.json | 2 + lib/localizations/pt.json | 2 + lib/localizations/tr.json | 2 + lib/localizations/uk.json | 2 + lib/localizations/zh.json | 2 + lib/migrations.dart | 74 ++++++++++++++++ lib/screens/profile_editor.dart | 105 +++++++++++++++++------ lib/services/changelog_service.dart | 12 +++ lib/services/profile_import_service.dart | 9 +- lib/state/profile_state.dart | 22 +++++ pubspec.yaml | 2 +- 20 files changed, 232 insertions(+), 29 deletions(-) diff --git a/assets/changelog.json b/assets/changelog.json index a7cdf28..6100c2a 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,4 +1,11 @@ { + "2.10.0": { + "content": [ + "• Simplified profile field-of-view settings - profiles now use a simple '360 FOV' checkbox instead of custom degree values", + "• Existing profiles with non-360° FOV settings have been automatically updated to use default cone angles", + "• All existing functionality for reading and displaying surveillance devices with custom FOV ranges is preserved" + ] + }, "2.9.2": { "content": [ "• Moved 'About OpenStreetMap' section from OSM Account page to Settings > About for better organization", diff --git a/lib/app_state.dart b/lib/app_state.dart index 9991cb6..2688946 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -414,6 +414,11 @@ class AppState extends ChangeNotifier { void deleteProfile(NodeProfile p) { _profileState.deleteProfile(p); } + + /// Reload all profiles from storage (useful after migrations modify stored profiles) + Future reloadProfiles() async { + await _profileState.reloadFromStorage(); + } // Callback when a profile is deleted - clear any stale session references void _onProfileDeleted(NodeProfile deletedProfile) { diff --git a/lib/dev_config.dart b/lib/dev_config.dart index d05f6c9..903b92a 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -82,6 +82,9 @@ const bool kEnableNodeEdits = true; // Set to false to temporarily disable node // Node extraction features - set to false to hide extract functionality for constrained nodes const bool kEnableNodeExtraction = false; // Set to true to enable extract from way/relation feature (WIP) +// Profile FOV features - set to false to restrict profiles to 360° FOV only +const bool kEnableNon360FOVs = false; // Set to true to allow custom FOV values in profiles + /// Navigation availability: only dev builds, and only when online bool enableNavigationFeatures({required bool offlineMode}) { return kEnableNavigationFeatures && !offlineMode; diff --git a/lib/localizations/de.json b/lib/localizations/de.json index e31b349..2c8e745 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -324,6 +324,8 @@ "fovHint": "Sichtfeld in Grad (leer lassen für Standard)", "fovSubtitle": "Kamera-Sichtfeld - verwendet für Kegelbreite und Bereichsübertragungsformat", "fovInvalid": "Sichtfeld muss zwischen 1 und 360 Grad liegen", + "fov360": "360° Sichtfeld", + "fov360Subtitle": "Kamera hat 360-Grad omnidirektionales Sichtfeld", "submittable": "Übertragbar", "submittableSubtitle": "Ob dieses Profil für Kamera-Übertragungen verwendet werden kann", "osmTags": "OSM-Tags", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 3534972..0c27a46 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -361,6 +361,8 @@ "fovHint": "FOV in degrees (leave empty for default)", "fovSubtitle": "Camera field of view - used for cone width and range submission format", "fovInvalid": "FOV must be between 1 and 360 degrees", + "fov360": "360 FOV", + "fov360Subtitle": "Camera has 360-degree omnidirectional field of view", "submittable": "Submittable", "submittableSubtitle": "Whether this profile can be used for camera submissions", "osmTags": "OSM Tags", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index ef83cc5..cd2f80a 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -361,6 +361,8 @@ "fovHint": "Campo de visión en grados (dejar vacío para el predeterminado)", "fovSubtitle": "Campo de visión de la cámara - usado para el ancho del cono y formato de envío por rango", "fovInvalid": "El campo de visión debe estar entre 1 y 360 grados", + "fov360": "Campo de Visión 360°", + "fov360Subtitle": "La cámara tiene un campo de visión omnidireccional de 360 grados", "submittable": "Envíable", "submittableSubtitle": "Si este perfil puede usarse para envíos de cámaras", "osmTags": "Etiquetas OSM", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index fabfb7a..18d4ba1 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -361,6 +361,8 @@ "fovHint": "Champ de vision en degrés (laisser vide pour la valeur par défaut)", "fovSubtitle": "Champ de vision de la caméra - utilisé pour la largeur du cône et le format de soumission par plage", "fovInvalid": "Le champ de vision doit être entre 1 et 360 degrés", + "fov360": "Champ de Vision 360°", + "fov360Subtitle": "La caméra a un champ de vision omnidirectionnel de 360 degrés", "submittable": "Soumissible", "submittableSubtitle": "Si ce profil peut être utilisé pour les soumissions de caméras", "osmTags": "Balises OSM", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 066f3ec..4c573d9 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -361,6 +361,8 @@ "fovHint": "Campo visivo in gradi (lasciare vuoto per il valore predefinito)", "fovSubtitle": "Campo visivo della telecamera - utilizzato per la larghezza del cono e il formato di invio per intervallo", "fovInvalid": "Il campo visivo deve essere tra 1 e 360 gradi", + "fov360": "Campo Visivo 360°", + "fov360Subtitle": "La telecamera ha un campo visivo omnidirezionale di 360 gradi", "submittable": "Inviabile", "submittableSubtitle": "Se questo profilo può essere usato per invii di telecamere", "osmTags": "Tag OSM", diff --git a/lib/localizations/nl.json b/lib/localizations/nl.json index b619204..40037a0 100644 --- a/lib/localizations/nl.json +++ b/lib/localizations/nl.json @@ -361,6 +361,8 @@ "fovHint": "FOV in graden (laat leeg voor standaard)", "fovSubtitle": "Camera gezichtsveld - gebruikt voor kegel breedte en bereik inzending formaat", "fovInvalid": "FOV moet tussen 1 en 360 graden zijn", + "fov360": "360° Gezichtsveld", + "fov360Subtitle": "Camera heeft een 360-graden omnidirectioneel gezichtsveld", "submittable": "Indienbaar", "submittableSubtitle": "Of dit profiel gebruikt kan worden voor camera inzendingen", "osmTags": "OSM Tags", diff --git a/lib/localizations/pl.json b/lib/localizations/pl.json index b97750c..5d7a2c1 100644 --- a/lib/localizations/pl.json +++ b/lib/localizations/pl.json @@ -361,6 +361,8 @@ "fovHint": "FOV w stopniach (zostaw puste dla domyślnego)", "fovSubtitle": "Pole widzenia kamery - używane dla szerokości stożka i formatu zgłaszania zasięgu", "fovInvalid": "FOV musi być między 1 a 360 stopniami", + "fov360": "Pole Widzenia 360°", + "fov360Subtitle": "Kamera ma wielokierunkowe pole widzenia o zasięgu 360 stopni", "submittable": "Możliwy do Zgłoszenia", "submittableSubtitle": "Czy ten profil może być używany do zgłoszeń kamer", "osmTags": "Tagi OSM", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index fe71712..e135485 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -361,6 +361,8 @@ "fovHint": "Campo de visão em graus (deixar vazio para o padrão)", "fovSubtitle": "Campo de visão da câmera - usado para largura do cone e formato de envio por intervalo", "fovInvalid": "Campo de visão deve estar entre 1 e 360 graus", + "fov360": "Campo de Visão 360°", + "fov360Subtitle": "A câmera tem um campo de visão omnidirecional de 360 graus", "submittable": "Enviável", "submittableSubtitle": "Se este perfil pode ser usado para envios de câmeras", "osmTags": "Tags OSM", diff --git a/lib/localizations/tr.json b/lib/localizations/tr.json index 11ce32a..491a484 100644 --- a/lib/localizations/tr.json +++ b/lib/localizations/tr.json @@ -361,6 +361,8 @@ "fovHint": "FOV derece cinsinden (varsayılan için boş bırakın)", "fovSubtitle": "Kamera görüş alanı - koni genişliği ve aralık gönderim formatı için kullanılır", "fovInvalid": "FOV 1 ile 360 derece arasında olmalıdır", + "fov360": "360° Görüş Alanı", + "fov360Subtitle": "Kamera 360 derece çok yönlü görüş alanına sahiptir", "submittable": "Gönderilebilir", "submittableSubtitle": "Bu profilin kamera gönderimlerinde kullanılıp kullanılamayacağı", "osmTags": "OSM Etiketleri", diff --git a/lib/localizations/uk.json b/lib/localizations/uk.json index 322ee58..e86696b 100644 --- a/lib/localizations/uk.json +++ b/lib/localizations/uk.json @@ -361,6 +361,8 @@ "fovHint": "FOV в градусах (залиште порожнім для значення за замовчуванням)", "fovSubtitle": "Поле зору камери - використовується для ширини конуса та формату подання діапазону", "fovInvalid": "FOV повинно бути між 1 і 360 градусами", + "fov360": "Поле Зору 360°", + "fov360Subtitle": "Камера має всенаправлене поле зору на 360 градусів", "submittable": "Можна Подавати", "submittableSubtitle": "Чи можна використовувати цей профіль для подань камер", "osmTags": "OSM Теги", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index add697b..a62a858 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -361,6 +361,8 @@ "fovHint": "视场角度数(留空使用默认值)", "fovSubtitle": "摄像头视场角 - 用于锥体宽度和范围提交格式", "fovInvalid": "视场角必须在1到360度之间", + "fov360": "360°视场角", + "fov360Subtitle": "摄像头具有360度全方向视场角", "submittable": "可提交", "submittableSubtitle": "此配置文件是否可用于摄像头提交", "osmTags": "OSM 标签", diff --git a/lib/migrations.dart b/lib/migrations.dart index b74a52b..00c2068 100644 --- a/lib/migrations.dart +++ b/lib/migrations.dart @@ -6,6 +6,7 @@ import 'app_state.dart'; import 'services/profile_service.dart'; import 'services/suspected_location_cache.dart'; import 'widgets/nuclear_reset_dialog.dart'; +import 'dev_config.dart'; /// One-time migrations that run when users upgrade to specific versions. /// Each migration function is named after the version where it should run. @@ -142,6 +143,77 @@ class OneTimeMigrations { } } + /// Clear non-360 FOV values from all profiles (v2.10.0) + static Future migrate_2_10_0(AppState appState) async { + debugPrint('[Migration] 2.10.0 STARTED: Clearing non-360 FOV values from profiles'); + + try { + // Check dev config flag + debugPrint('[Migration] 2.10.0: kEnableNon360FOVs = $kEnableNon360FOVs'); + + // Only perform this migration if non-360 FOVs are disabled + if (kEnableNon360FOVs) { + debugPrint('[Migration] 2.10.0: Non-360 FOVs enabled, skipping FOV cleanup'); + return; + } + + // Load all profiles from storage + debugPrint('[Migration] 2.10.0: Loading profiles from storage...'); + final profiles = await ProfileService().load(); + debugPrint('[Migration] 2.10.0: Loaded ${profiles.length} profiles'); + + bool anyProfileChanged = false; + int profilesWithFOV = 0; + int profilesCleared = 0; + + // Log all profiles and their FOV values for debugging + for (final profile in profiles) { + final fovStr = profile.fov?.toString() ?? 'null'; + debugPrint('[Migration] 2.10.0: Profile "${profile.name}" (${profile.id}) has FOV: $fovStr'); + if (profile.fov != null) profilesWithFOV++; + } + + // Clear non-360 FOV values from all profiles + final updatedProfiles = profiles.map((profile) { + if (profile.fov != null) { + // Use approximation to handle floating point precision issues + final fovValue = profile.fov!; + final is360 = (fovValue - 360.0).abs() < 0.01; // Within 0.01 degrees of 360 + + if (!is360) { + debugPrint('[Migration] 2.10.0: CLEARING FOV ${profile.fov} from profile: ${profile.name} (${profile.id})'); + anyProfileChanged = true; + profilesCleared++; + return profile.copyWith(fov: null); + } else { + debugPrint('[Migration] 2.10.0: Keeping FOV ${profile.fov} (360-degree) for profile: ${profile.name} (${profile.id})'); + } + } + return profile; + }).toList(); + + // Save updated profiles back to storage if any changes were made + if (anyProfileChanged) { + debugPrint('[Migration] 2.10.0: Saving ${updatedProfiles.length} profiles back to storage (${profilesCleared} profiles modified)...'); + await ProfileService().save(updatedProfiles); + debugPrint('[Migration] 2.10.0: Updated profiles saved to storage successfully'); + + // Reload profiles in AppState to reflect the changes immediately + debugPrint('[Migration] 2.10.0: Reloading profiles in AppState...'); + await appState.reloadProfiles(); + debugPrint('[Migration] 2.10.0: Profiles reloaded in AppState'); + } else { + debugPrint('[Migration] 2.10.0: No profiles with non-360 FOV found (${profilesWithFOV} profiles had FOV values, none needed clearing)'); + } + + debugPrint('[Migration] 2.10.0 COMPLETED: cleared non-360 FOV values from profiles'); + } catch (e, stackTrace) { + debugPrint('[Migration] 2.10.0 ERROR: Failed to clear non-360 FOV values: $e'); + debugPrint('[Migration] 2.10.0 ERROR: Stack trace: $stackTrace'); + // Don't rethrow - this is non-critical, FOV restrictions will still apply going forward + } + } + /// Get the migration function for a specific version static Future Function(AppState)? getMigrationForVersion(String version) { switch (version) { @@ -157,6 +229,8 @@ class OneTimeMigrations { return migrate_2_1_0; case '2.7.3': return migrate_2_7_3; + case '2.10.0': + return migrate_2_10_0; default: return null; } diff --git a/lib/screens/profile_editor.dart b/lib/screens/profile_editor.dart index 08a6bf5..a7d32c5 100644 --- a/lib/screens/profile_editor.dart +++ b/lib/screens/profile_editor.dart @@ -6,6 +6,7 @@ import '../models/node_profile.dart'; import '../app_state.dart'; import '../services/localization_service.dart'; import '../widgets/nsi_tag_value_field.dart'; +import '../dev_config.dart'; class ProfileEditor extends StatefulWidget { const ProfileEditor({super.key, required this.profile}); @@ -22,6 +23,7 @@ class _ProfileEditorState extends State { late bool _requiresDirection; late bool _submittable; late TextEditingController _fovCtrl; + late bool _is360Fov; static const _defaultTags = [ MapEntry('man_made', 'surveillance'), @@ -41,6 +43,7 @@ class _ProfileEditorState extends State { _requiresDirection = widget.profile.requiresDirection; _submittable = widget.profile.submittable; _fovCtrl = TextEditingController(text: widget.profile.fov?.toString() ?? ''); + _is360Fov = widget.profile.fov == 360.0; if (widget.profile.tags.isEmpty) { // New profile → start with sensible defaults @@ -94,28 +97,68 @@ class _ProfileEditorState extends State { ), const SizedBox(height: 16), if (widget.profile.editable) ...[ - CheckboxListTile( - title: Text(locService.t('profileEditor.requiresDirection')), - subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')), - value: _requiresDirection, - onChanged: (value) => setState(() => _requiresDirection = value ?? true), - controlAffinity: ListTileControlAffinity.leading, - ), - if (_requiresDirection) Padding( - padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), - child: TextField( - controller: _fovCtrl, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: locService.t('profileEditor.fov'), - hintText: locService.t('profileEditor.fovHint'), - helperText: locService.t('profileEditor.fovSubtitle'), - errorText: _validateFov(), - suffixText: '°', - ), - onChanged: (value) => setState(() {}), // Trigger validation + // Direction and FOV configuration - show different UI based on dev config + if (kEnableNon360FOVs) ...[ + // Old UI: direction required + optional custom FOV text field (development mode) + CheckboxListTile( + title: Text(locService.t('profileEditor.requiresDirection')), + subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')), + value: _requiresDirection, + onChanged: (value) => setState(() => _requiresDirection = value ?? true), + controlAffinity: ListTileControlAffinity.leading, ), - ), + if (_requiresDirection) Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), + child: TextField( + controller: _fovCtrl, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: locService.t('profileEditor.fov'), + hintText: locService.t('profileEditor.fovHint'), + helperText: locService.t('profileEditor.fovSubtitle'), + errorText: _validateFov(), + suffixText: '°', + ), + onChanged: (value) => setState(() {}), // Trigger validation + ), + ), + ] else ...[ + // New UI: mutually exclusive direction vs 360° FOV checkboxes (production mode) + CheckboxListTile( + title: Text(locService.t('profileEditor.requiresDirection')), + subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')), + value: _requiresDirection, + onChanged: widget.profile.editable + ? (value) { + setState(() { + _requiresDirection = value ?? false; + // Make mutually exclusive with 360° FOV + if (_requiresDirection) { + _is360Fov = false; + } + }); + } + : null, + controlAffinity: ListTileControlAffinity.leading, + ), + CheckboxListTile( + title: Text(locService.t('profileEditor.fov360')), + subtitle: Text(locService.t('profileEditor.fov360Subtitle')), + value: _is360Fov, + onChanged: widget.profile.editable + ? (value) { + setState(() { + _is360Fov = value ?? false; + // Make mutually exclusive with direction requirement + if (_is360Fov) { + _requiresDirection = false; + } + }); + } + : null, + controlAffinity: ListTileControlAffinity.leading, + ), + ], CheckboxListTile( title: Text(locService.t('profileEditor.submittable')), subtitle: Text(locService.t('profileEditor.submittableSubtitle')), @@ -199,6 +242,9 @@ class _ProfileEditorState extends State { } String? _validateFov() { + // Only validate when using text field mode + if (!kEnableNon360FOVs) return null; + final text = _fovCtrl.text.trim(); if (text.isEmpty) return null; // Optional field @@ -219,14 +265,21 @@ class _ProfileEditorState extends State { return; } - // Validate FOV if provided - if (_validateFov() != null) { + // Validate FOV if using text field mode + if (kEnableNon360FOVs && _validateFov() != null) { return; // Don't save if FOV validation fails } - // Parse FOV - final fovText = _fovCtrl.text.trim(); - final fov = fovText.isEmpty ? null : double.tryParse(fovText); + // Parse FOV based on dev config mode + double? fov; + if (kEnableNon360FOVs) { + // Old mode: parse from text field + final fovText = _fovCtrl.text.trim(); + fov = fovText.isEmpty ? null : double.tryParse(fovText); + } else { + // New mode: use checkbox state + fov = _is360Fov ? 360.0 : null; + } final tagMap = {}; for (final e in _tags) { diff --git a/lib/services/changelog_service.dart b/lib/services/changelog_service.dart index 0ee3be4..7abfcbc 100644 --- a/lib/services/changelog_service.dart +++ b/lib/services/changelog_service.dart @@ -225,10 +225,22 @@ class ChangelogService { versionsNeedingMigration.add('1.6.3'); } + if (needsMigration(lastSeenVersion, currentVersion, '1.8.0')) { + versionsNeedingMigration.add('1.8.0'); + } + + if (needsMigration(lastSeenVersion, currentVersion, '2.1.0')) { + versionsNeedingMigration.add('2.1.0'); + } + if (needsMigration(lastSeenVersion, currentVersion, '2.7.3')) { versionsNeedingMigration.add('2.7.3'); } + if (needsMigration(lastSeenVersion, currentVersion, '2.10.0')) { + versionsNeedingMigration.add('2.10.0'); + } + // Future versions can be added here // if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) { // versionsNeedingMigration.add('2.0.0'); diff --git a/lib/services/profile_import_service.dart b/lib/services/profile_import_service.dart index 2ecac87..24d1550 100644 --- a/lib/services/profile_import_service.dart +++ b/lib/services/profile_import_service.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:uuid/uuid.dart'; import '../models/node_profile.dart'; +import '../dev_config.dart'; class ProfileImportService { // Maximum size for base64 encoded profile data (approx 50KB decoded) @@ -72,13 +73,17 @@ class ProfileImportService { final requiresDirection = data['requiresDirection'] ?? true; final submittable = data['submittable'] ?? true; - // Parse FOV if provided + // Parse FOV if provided, applying restrictions based on dev config double? fov; if (data['fov'] != null) { if (data['fov'] is num) { final fovValue = (data['fov'] as num).toDouble(); if (fovValue > 0 && fovValue <= 360) { - fov = fovValue; + // If non-360 FOVs are disabled, only allow 360° FOV + if (kEnableNon360FOVs || fovValue == 360.0) { + fov = fovValue; + } + // Otherwise, clear non-360 FOV values (set fov to null) } } } diff --git a/lib/state/profile_state.dart b/lib/state/profile_state.dart index 8b2c5c9..4af049d 100644 --- a/lib/state/profile_state.dart +++ b/lib/state/profile_state.dart @@ -57,6 +57,28 @@ class ProfileState extends ChangeNotifier { _customOrder = prefs.getStringList(_profileOrderPrefsKey) ?? []; } + /// Reload all profiles from storage (useful after migrations modify stored profiles) + Future reloadFromStorage() async { + // Preserve enabled state by ID + final enabledIds = _enabled.map((p) => p.id).toSet(); + + // Clear and reload profiles + _profiles.clear(); + _profiles.addAll(await ProfileService().load()); + + // Restore enabled state for profiles that still exist + _enabled.clear(); + _enabled.addAll(_profiles.where((p) => enabledIds.contains(p.id))); + + // Safety: Always have at least one enabled profile + if (_enabled.isEmpty && _profiles.isNotEmpty) { + final builtIn = _profiles.firstWhere((profile) => profile.builtin, orElse: () => _profiles.first); + _enabled.add(builtIn); + } + + notifyListeners(); + } + void toggleProfile(NodeProfile p, bool e) { if (e) { _enabled.add(p); diff --git a/pubspec.yaml b/pubspec.yaml index 852d5b6..bbc1e0e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: deflockapp description: Map public surveillance infrastructure with OpenStreetMap publish_to: "none" -version: 2.9.2+53 # The thing after the + is the version code, incremented with each release +version: 2.10.0+54 # The thing after the + is the version code, incremented with each release environment: sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+)