Compare commits

..

8 Commits

Author SHA1 Message Date
stopflock
157e72011f devibe changelog 2026-03-23 13:15:26 -05:00
stopflock
bd71f88452 operator profiles - import, reorderable 2026-03-23 12:58:50 -05:00
stopflock
6e2fa3a04c typo 2026-03-23 11:45:13 -05:00
stopflock
843d377b87 Nuclear reset button now appears when kEnableDevelopmentModes is true as well 2026-03-15 14:31:39 -05:00
stopflock
3857023d43 Fix deprecation warning 2026-03-14 18:11:43 -05:00
stopflock
ddf7f543ff Button to trigger nuke reset in dev mode 2026-03-14 18:04:57 -05:00
stopflock
9f72ec9a76 Fix lint warnings, clean up debug logging 2026-03-14 17:31:01 -05:00
stopflock
16e34b614f Rework profile FOVs - 360 checkbox 2026-03-14 17:18:33 -05:00
27 changed files with 730 additions and 94 deletions

11
COMMENT
View File

@@ -1,11 +0,0 @@
---
An alternative approach to addressing this issue could be adjusting the `optionsBuilder` logic to avoid returning any suggestions when the input text field is empty, rather than guarding `onFieldSubmitted`. For instance:
```dart
optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text.isEmpty) return <String>[];
return suggestions.where((s) => s.contains(textEditingValue.text));
}
```
This ensures that the `RawAutocomplete` widget doesn't offer any options to auto-select on submission when the field is cleared, potentially simplifying the implementation and avoiding the need for additional boolean flags (`guardOnSubmitted`). This pattern can be seen in some implementations "in the wild."

View File

@@ -1,4 +1,16 @@
{
"2.10.1": {
"content": [
"• Operator profiles are now reorderable",
"• Support operator profile import via deflockapp:// links",
"• Makes operator profile UI consistent with device profiles"
]
},
"2.10.0": {
"content": [
"• Simplified profile FOVs; there is now only a checkbox for '360'",
]
},
"2.9.2": {
"content": [
"• Moved 'About OpenStreetMap' section from OSM Account page to Settings > About for better organization",

View File

@@ -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<void> reloadProfiles() async {
await _profileState.reloadFromStorage();
}
// Callback when a profile is deleted - clear any stale session references
void _onProfileDeleted(NodeProfile deletedProfile) {
@@ -437,6 +442,10 @@ class AppState extends ChangeNotifier {
_operatorProfileState.deleteProfile(p);
}
void reorderOperatorProfiles(int oldIndex, int newIndex) {
_operatorProfileState.reorderProfiles(oldIndex, newIndex);
}
// ---------- Session Methods ----------
void startAddSession() {
_sessionState.startAddSession(enabledProfiles);

View File

@@ -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;

View File

@@ -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",
@@ -524,7 +526,7 @@
"dataSourceDescription": "Versorgungsgenehmigungsdaten, die auf potenzielle Installationsstandorte für Überwachungsinfrastruktur hinweisen",
"dataSourceCredit": "Datensammlung und -hosting bereitgestellt von alprwatch.org",
"minimumDistance": "Mindestabstand zu echten Geräten",
"minimumDistanceSubtitle": "Verdächtige Standorte innerhalb von {}m vorhandener Überwachungsgeräte ausblenden",
"minimumDistanceSubtitle": "Verdächtige Standorte innerhalb von {} vorhandener Überwachungsgeräte ausblenden",
"updating": "Verdächtige Standorte werden aktualisiert",
"downloadingAndProcessing": "Daten werden heruntergeladen und verarbeitet...",
"updateSuccess": "Verdächtige Standorte erfolgreich aktualisiert",

View File

@@ -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",
@@ -524,7 +526,7 @@
"dataSourceDescription": "Utility permit data indicating potential surveillance infrastructure installation sites",
"dataSourceCredit": "Data collection and hosting provided by alprwatch.org",
"minimumDistance": "Minimum Distance from Real Nodes",
"minimumDistanceSubtitle": "Hide suspected locations within {}m of existing surveillance devices",
"minimumDistanceSubtitle": "Hide suspected locations within {} of existing surveillance devices",
"updating": "Updating Suspected Locations",
"downloadingAndProcessing": "Downloading and processing data...",
"updateSuccess": "Suspected locations updated successfully",

View File

@@ -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",
@@ -524,7 +526,7 @@
"dataSourceDescription": "Datos de permisos de servicios públicos que indican posibles sitios de instalación de infraestructura de vigilancia",
"dataSourceCredit": "Recopilación y alojamiento de datos proporcionado por alprwatch.org",
"minimumDistance": "Distancia Mínima de Nodos Reales",
"minimumDistanceSubtitle": "Ocultar ubicaciones sospechosas dentro de {}m de dispositivos de vigilancia existentes",
"minimumDistanceSubtitle": "Ocultar ubicaciones sospechosas dentro de {} de dispositivos de vigilancia existentes",
"updating": "Actualizando Ubicaciones Sospechosas",
"downloadingAndProcessing": "Descargando y procesando datos...",
"updateSuccess": "Ubicaciones sospechosas actualizadas exitosamente",

View File

@@ -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",
@@ -524,7 +526,7 @@
"dataSourceDescription": "Données de permis de services publics indiquant des sites d'installation potentiels d'infrastructure de surveillance",
"dataSourceCredit": "Collecte et hébergement des données fournis par alprwatch.org",
"minimumDistance": "Distance Minimale des Nœuds Réels",
"minimumDistanceSubtitle": "Masquer les emplacements suspects dans un rayon de {}m des dispositifs de surveillance existants",
"minimumDistanceSubtitle": "Masquer les emplacements suspects dans un rayon de {} des dispositifs de surveillance existants",
"updating": "Mise à Jour des Emplacements Suspects",
"downloadingAndProcessing": "Téléchargement et traitement des données...",
"updateSuccess": "Emplacements suspects mis à jour avec succès",

View File

@@ -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",
@@ -524,7 +526,7 @@
"dataSourceDescription": "Dati dei permessi dei servizi pubblici che indicano potenziali siti di installazione di infrastrutture di sorveglianza",
"dataSourceCredit": "Raccolta e hosting dei dati forniti da alprwatch.org",
"minimumDistance": "Distanza Minima dai Nodi Reali",
"minimumDistanceSubtitle": "Nascondi posizioni sospette entro {}m dai dispositivi di sorveglianza esistenti",
"minimumDistanceSubtitle": "Nascondi posizioni sospette entro {} dai dispositivi di sorveglianza esistenti",
"updating": "Aggiornamento Posizioni Sospette",
"downloadingAndProcessing": "Scaricamento e elaborazione dati...",
"updateSuccess": "Posizioni sospette aggiornate con successo",

View File

@@ -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",
@@ -524,7 +526,7 @@
"dataSourceDescription": "Nutsbedrijf vergunning data die mogelijke surveillance infrastructuur installatie sites aangeeft",
"dataSourceCredit": "Gegevens verzameling en hosting geleverd door alprwatch.org",
"minimumDistance": "Minimum Afstand van Echte Nodes",
"minimumDistanceSubtitle": "Verberg verdachte locaties binnen {}m van bestaande surveillance apparaten",
"minimumDistanceSubtitle": "Verberg verdachte locaties binnen {} van bestaande surveillance apparaten",
"updating": "Verdachte Locaties Bijwerken",
"downloadingAndProcessing": "Data downloaden en verwerken...",
"updateSuccess": "Verdachte locaties succesvol bijgewerkt",

View File

@@ -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",
@@ -524,7 +526,7 @@
"dataSourceDescription": "Dane pozwoleń użyteczności publicznej wskazujące potencjalne miejsca instalacji infrastruktury nadzoru",
"dataSourceCredit": "Zbieranie danych i hosting zapewnione przez alprwatch.org",
"minimumDistance": "Minimalna Odległość od Rzeczywistych Węzłów",
"minimumDistanceSubtitle": "Ukryj podejrzane lokalizacje w promieniu {}m od istniejących urządzeń nadzoru",
"minimumDistanceSubtitle": "Ukryj podejrzane lokalizacje w promieniu {} od istniejących urządzeń nadzoru",
"updating": "Aktualizowanie Podejrzanych Lokalizacji",
"downloadingAndProcessing": "Pobieranie i przetwarzanie danych...",
"updateSuccess": "Podejrzane lokalizacje zaktualizowane pomyślnie",

View File

@@ -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",
@@ -524,7 +526,7 @@
"dataSourceDescription": "Dados de licenças de serviços públicos indicando possíveis locais de instalação de infraestrutura de vigilância",
"dataSourceCredit": "Coleta e hospedagem de dados fornecidas por alprwatch.org",
"minimumDistance": "Distância Mínima de Nós Reais",
"minimumDistanceSubtitle": "Ocultar localizações suspeitas dentro de {}m de dispositivos de vigilância existentes",
"minimumDistanceSubtitle": "Ocultar localizações suspeitas dentro de {} de dispositivos de vigilância existentes",
"updating": "Atualizando Localizações Suspeitas",
"downloadingAndProcessing": "Baixando e processando dados...",
"updateSuccess": "Localizações suspeitas atualizadas com sucesso",

View File

@@ -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",
@@ -524,7 +526,7 @@
"dataSourceDescription": "Potansiyel gözetleme altyapısı kurulum sitelerini gösteren altyapı izin verileri",
"dataSourceCredit": "Veri toplama ve barındırma alprwatch.org tarafından sağlanır",
"minimumDistance": "Gerçek Düğümlerden Minimum Mesafe",
"minimumDistanceSubtitle": "Mevcut gözetleme cihazlarının {}m yakınındaki şüpheli konumları gizle",
"minimumDistanceSubtitle": "Mevcut gözetleme cihazlarının {} yakınındaki şüpheli konumları gizle",
"updating": "Şüpheli Konumlar Güncelleniyor",
"downloadingAndProcessing": "Veri indiriliyor ve işleniyor...",
"updateSuccess": "Şüpheli konumlar başarıyla güncellendi",

View File

@@ -361,6 +361,8 @@
"fovHint": "FOV в градусах (залиште порожнім для значення за замовчуванням)",
"fovSubtitle": "Поле зору камери - використовується для ширини конуса та формату подання діапазону",
"fovInvalid": "FOV повинно бути між 1 і 360 градусами",
"fov360": "Поле Зору 360°",
"fov360Subtitle": "Камера має всенаправлене поле зору на 360 градусів",
"submittable": "Можна Подавати",
"submittableSubtitle": "Чи можна використовувати цей профіль для подань камер",
"osmTags": "OSM Теги",
@@ -524,7 +526,7 @@
"dataSourceDescription": "Дані дозволів комунальних служб, що вказують на потенційні сайти встановлення інфраструктури спостереження",
"dataSourceCredit": "Збір даних та хостинг надається alprwatch.org",
"minimumDistance": "Мінімальна Відстань від Реальних Вузлів",
"minimumDistanceSubtitle": "Приховати підозрілі локації в межах {}м від існуючих пристроїв спостереження",
"minimumDistanceSubtitle": "Приховати підозрілі локації в межах {} від існуючих пристроїв спостереження",
"updating": "Оновлення Підозрілих Локацій",
"downloadingAndProcessing": "Завантаження та обробка даних...",
"updateSuccess": "Підозрілі локації успішно оновлено",

View File

@@ -361,6 +361,8 @@
"fovHint": "视场角度数(留空使用默认值)",
"fovSubtitle": "摄像头视场角 - 用于锥体宽度和范围提交格式",
"fovInvalid": "视场角必须在1到360度之间",
"fov360": "360°视场角",
"fov360Subtitle": "摄像头具有360度全方向视场角",
"submittable": "可提交",
"submittableSubtitle": "此配置文件是否可用于摄像头提交",
"osmTags": "OSM 标签",
@@ -524,7 +526,7 @@
"dataSourceDescription": "公用事业许可数据,表明潜在的监控基础设施安装站点",
"dataSourceCredit": "数据收集和托管由 alprwatch.org 提供",
"minimumDistance": "与真实节点的最小距离",
"minimumDistanceSubtitle": "隐藏现有监控设备{}范围内的疑似位置",
"minimumDistanceSubtitle": "隐藏现有监控设备{}范围内的疑似位置",
"updating": "正在更新疑似位置",
"downloadingAndProcessing": "正在下载和处理数据...",
"updateSuccess": "疑似位置更新成功",

View File

@@ -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,52 @@ class OneTimeMigrations {
}
}
/// Clear non-360 FOV values from all profiles (v2.10.0)
static Future<void> migrate_2_10_0(AppState appState) async {
try {
// 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
final profiles = await ProfileService().load();
bool anyProfileChanged = false;
int profilesCleared = 0;
// 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 $fovValue from profile: ${profile.name}');
anyProfileChanged = true;
profilesCleared++;
return profile.copyWith(fov: null);
}
}
return profile;
}).toList();
// Save updated profiles back to storage if any changes were made
if (anyProfileChanged) {
await ProfileService().save(updatedProfiles);
await appState.reloadProfiles();
debugPrint('[Migration] 2.10.0: Cleared FOV from $profilesCleared profiles');
}
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<void> Function(AppState)? getMigrationForVersion(String version) {
switch (version) {
@@ -157,6 +204,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;
}

View File

@@ -1,6 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../dev_config.dart';
import '../services/localization_service.dart';
import '../services/nuclear_reset_service.dart';
import '../widgets/welcome_dialog.dart';
import '../widgets/submission_guide_dialog.dart';
@@ -83,6 +86,13 @@ class AboutScreen extends StatelessWidget {
_buildDialogButtons(context),
const SizedBox(height: 24),
_buildHelpLinks(context),
// Dev-only nuclear reset button at very bottom
if (kDebugMode || kEnableDevelopmentModes) ...[
const SizedBox(height: 32),
_buildDevNuclearResetButton(context),
const SizedBox(height: 16),
],
],
),
),
@@ -203,5 +213,231 @@ class AboutScreen extends StatelessWidget {
);
}
/// Dev-only nuclear reset button (only visible in debug mode)
Widget _buildDevNuclearResetButton(BuildContext context) {
return Card(
color: Theme.of(context).colorScheme.errorContainer.withValues(alpha: 0.1),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.developer_mode,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
const SizedBox(width: 8),
Text(
'Developer Tools',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
Text(
'These tools are only available in debug mode for development and troubleshooting.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _showNuclearResetConfirmation(context),
icon: const Icon(Icons.delete_forever, color: Colors.red),
label: const Text(
'Nuclear Reset (Clear All Data)',
style: TextStyle(color: Colors.red),
),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
),
),
),
],
),
),
);
}
/// Show confirmation dialog for nuclear reset
Future<void> _showNuclearResetConfirmation(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning, color: Colors.red),
SizedBox(width: 8),
Text('Nuclear Reset'),
],
),
content: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'This will completely clear ALL app data:',
style: TextStyle(fontWeight: FontWeight.w500),
),
SizedBox(height: 12),
Text('• All settings and preferences'),
Text('• OAuth login credentials'),
Text('• Custom profiles and operators'),
Text('• Upload queue and cached data'),
Text('• Downloaded offline areas'),
Text('• Everything else'),
SizedBox(height: 16),
Text(
'The app will behave exactly like a fresh install after this operation.',
style: TextStyle(
fontWeight: FontWeight.w500,
color: Colors.red,
),
),
SizedBox(height: 12),
Text(
'This action cannot be undone.',
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Nuclear Reset'),
),
],
),
);
if (confirmed == true && context.mounted) {
await _performNuclearReset(context);
}
}
/// Perform the nuclear reset operation
Future<void> _performNuclearReset(BuildContext context) async {
// Show progress dialog
if (!context.mounted) return;
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Clearing all app data...'),
],
),
),
);
try {
// Perform the nuclear reset
await NuclearResetService.clearEverything();
if (!context.mounted) return;
// Close progress dialog
Navigator.of(context).pop();
// Show completion dialog
await showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.check_circle, color: Colors.green),
SizedBox(width: 8),
Text('Reset Complete'),
],
),
content: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'All app data has been cleared successfully.',
style: TextStyle(fontWeight: FontWeight.w500),
),
SizedBox(height: 12),
Text(
'Please close and restart the app to continue with a fresh state.',
style: TextStyle(fontWeight: FontWeight.w500),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
} catch (e) {
if (!context.mounted) return;
// Close progress dialog if it's still open
Navigator.of(context).pop();
// Show error dialog
await showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.error, color: Colors.red),
SizedBox(width: 8),
Text('Reset Failed'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'An error occurred during the nuclear reset:',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 12),
Text(
e.toString(),
style: const TextStyle(fontFamily: 'monospace'),
),
const SizedBox(height: 12),
const Text(
'Some data may have been partially cleared. You may want to manually clear app data through device settings.',
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
}
}
}

View File

@@ -155,8 +155,13 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
final tagMap = <String, String>{};
for (final e in _tags) {
if (e.key.trim().isEmpty) continue; // Skip only if key is empty
tagMap[e.key.trim()] = e.value.trim(); // Allow empty values for refinement
final key = e.key.trim();
final value = e.value.trim();
// Skip if key is empty OR value is empty (no empty values for operator profiles)
if (key.isEmpty || value.isEmpty) continue;
tagMap[key] = value;
}
final newProfile = widget.profile.copyWith(

View File

@@ -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<ProfileEditor> {
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<ProfileEditor> {
_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<ProfileEditor> {
),
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<ProfileEditor> {
}
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<ProfileEditor> {
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 = <String, String>{};
for (final e in _tags) {

View File

@@ -54,47 +54,64 @@ class OperatorProfilesSection extends StatelessWidget {
),
)
else
...appState.operatorProfiles.map(
(p) => ListTile(
title: Text(p.name),
subtitle: Text(locService.t('operatorProfiles.tagsCount', params: [p.tags.length.toString()])),
trailing: PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit),
const SizedBox(width: 8),
Text(locService.t('actions.edit')),
],
),
ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: appState.operatorProfiles.length,
onReorder: (oldIndex, newIndex) {
appState.reorderOperatorProfiles(oldIndex, newIndex);
},
itemBuilder: (context, index) {
final p = appState.operatorProfiles[index];
return ListTile(
key: ValueKey(p.id),
leading: ReorderableDragStartListener(
index: index,
child: const Icon(
Icons.drag_handle,
color: Colors.grey,
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, color: Colors.red),
const SizedBox(width: 8),
Text(locService.t('operatorProfiles.deleteOperatorProfile'), style: const TextStyle(color: Colors.red)),
],
),
),
],
onSelected: (value) {
if (value == 'edit') {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => OperatorProfileEditor(profile: p),
),
title: Text(p.name),
subtitle: Text(locService.t('operatorProfiles.tagsCount', params: [p.tags.length.toString()])),
trailing: PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit),
const SizedBox(width: 8),
Text(locService.t('actions.edit')),
],
),
);
} else if (value == 'delete') {
_showDeleteProfileDialog(context, p);
}
},
),
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, color: Colors.red),
const SizedBox(width: 8),
Text(locService.t('operatorProfiles.deleteOperatorProfile'), style: const TextStyle(color: Colors.red)),
],
),
),
],
onSelected: (value) {
if (value == 'edit') {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => OperatorProfileEditor(profile: p),
),
);
} else if (value == 'delete') {
_showDeleteProfileDialog(context, p);
}
},
),
);
},
),
],
);

View File

@@ -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');

View File

@@ -3,8 +3,11 @@ import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
import '../models/node_profile.dart';
import '../models/operator_profile.dart';
import 'profile_import_service.dart';
import 'operator_profile_import_service.dart';
import '../screens/profile_editor.dart';
import '../screens/operator_profile_editor.dart';
class DeepLinkService {
static final DeepLinkService _instance = DeepLinkService._internal();
@@ -86,8 +89,16 @@ class DeepLinkService {
}
}
/// Handle profile add deep link: `deflockapp://profiles/add?p=<base64>`
/// Handle profile add deep link: `deflockapp://profiles/add?p=<base64>` or `deflockapp://profiles/add?op=<base64>`
void _handleAddProfileLink(Uri uri) {
// Check for operator profile parameter first
final operatorBase64Data = uri.queryParameters['op'];
if (operatorBase64Data != null && operatorBase64Data.isNotEmpty) {
_handleOperatorProfileImport(operatorBase64Data);
return;
}
// Otherwise check for device profile parameter
final base64Data = uri.queryParameters['p'];
if (base64Data == null || base64Data.isEmpty) {
@@ -107,6 +118,20 @@ class DeepLinkService {
_navigateToProfileEditor(profile);
}
/// Handle operator profile import from deep link
void _handleOperatorProfileImport(String base64Data) {
// Parse operator profile from base64
final operatorProfile = OperatorProfileImportService.parseProfileFromBase64(base64Data);
if (operatorProfile == null) {
_showError('Invalid operator profile data');
return;
}
// Navigate to operator profile editor with the imported profile
_navigateToOperatorProfileEditor(operatorProfile);
}
/// Navigate to profile editor with pre-filled profile data
void _navigateToProfileEditor(NodeProfile profile) {
final context = _navigatorKey?.currentContext;
@@ -124,6 +149,23 @@ class DeepLinkService {
);
}
/// Navigate to operator profile editor with pre-filled operator profile data
void _navigateToOperatorProfileEditor(OperatorProfile operatorProfile) {
final context = _navigatorKey?.currentContext;
if (context == null) {
debugPrint('[DeepLinkService] No navigator context available');
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => OperatorProfileEditor(profile: operatorProfile),
),
);
}
/// Show error message to user
void _showError(String message) {
final context = _navigatorKey?.currentContext;

View File

@@ -0,0 +1,100 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import '../models/operator_profile.dart';
class OperatorProfileImportService {
// Maximum size for base64 encoded profile data (approx 50KB decoded)
static const int maxBase64Length = 70000;
/// Parse and validate an operator profile from a base64-encoded JSON string
/// Returns null if parsing/validation fails
static OperatorProfile? parseProfileFromBase64(String base64Data) {
try {
// Basic size validation before expensive decode
if (base64Data.length > maxBase64Length) {
debugPrint('[OperatorProfileImportService] Base64 data too large: ${base64Data.length} characters');
return null;
}
// Decode base64
final jsonBytes = base64Decode(base64Data);
final jsonString = utf8.decode(jsonBytes);
// Parse JSON
final jsonData = jsonDecode(jsonString) as Map<String, dynamic>;
// Validate and sanitize the profile data
final sanitizedProfile = _validateAndSanitizeProfile(jsonData);
return sanitizedProfile;
} catch (e) {
debugPrint('[OperatorProfileImportService] Failed to parse profile from base64: $e');
return null;
}
}
/// Validate operator profile structure and sanitize all string values
static OperatorProfile? _validateAndSanitizeProfile(Map<String, dynamic> data) {
try {
// Extract and sanitize required fields
final name = _sanitizeString(data['name']);
if (name == null || name.isEmpty) {
debugPrint('[OperatorProfileImportService] Operator profile name is required');
return null;
}
// Extract and sanitize tags
final tagsData = data['tags'];
if (tagsData is! Map<String, dynamic>) {
debugPrint('[OperatorProfileImportService] Operator profile tags must be a map');
return null;
}
final sanitizedTags = <String, String>{};
for (final entry in tagsData.entries) {
final key = _sanitizeString(entry.key);
final value = _sanitizeString(entry.value);
if (key != null && key.isNotEmpty) {
// Allow empty values for refinement purposes
sanitizedTags[key] = value ?? '';
}
}
if (sanitizedTags.isEmpty) {
debugPrint('[OperatorProfileImportService] Operator profile must have at least one valid tag');
return null;
}
return OperatorProfile(
id: const Uuid().v4(), // Always generate new ID for imported profiles
name: name,
tags: sanitizedTags,
);
} catch (e) {
debugPrint('[OperatorProfileImportService] Failed to validate operator profile: $e');
return null;
}
}
/// Sanitize a string value by trimming and removing potentially harmful characters
static String? _sanitizeString(dynamic value) {
if (value == null) return null;
final str = value.toString().trim();
// Remove control characters and limit length
final sanitized = str.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '');
// Limit length to prevent abuse
const maxLength = 500;
if (sanitized.length > maxLength) {
return sanitized.substring(0, maxLength);
}
return sanitized;
}
}

View File

@@ -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)
}
}
}

View File

@@ -1,12 +1,16 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/operator_profile.dart';
import '../services/operator_profile_service.dart';
class OperatorProfileState extends ChangeNotifier {
static const String _profileOrderPrefsKey = 'operator_profile_order';
final List<OperatorProfile> _profiles = [];
List<String> _customOrder = []; // List of profile IDs in user's preferred order
List<OperatorProfile> get profiles => List.unmodifiable(_profiles);
List<OperatorProfile> get profiles => List.unmodifiable(_getOrderedProfiles());
Future<void> init({bool addDefaults = false}) async {
_profiles.addAll(await OperatorProfileService().load());
@@ -16,6 +20,10 @@ class OperatorProfileState extends ChangeNotifier {
_profiles.addAll(OperatorProfile.getDefaults());
await OperatorProfileService().save(_profiles);
}
// Load custom order from prefs
final prefs = await SharedPreferences.getInstance();
_customOrder = prefs.getStringList(_profileOrderPrefsKey) ?? [];
}
void addOrUpdateProfile(OperatorProfile p) {
@@ -34,4 +42,56 @@ class OperatorProfileState extends ChangeNotifier {
OperatorProfileService().save(_profiles);
notifyListeners();
}
// Reorder profiles (for drag-and-drop in settings)
void reorderProfiles(int oldIndex, int newIndex) {
final orderedProfiles = _getOrderedProfiles();
// Standard Flutter reordering logic
if (oldIndex < newIndex) {
newIndex -= 1;
}
final item = orderedProfiles.removeAt(oldIndex);
orderedProfiles.insert(newIndex, item);
// Update custom order with new sequence
_customOrder = orderedProfiles.map((p) => p.id).toList();
_saveCustomOrder();
notifyListeners();
}
// Get profiles in custom order, with unordered profiles at the end
List<OperatorProfile> _getOrderedProfiles() {
if (_customOrder.isEmpty) {
return List.from(_profiles);
}
final ordered = <OperatorProfile>[];
final profilesById = {for (final p in _profiles) p.id: p};
// Add profiles in custom order
for (final id in _customOrder) {
final profile = profilesById[id];
if (profile != null) {
ordered.add(profile);
profilesById.remove(id);
}
}
// Add any remaining profiles that weren't in the custom order
ordered.addAll(profilesById.values);
return ordered;
}
// Save custom order to disk
Future<void> _saveCustomOrder() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_profileOrderPrefsKey, _customOrder);
} catch (e) {
// Fail gracefully in tests or if SharedPreferences isn't available
debugPrint('[OperatorProfileState] Failed to save custom order: $e');
}
}
}

View File

@@ -57,6 +57,28 @@ class ProfileState extends ChangeNotifier {
_customOrder = prefs.getStringList(_profileOrderPrefsKey) ?? [];
}
/// Reload all profiles from storage (useful after migrations modify stored profiles)
Future<void> 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);

View File

@@ -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.1+55 # 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+)