Compare commits

..

13 Commits

Author SHA1 Message Date
stopflock
7842848152 welcome note about firsthand knowledge 2025-11-02 16:02:16 -06:00
stopflock
07ced1bc11 add to changelog 2025-11-02 15:39:12 -06:00
stopflock
335eb33613 disable dev mode, temporarily pull 811 from stopflock.com 2025-11-02 15:37:35 -06:00
stopflock
c9a7045212 Accept cardinal type directions in osm data 2025-10-29 12:53:56 -05:00
stopflock
e861d00b68 update docs 2025-10-29 12:35:28 -05:00
stopflock
d9f415c527 Multiple cameras on one pole 2025-10-29 12:17:16 -05:00
stopflock
1993714752 Show all fields from suspected locations CSV in details sheet 2025-10-28 20:45:51 -05:00
stopflock
b80e1094af deletions no longer using stub profile, more handling of builtin profiles by lists. 2025-10-24 18:28:39 -05:00
stopflock
0db4c0f80d all profiles as lists, better handling, stop using a fallback profile for broken submissions without one 2025-10-24 17:31:04 -05:00
stopflock
f1f145a35f Operator profiles as list. Add simon property group. 2025-10-24 17:11:43 -05:00
stopflock
618d31d016 immediately enable suspected locations when commanded 2025-10-24 16:52:14 -05:00
stopflock
c8ae925dc1 Add new builtin profiles, better handline of initialization, bump version 2025-10-24 16:23:09 -05:00
stopflock
2a7004e5a2 require profile selection 2025-10-24 13:49:48 -05:00
31 changed files with 891 additions and 522 deletions

View File

@@ -148,10 +148,29 @@ enum UploadOperation { create, modify, delete }
- **Clear intent**: `operation == UploadOperation.delete` is unambiguous
**Session Pattern:**
- `AddNodeSession`: For creating new nodes
- `EditNodeSession`: For modifying existing nodes
- `AddNodeSession`: For creating new nodes with single or multiple directions
- `EditNodeSession`: For modifying existing nodes, preserving all existing directions
- No "DeleteSession": Deletions are immediate (simpler)
**Multi-Direction Support:**
Sessions use a simple model for handling multiple directions:
```dart
class AddNodeSession {
List<double> directions; // [90, 180, 270] - all directions
int currentDirectionIndex; // Which direction is being edited
// Slider always shows the current direction
double get directionDegrees => directions[currentDirectionIndex];
set directionDegrees(value) => directions[currentDirectionIndex] = value;
}
```
**Direction Interaction:**
- **Add**: New directions start at 0° and are automatically selected for editing
- **Remove**: Current direction removed from list (minimum 1 direction)
- **Cycle**: Switch between existing directions in the list
- **Submit**: All directions combined as semicolon-separated string (e.g., "90;180;270")
**Why no delete session:**
Deletions don't need position dragging or tag editing - they just need confirmation and queuing. A session would add complexity without benefit.
@@ -182,6 +201,11 @@ Users expect instant response to their actions. By immediately updating the cach
- **Orange ring**: Node currently being edited
- **Red ring**: Nodes pending deletion
**Direction cone visual states:**
- **Full opacity**: Active session direction (currently being edited)
- **Reduced opacity (40%)**: Inactive session directions
- **Standard opacity**: Existing node directions (when not in edit mode)
**Cache tags for state tracking:**
```dart
'_pending_upload' // New node waiting to upload
@@ -190,6 +214,17 @@ Users expect instant response to their actions. By immediately updating the cach
'_original_node_id' // For drawing connection lines
```
**Multi-direction parsing:**
The app supports nodes with multiple directions specified as semicolon-separated values:
```dart
// OSM tag: direction="90;180;270"
List<double> get directionDeg {
final raw = tags['direction'] ?? tags['camera:direction'];
// Splits on semicolons, parses each direction, normalizes to 0-359°
return [90.0, 180.0, 270.0]; // Results in multiple FOV cones
}
```
**Why underscore prefix:**
These are internal app tags, not OSM tags. The underscore prefix makes this explicit and prevents accidental upload to OSM.
@@ -270,10 +305,17 @@ Users often want to follow their location while keeping the map oriented north.
**Data pipeline:**
- **CSV ingestion**: Downloads utility permit data from alprwatch.org
- **Dynamic field parsing**: Stores all CSV columns (except `location` and `ticket_no`) for flexible display
- **GeoJSON processing**: Handles Point, Polygon, and MultiPolygon geometries
- **Proximity filtering**: Hides suspected locations near confirmed devices
- **Regional availability**: Currently select locations, expanding regularly
**Display approach:**
- **Required fields**: `ticket_no` (for heading) and `location` (for map positioning)
- **Dynamic display**: All other CSV fields shown automatically, no hardcoded field list
- **Server control**: Field names and content controlled server-side via CSV headers
- **Brutalist rendering**: Fields displayed as-is from CSV, empty fields hidden
**Why utility permits:**
Utility companies often must file permits when installing surveillance infrastructure. This creates a paper trail that can indicate potential surveillance sites before devices are confirmed through direct observation.

View File

@@ -30,11 +30,12 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
### Device Management
- **Comprehensive profiles**: Built-in profiles for major manufacturers (Flock Safety, Motorola/Vigilant, Genetec, Leonardo/ELSAG, Neology) plus custom profile creation
- **Full CRUD operations**: Create, edit, and delete surveillance devices
- **Direction visualization**: Interactive field-of-view cones showing camera viewing angles
- **Multi-direction support**: Devices can have multiple viewing directions (e.g. "90;180") with individual field-of-view cones
- **Direction visualization**: Interactive field-of-view cones showing camera viewing angles with opacity-based selection
- **Bulk operations**: Tag multiple devices efficiently with profile-based workflow
### Surveillance Intelligence
- **Suspected locations**: Display potential surveillance sites from utility permit data (select locations, more added regularly)
- **Suspected locations**: Display potential surveillance sites from utility permit data with dynamic field display (select locations, more added regularly)
- **Proximity alerts**: Get notified when approaching mapped surveillance devices, with configurable distance and background notifications
- **Location search**: Find addresses and points of interest to aid in mapping missions
@@ -57,7 +58,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
1. **Install** the app on iOS or Android - a welcome popup will guide you through key information
2. **Enable location** permissions
3. **Log into OpenStreetMap**: Choose upload mode and get OAuth2 credentials
4. **Add your first device**: Tap the "New Node" button, position the pin, set direction, select a profile, and tap submit
4. **Add your first device**: Tap the "New Node" button, position the pin, set direction(s), select a profile, and tap submit
5. **Edit or delete devices**: Tap any device marker to view details, then use Edit or Delete buttons
**New to OpenStreetMap?** Visit [deflock.me](https://deflock.me) for complete setup instructions and community guidelines.
@@ -89,14 +90,16 @@ cp lib/keys.dart.example lib/keys.dart
- Are offline areas preferred for fast loading even when online? Check working.
- Fix network indicator - only done when fetch queue is empty!
### Recently Completed
- **Multi-direction support**: Devices can now have multiple viewing directions (e.g., "90;180") with individual FOV cones
- **Dynamic suspected location fields**: Server-controlled field display for suspected locations data
### Current Development
- Import/Export map providers
- Swap in alprwatch.org/directions avoidance routing API
- Help button with links to email, discord, and website
- Clean cache when nodes have disappeared / been deleted by others / queue item was deleted
- Improve offline area node refresh live display
- Add default operator profiles (Lowes etc)
- Add Rekor, generic PTZ profiles
- Add Rekor profile
### Future Features & Wishlist
- Update offline area nodes while browsing?
@@ -104,21 +107,12 @@ cp lib/keys.dart.example lib/keys.dart
- Suspected locations expansion to more regions
### Maybes
- Yellow ring for devices missing specific tag details?
- "Cache accumulating" offline area?
- "Offline areas" as tile provider?
- Maybe we could grab the full latest database for each profile just like for suspected locations? (Instead of overpass)
- Optional custom icons for camera profiles?
- Upgrade device marker design? (considering nullplate's svg)
- Custom device providers and OSM/Overpass alternatives?
- More map data providers:
https://gis.sanramon.ca.gov/arcgis_js_api/sdk/jsapi/esri.basemaps-amd.html#osm
https://www.icgc.cat/en/Geoinformation-and-Maps/Base-Map-Service
https://github.com/CartoDB/basemap-styles
https://forum.inductiveautomation.com/t/perspective-map-theming-internet-tile-server-options/40164
https://github.com/roblabs/xyz-raster-sources
https://github.com/geopandas/xyzservices/blob/main/provider_sources/xyzservices-providers.json
https://medium.com/@go2garret/free-basemap-tiles-for-maplibre-18374fab60cb
- Yellow ring for devices missing specific tag details
- "Cache accumulating" offline area
- "Offline areas" as tile provider
- Grab the full latest database for each profile just like for suspected locations (instead of overpass)
- Optional custom icons for profiles to aid identification
- Custom device providers and OSM/Overpass alternatives
---

View File

@@ -1,7 +1,10 @@
{
"1.2.8": {
"content": "• UX: Profile selection is now a required step to prevent accidental submission of default profile.\n• NEW: Note in welcome message about not submitting data you cannot vouch for personally (no street view etc)\n• NEW: Added default operator profiles for the most common private operators nationwide (Lowe's, Home Depot, et al)\n• NEW: Support for cardinal directions in OSM data, multiple directions on a node."
},
"1.2.7": {
"content": "• NEW: Compass indicator shows map orientation; tap to spin north-up\n• Smart area caching: Loads 3x larger areas and refreshes data every 60 seconds for much faster browsing\n• Enhanced tile loading: Increased retry attempts with faster delays - tiles load much more reliably\n• Better network status: Simplified loading indicator logic\n• Instant node display: Surveillance devices now appear immediately when data finishes loading\n• Node limit alerts: Get notified when some nodes are not drawn"
},
},
"1.2.4": {
"content": "• New welcome popup for first-time users with essential privacy information\n• Automatic changelog display when app updates (like this one!)\n• Added Release Notes viewer in Settings > About\n• Enhanced user onboarding and transparency about data handling\n• Improved documentation for contributors"
},
@@ -11,9 +14,6 @@
"1.2.2": {
"content": "• New surveillance device profiles added\n• Improved tile loading performance\n• Fixed issue with GPS accuracy\n• Updated translations"
},
"1.2.1": {
"content": ""
},
"1.2.0": {
"content": "• Major UI improvements\n• Added proximity alerts\n• Enhanced offline capabilities\n• New suspected locations feature"
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'models/node_profile.dart';
import 'models/operator_profile.dart';
@@ -13,6 +14,8 @@ import 'services/offline_area_service.dart';
import 'services/node_cache.dart';
import 'services/tile_preview_service.dart';
import 'services/changelog_service.dart';
import 'services/operator_profile_service.dart';
import 'services/profile_service.dart';
import 'widgets/camera_provider_with_cache.dart';
import 'state/auth_state.dart';
import 'state/navigation_state.dart';
@@ -168,8 +171,26 @@ class AppState extends ChangeNotifier {
// Attempt to fetch missing tile type preview tiles (fails silently)
_fetchMissingTilePreviews();
await _operatorProfileState.init();
await _profileState.init();
// Check if we should add default profiles (first launch OR no profiles of each type exist)
final prefs = await SharedPreferences.getInstance();
const firstLaunchKey = 'profiles_defaults_initialized';
final isFirstLaunch = !(prefs.getBool(firstLaunchKey) ?? false);
// Load existing profiles to check each type independently
final existingOperatorProfiles = await OperatorProfileService().load();
final existingNodeProfiles = await ProfileService().load();
final shouldAddOperatorDefaults = isFirstLaunch || existingOperatorProfiles.isEmpty;
final shouldAddNodeDefaults = isFirstLaunch || existingNodeProfiles.isEmpty;
await _operatorProfileState.init(addDefaults: shouldAddOperatorDefaults);
await _profileState.init(addDefaults: shouldAddNodeDefaults);
// Mark defaults as initialized if this was first launch
if (isFirstLaunch) {
await prefs.setBool(firstLaunchKey, true);
}
await _suspectedLocationState.init(offlineMode: _settingsState.offlineMode);
await _uploadQueueState.init();
await _authState.init(_settingsState.uploadMode);
@@ -264,6 +285,20 @@ class AppState extends ChangeNotifier {
);
}
void addDirection() {
_sessionState.addDirection();
}
void removeDirection() {
_sessionState.removeDirection();
}
void cycleDirection() {
_sessionState.cycleDirection();
}
void cancelSession() {
_sessionState.cancelSession();
}

View File

@@ -41,7 +41,7 @@ const String kClientName = 'DeFlock';
// Note: Version is now dynamically retrieved from VersionService
// Suspected locations CSV URL
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/pub/flock_utilities_mini_latest.csv';
const String kSuspectedLocationsCsvUrl = 'https://stopflock.com/app/flock_utilities_mini_latest.csv';
// Development/testing features - set to false for production builds
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode

View File

@@ -72,6 +72,8 @@
},
"addNode": {
"profile": "Profil",
"selectProfile": "Profil auswählen...",
"profileRequired": "Bitte wählen Sie ein Profil aus, um fortzufahren.",
"direction": "Richtung {}°",
"profileNoDirectionInfo": "Dieses Profil benötigt keine Richtung.",
"mustBeLoggedIn": "Sie müssen angemeldet sein, um neue Knoten zu übertragen. Bitte melden Sie sich über die Einstellungen an.",
@@ -83,6 +85,8 @@
"editNode": {
"title": "Knoten #{} Bearbeiten",
"profile": "Profil",
"selectProfile": "Profil auswählen...",
"profileRequired": "Bitte wählen Sie ein Profil aus, um fortzufahren.",
"direction": "Richtung {}°",
"profileNoDirectionInfo": "Dieses Profil benötigt keine Richtung.",
"mustBeLoggedIn": "Sie müssen angemeldet sein, um Knoten zu bearbeiten. Bitte melden Sie sich über die Einstellungen an.",
@@ -315,6 +319,7 @@
"title": "Willkommen bei DeFlock",
"description": "DeFlock wurde auf der Idee gegründet, dass öffentliche Überwachungsinstrumente transparent sein sollten. In dieser mobilen App, wie auch auf der Website, können Sie die Standorte von ALPRs und anderer Überwachungsinfrastruktur in Ihrer Umgebung und weltweit einsehen.",
"mission": "Dieses Projekt ist jedoch nicht automatisiert; es braucht uns alle, um dieses Projekt zu verbessern. Bei der Kartenansicht können Sie auf \"Neuer Knoten\" tippen, um eine bisher unbekannte Installation hinzuzufügen. Mit Ihrer Hilfe können wir unser Ziel erreichen: mehr Transparenz und öffentliches Bewusstsein für Überwachungsinfrastruktur.",
"firsthandKnowledge": "WICHTIG: Tragen Sie nur Überwachungsgeräte bei, die Sie persönlich aus erster Hand beobachtet haben. OpenStreetMap- und Google-Richtlinien verbieten die Nutzung von Quellen wie Street View-Bildern für Beiträge. Ihre Beiträge sollten auf Ihren eigenen direkten, persönlichen Beobachtungen basieren.",
"privacy": "Datenschutzhinweis: Diese App läuft vollständig lokal auf Ihrem Gerät und nutzt die OpenStreetMap-API von Drittanbietern nur für Datenspeicherung und Übermittlungen. DeFlock sammelt oder speichert keinerlei Nutzerdaten und ist nicht für die Kontoverwaltung verantwortlich.",
"tileNote": "HINWEIS: Die kostenlosen Kartenkacheln von OpenStreetMap können sehr langsam laden. Alternative Kartenanbieter können unter Einstellungen > Erweitert konfiguriert werden.",
"moreInfo": "Weitere Links finden Sie unter Einstellungen > Über.",

View File

@@ -16,6 +16,7 @@
"title": "Welcome to DeFlock",
"description": "DeFlock was founded on the idea that public surveillance tools should be transparent. Within this mobile app, as on the website, you will be able to view the location of ALPRs and other surveillance infrastructure in your local area and abroad.",
"mission": "However, this project isn't automated; it takes all of us to make this project better. When viewing the map, you can tap \"New Node\" to add a previously unknown installation. With your help, we can achieve our goal of increased transparency and public awareness of surveillance infrastructure.",
"firsthandKnowledge": "IMPORTANT: Only contribute surveillance devices that you have personally observed firsthand. OpenStreetMap and Google policies prohibit using sources like Street View imagery for submissions. Your contributions should be based on your own direct observations.",
"privacy": "Privacy Note: This app runs entirely locally on your device and uses the third-party OpenStreetMap API for data storage and submissions. DeFlock does not collect or store any user data of any kind, and is not responsible for account management.",
"tileNote": "NOTE: The free map tiles from OpenStreetMap can be very slow to load. Alternate tile providers can be configured in Settings > Advanced.",
"moreInfo": "You can find more links under Settings > About.",
@@ -89,6 +90,8 @@
},
"addNode": {
"profile": "Profile",
"selectProfile": "Select a profile...",
"profileRequired": "Please select a profile to continue.",
"direction": "Direction {}°",
"profileNoDirectionInfo": "This profile does not require a direction.",
"mustBeLoggedIn": "You must be logged in to submit new nodes. Please log in via Settings.",
@@ -100,6 +103,8 @@
"editNode": {
"title": "Edit Node #{}",
"profile": "Profile",
"selectProfile": "Select a profile...",
"profileRequired": "Please select a profile to continue.",
"direction": "Direction {}°",
"profileNoDirectionInfo": "This profile does not require a direction.",
"mustBeLoggedIn": "You must be logged in to edit nodes. Please log in via Settings.",

View File

@@ -16,6 +16,7 @@
"title": "Bienvenido a DeFlock",
"description": "DeFlock fue fundado sobre la idea de que las herramientas de vigilancia pública deben ser transparentes. Dentro de esta aplicación móvil, como en el sitio web, podrás ver la ubicación de ALPRs y otra infraestructura de vigilancia en tu área local y en el extranjero.",
"mission": "Sin embargo, este proyecto no es automatizado; todos nosotros somos necesarios para mejorarlo. Al ver el mapa, puedes tocar \"Nuevo Nodo\" para agregar una instalación previamente desconocida. Con tu ayuda, podemos lograr nuestro objetivo de mayor transparencia y conciencia pública sobre la infraestructura de vigilancia.",
"firsthandKnowledge": "IMPORTANTE: Solo contribuye con dispositivos de vigilancia que hayas observado personalmente de primera mano. Las políticas de OpenStreetMap y Google prohíben el uso de fuentes como imágenes de Street View para las contribuciones. Tus contribuciones deben basarse en tus propias observaciones directas y en persona.",
"privacy": "Nota de Privacidad: Esta aplicación funciona completamente de forma local en tu dispositivo y utiliza la API de terceros de OpenStreetMap solo para almacenamiento y envío de datos. DeFlock no recopila ni almacena ningún tipo de datos de usuario, y no es responsable de la gestión de cuentas.",
"tileNote": "NOTA: Los mosaicos gratuitos de mapa de OpenStreetMap pueden tardar mucho en cargar. Se pueden configurar proveedores alternativos de mosaicos en Configuración > Avanzado.",
"moreInfo": "Puedes encontrar más enlaces en Configuración > Acerca de.",
@@ -89,6 +90,8 @@
},
"addNode": {
"profile": "Perfil",
"selectProfile": "Seleccionar un perfil...",
"profileRequired": "Por favor, seleccione un perfil para continuar.",
"direction": "Dirección {}°",
"profileNoDirectionInfo": "Este perfil no requiere una dirección.",
"mustBeLoggedIn": "Debe estar conectado para enviar nuevos nodos. Por favor, inicie sesión a través de Configuración.",
@@ -100,6 +103,8 @@
"editNode": {
"title": "Editar Nodo #{}",
"profile": "Perfil",
"selectProfile": "Seleccionar un perfil...",
"profileRequired": "Por favor, seleccione un perfil para continuar.",
"direction": "Dirección {}°",
"profileNoDirectionInfo": "Este perfil no requiere una dirección.",
"mustBeLoggedIn": "Debe estar conectado para editar nodos. Por favor, inicie sesión a través de Configuración.",

View File

@@ -16,6 +16,7 @@
"title": "Bienvenue dans DeFlock",
"description": "DeFlock a été fondé sur l'idée que les outils de surveillance publique devraient être transparents. Dans cette application mobile, comme sur le site web, vous pourrez voir l'emplacement des ALPRs et autres infrastructures de surveillance dans votre région et à l'étranger.",
"mission": "Cependant, ce projet n'est pas automatisé ; il nous faut tous pour améliorer ce projet. En visualisant la carte, vous pouvez appuyer sur \"Nouveau Nœud\" pour ajouter une installation précédemment inconnue. Avec votre aide, nous pouvons atteindre notre objectif d'augmenter la transparence et la sensibilisation du public à l'infrastructure de surveillance.",
"firsthandKnowledge": "IMPORTANT : Ne contribuez qu'aux dispositifs de surveillance que vous avez personnellement observés de première main. Les politiques d'OpenStreetMap et de Google interdisent l'utilisation de sources comme les images Street View pour les contributions. Vos contributions doivent être basées sur vos propres observations directes et en personne.",
"privacy": "Note de Confidentialité : Cette application fonctionne entièrement localement sur votre appareil et utilise l'API tierce OpenStreetMap uniquement pour le stockage et la soumission de données. DeFlock ne collecte ni ne stocke aucune donnée utilisateur de quelque nature que ce soit, et n'est pas responsable de la gestion des comptes.",
"tileNote": "NOTE : Les tuiles de carte gratuites d'OpenStreetMap peuvent être très lentes à charger. Des fournisseurs de tuiles alternatifs peuvent être configurés dans Paramètres > Avancé.",
"moreInfo": "Vous pouvez trouver plus de liens sous Paramètres > À propos.",
@@ -89,6 +90,8 @@
},
"addNode": {
"profile": "Profil",
"selectProfile": "Sélectionner un profil...",
"profileRequired": "Veuillez sélectionner un profil pour continuer.",
"direction": "Direction {}°",
"profileNoDirectionInfo": "Ce profil ne nécessite pas de direction.",
"mustBeLoggedIn": "Vous devez être connecté pour soumettre de nouveaux nœuds. Veuillez vous connecter via les Paramètres.",
@@ -100,6 +103,8 @@
"editNode": {
"title": "Modifier Nœud #{}",
"profile": "Profil",
"selectProfile": "Sélectionner un profil...",
"profileRequired": "Veuillez sélectionner un profil pour continuer.",
"direction": "Direction {}°",
"profileNoDirectionInfo": "Ce profil ne nécessite pas de direction.",
"mustBeLoggedIn": "Vous devez être connecté pour modifier les nœuds. Veuillez vous connecter via les Paramètres.",

View File

@@ -16,6 +16,7 @@
"title": "Benvenuto in DeFlock",
"description": "DeFlock è stato fondato sull'idea che gli strumenti di sorveglianza pubblica dovrebbero essere trasparenti. All'interno di questa app mobile, come sul sito web, sarai in grado di visualizzare la posizione di ALPR e altre infrastrutture di sorveglianza nella tua zona locale e all'estero.",
"mission": "Tuttavia, questo progetto non è automatizzato; servono tutti noi per migliorare questo progetto. Durante la visualizzazione della mappa, puoi toccare \"Nuovo Nodo\" per aggiungere un'installazione precedentemente sconosciuta. Con il tuo aiuto, possiamo raggiungere il nostro obiettivo di maggiore trasparenza e consapevolezza pubblica dell'infrastruttura di sorveglianza.",
"firsthandKnowledge": "IMPORTANTE: Contribuisci solo con dispositivi di sorveglianza che hai osservato personalmente di prima mano. Le politiche di OpenStreetMap e Google vietano l'uso di fonti come le immagini di Street View per i contributi. I tuoi contributi dovrebbero essere basati sulle tue osservazioni dirette e di persona.",
"privacy": "Nota sulla Privacy: Questa app funziona interamente localmente sul tuo dispositivo e utilizza l'API di terze parti OpenStreetMap solo per l'archiviazione e l'invio dei dati. DeFlock non raccoglie né memorizza alcun tipo di dati utente e non è responsabile della gestione degli account.",
"tileNote": "NOTA: Le tessere mappa gratuite di OpenStreetMap possono essere molto lente a caricare. Fornitori di tessere alternativi possono essere configurati in Impostazioni > Avanzate.",
"moreInfo": "Puoi trovare altri collegamenti in Impostazioni > Informazioni.",
@@ -89,6 +90,8 @@
},
"addNode": {
"profile": "Profilo",
"selectProfile": "Seleziona un profilo...",
"profileRequired": "Per favore seleziona un profilo per continuare.",
"direction": "Direzione {}°",
"profileNoDirectionInfo": "Questo profilo non richiede una direzione.",
"mustBeLoggedIn": "Devi essere loggato per inviare nuovi nodi. Per favore accedi tramite Impostazioni.",
@@ -100,6 +103,8 @@
"editNode": {
"title": "Modifica Nodo #{}",
"profile": "Profilo",
"selectProfile": "Seleziona un profilo...",
"profileRequired": "Per favore seleziona un profilo per continuare.",
"direction": "Direzione {}°",
"profileNoDirectionInfo": "Questo profilo non richiede una direzione.",
"mustBeLoggedIn": "Devi essere loggato per modificare i nodi. Per favore accedi tramite Impostazioni.",

View File

@@ -16,6 +16,7 @@
"title": "Bem-vindo ao DeFlock",
"description": "DeFlock foi fundado na ideia de que ferramentas de vigilância pública devem ser transparentes. Dentro deste aplicativo móvel, como no site, você poderá ver a localização de ALPRs e outras infraestruturas de vigilância em sua área local e no exterior.",
"mission": "No entanto, este projeto não é automatizado; precisamos de todos nós para tornar este projeto melhor. Ao visualizar o mapa, você pode tocar em \"Novo Nó\" para adicionar uma instalação anteriormente desconhecida. Com sua ajuda, podemos alcançar nosso objetivo de maior transparência e conscientização pública sobre infraestrutura de vigilância.",
"firsthandKnowledge": "IMPORTANTE: Contribua apenas com dispositivos de vigilância que você observou pessoalmente em primeira mão. As políticas do OpenStreetMap e Google proíbem o uso de fontes como imagens do Street View para contribuições. Suas contribuições devem ser baseadas em suas próprias observações diretas e presenciais.",
"privacy": "Nota de Privacidade: Este aplicativo funciona inteiramente localmente em seu dispositivo e usa a API de terceiros OpenStreetMap apenas para armazenamento e envio de dados. DeFlock não coleta nem armazena qualquer tipo de dados do usuário e não é responsável pelo gerenciamento de contas.",
"tileNote": "NOTA: Os tiles gratuitos de mapa do OpenStreetMap podem ser muito lentos para carregar. Provedores alternativos de tiles podem ser configurados em Configurações > Avançado.",
"moreInfo": "Você pode encontrar mais links em Configurações > Sobre.",
@@ -89,6 +90,8 @@
},
"addNode": {
"profile": "Perfil",
"selectProfile": "Selecionar um perfil...",
"profileRequired": "Por favor, selecione um perfil para continuar.",
"direction": "Direção {}°",
"profileNoDirectionInfo": "Este perfil não requer uma direção.",
"mustBeLoggedIn": "Você deve estar logado para enviar novos nós. Por favor, faça login via Configurações.",
@@ -100,6 +103,8 @@
"editNode": {
"title": "Editar Nó #{}",
"profile": "Perfil",
"selectProfile": "Selecionar um perfil...",
"profileRequired": "Por favor, selecione um perfil para continuar.",
"direction": "Direção {}°",
"profileNoDirectionInfo": "Este perfil não requer uma direção.",
"mustBeLoggedIn": "Você deve estar logado para editar nós. Por favor, faça login via Configurações.",

View File

@@ -16,6 +16,7 @@
"title": "欢迎使用 DeFlock",
"description": "DeFlock 的创立基于公共监控工具应该透明的理念。在这个移动应用程序中,就像在网站上一样,您将能够查看您当地和国外的车牌识别系统和其他监控基础设施的位置。",
"mission": "然而,这个项目不是自动化的;需要我们所有人来改善这个项目。在查看地图时,您可以点击\"新建节点\"来添加一个之前未知的装置。在您的帮助下,我们可以实现增强监控基础设施透明度和公众意识的目标。",
"firsthandKnowledge": "重要提醒只贡献您亲自第一手观察到的监控设备。OpenStreetMap 和 Google 的政策禁止使用街景图像等来源进行贡献。您的贡献应该基于您自己的直接、亲身观察。",
"privacy": "隐私说明:此应用程序完全在您的设备上本地运行,仅使用第三方 OpenStreetMap API 进行数据存储和提交。DeFlock 不收集或存储任何类型的用户数据,也不负责账户管理。",
"tileNote": "注意:来自 OpenStreetMap 的免费地图图块可能加载很慢。可以在设置 > 高级中配置替代图块提供商。",
"moreInfo": "您可以在设置 > 关于中找到更多链接。",
@@ -89,6 +90,8 @@
},
"addNode": {
"profile": "配置文件",
"selectProfile": "选择配置文件...",
"profileRequired": "请选择配置文件以继续。",
"direction": "方向 {}°",
"profileNoDirectionInfo": "此配置文件不需要方向。",
"mustBeLoggedIn": "您必须登录才能提交新节点。请通过设置登录。",
@@ -100,6 +103,8 @@
"editNode": {
"title": "编辑节点 #{}",
"profile": "配置文件",
"selectProfile": "选择配置文件...",
"profileRequired": "请选择配置文件以继续。",
"direction": "方向 {}°",
"profileNoDirectionInfo": "此配置文件不需要方向。",
"mustBeLoggedIn": "您必须登录才能编辑节点。请通过设置登录。",

View File

@@ -20,161 +20,149 @@ class NodeProfile {
this.editable = true,
});
/// Builtin default: Generic ALPR camera (customizable template, not submittable)
factory NodeProfile.genericAlpr() => NodeProfile(
id: 'builtin-generic-alpr',
name: 'Generic ALPR',
tags: const {
'man_made': 'surveillance',
'surveillance:type': 'ALPR',
},
builtin: true,
requiresDirection: true,
submittable: false,
editable: false,
);
/// Get all built-in default node profiles
static List<NodeProfile> getDefaults() => [
NodeProfile(
id: 'builtin-generic-alpr',
name: 'Generic ALPR',
tags: const {
'man_made': 'surveillance',
'surveillance:type': 'ALPR',
},
builtin: true,
requiresDirection: true,
submittable: false,
editable: false,
),
NodeProfile(
id: 'builtin-flock',
name: 'Flock',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Flock Safety',
'manufacturer:wikidata': 'Q108485435',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
),
NodeProfile(
id: 'builtin-motorola',
name: 'Motorola/Vigilant',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Motorola Solutions',
'manufacturer:wikidata': 'Q634815',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
),
NodeProfile(
id: 'builtin-genetec',
name: 'Genetec',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Genetec',
'manufacturer:wikidata': 'Q30295174',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
),
NodeProfile(
id: 'builtin-leonardo',
name: 'Leonardo/ELSAG',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Leonardo',
'manufacturer:wikidata': 'Q910379',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
),
NodeProfile(
id: 'builtin-neology',
name: 'Neology',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Neology, Inc.',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
),
NodeProfile(
id: 'builtin-generic-gunshot',
name: 'Generic Gunshot Detector',
tags: const {
'man_made': 'surveillance',
'surveillance:type': 'gunshot_detector',
},
builtin: true,
requiresDirection: false,
submittable: false,
editable: false,
),
NodeProfile(
id: 'builtin-shotspotter',
name: 'ShotSpotter',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'gunshot_detector',
'surveillance:brand': 'ShotSpotter',
'surveillance:brand:wikidata': 'Q107740188',
},
builtin: true,
requiresDirection: false,
submittable: true,
editable: true,
),
NodeProfile(
id: 'builtin-flock-raven',
name: 'Flock Raven',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'gunshot_detector',
'brand': 'Flock Safety',
'brand:wikidata': 'Q108485435',
},
builtin: true,
requiresDirection: false,
submittable: true,
editable: true,
),
];
/// Builtin: Flock Safety ALPR camera
factory NodeProfile.flock() => NodeProfile(
id: 'builtin-flock',
name: 'Flock',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Flock Safety',
'manufacturer:wikidata': 'Q108485435',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
);
/// Builtin: Motorola Solutions/Vigilant ALPR camera
factory NodeProfile.motorola() => NodeProfile(
id: 'builtin-motorola',
name: 'Motorola/Vigilant',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Motorola Solutions',
'manufacturer:wikidata': 'Q634815',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
);
/// Builtin: Genetec ALPR camera
factory NodeProfile.genetec() => NodeProfile(
id: 'builtin-genetec',
name: 'Genetec',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Genetec',
'manufacturer:wikidata': 'Q30295174',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
);
/// Builtin: Leonardo/ELSAG ALPR camera
factory NodeProfile.leonardo() => NodeProfile(
id: 'builtin-leonardo',
name: 'Leonardo/ELSAG',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Leonardo',
'manufacturer:wikidata': 'Q910379',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
);
/// Builtin: Neology ALPR camera
factory NodeProfile.neology() => NodeProfile(
id: 'builtin-neology',
name: 'Neology',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Neology, Inc.',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
);
/// Builtin: Generic gunshot detector (customizable template, not submittable)
factory NodeProfile.genericGunshotDetector() => NodeProfile(
id: 'builtin-generic-gunshot',
name: 'Generic Gunshot Detector',
tags: const {
'man_made': 'surveillance',
'surveillance:type': 'gunshot_detector',
},
builtin: true,
requiresDirection: false,
submittable: false,
editable: false,
);
/// Builtin: ShotSpotter gunshot detector
factory NodeProfile.shotspotter() => NodeProfile(
id: 'builtin-shotspotter',
name: 'ShotSpotter',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'gunshot_detector',
'surveillance:brand': 'ShotSpotter',
'surveillance:brand:wikidata': 'Q107740188',
},
builtin: true,
requiresDirection: false,
submittable: true,
editable: true,
);
/// Builtin: Flock Raven gunshot detector
factory NodeProfile.flockRaven() => NodeProfile(
id: 'builtin-flock-raven',
name: 'Flock Raven',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'gunshot_detector',
'brand': 'Flock Safety',
'brand:wikidata': 'Q108485435',
},
builtin: true,
requiresDirection: false,
submittable: true,
editable: true,
);
/// Returns true if this profile can be used for submissions
bool get isSubmittable => submittable;

View File

@@ -13,6 +13,37 @@ class OperatorProfile {
required this.tags,
});
/// Get all built-in default operator profiles
static List<OperatorProfile> getDefaults() => [
OperatorProfile(
id: 'builtin-lowes',
name: "Lowe's",
tags: const {
'operator': "Lowe's",
'operator:wikidata': 'Q1373493',
'operator:type': 'private',
},
),
OperatorProfile(
id: 'builtin-home-depot',
name: 'The Home Depot',
tags: const {
'operator': 'The Home Depot',
'operator:wikidata': 'Q864407',
'operator:type': 'private',
},
),
OperatorProfile(
id: 'builtin-simon-property-group',
name: 'Simon Property Group',
tags: const {
'operator': 'Simon Property Group',
'operator:wikidata': 'Q2287759',
'operator:type': 'private',
},
),
];
OperatorProfile copyWith({
String? id,
String? name,

View File

@@ -32,23 +32,47 @@ class OsmNode {
);
}
bool get hasDirection =>
tags.containsKey('direction') || tags.containsKey('camera:direction');
bool get hasDirection => directionDeg.isNotEmpty;
double? get directionDeg {
List<double> get directionDeg {
final raw = tags['direction'] ?? tags['camera:direction'];
if (raw == null) return null;
if (raw == null) return [];
// Keep digits, optional dot, optional leading sign.
final match = RegExp(r'[-+]?\d*\.?\d+').firstMatch(raw);
if (match == null) return null;
// Compass direction to degree mapping
const compassDirections = {
'N': 0.0, 'NNE': 22.5, 'NE': 45.0, 'ENE': 67.5,
'E': 90.0, 'ESE': 112.5, 'SE': 135.0, 'SSE': 157.5,
'S': 180.0, 'SSW': 202.5, 'SW': 225.0, 'WSW': 247.5,
'W': 270.0, 'WNW': 292.5, 'NW': 315.0, 'NNW': 337.5,
};
final numStr = match.group(0);
final val = double.tryParse(numStr ?? '');
if (val == null) return null;
// Split on semicolons and parse each direction
final directions = <double>[];
final parts = raw.split(';');
for (final part in parts) {
final trimmed = part.trim().toUpperCase();
if (trimmed.isEmpty) continue;
// First try compass direction lookup
if (compassDirections.containsKey(trimmed)) {
directions.add(compassDirections[trimmed]!);
continue;
}
// Then try numeric parsing
final match = RegExp(r'[-+]?\d*\.?\d+').firstMatch(trimmed);
if (match == null) continue;
// Normalize: wrap negative or >360 into 0359 range.
final normalized = ((val % 360) + 360) % 360;
return normalized;
final numStr = match.group(0);
final val = double.tryParse(numStr ?? '');
if (val == null) continue;
// Normalize: wrap negative or >360 into 0359 range
final normalized = ((val % 360) + 360) % 360;
directions.add(normalized);
}
return directions;
}
}

View File

@@ -7,8 +7,8 @@ enum UploadOperation { create, modify, delete }
class PendingUpload {
final LatLng coord;
final double direction;
final NodeProfile profile;
final dynamic direction; // Can be double or String for multiple directions
final NodeProfile? profile;
final OperatorProfile? operatorProfile;
final UploadMode uploadMode; // Capture upload destination when queued
final UploadOperation operation; // Type of operation: create, modify, or delete
@@ -21,7 +21,7 @@ class PendingUpload {
PendingUpload({
required this.coord,
required this.direction,
required this.profile,
this.profile,
this.operatorProfile,
required this.uploadMode,
required this.operation,
@@ -34,6 +34,10 @@ class PendingUpload {
(operation == UploadOperation.create && originalNodeId == null) ||
(operation != UploadOperation.create && originalNodeId != null),
'originalNodeId must be null for create operations and non-null for modify/delete operations'
),
assert(
(operation == UploadOperation.delete) || (profile != null),
'profile is required for create and modify operations'
);
// True if this is an edit of an existing node, false if it's a new node
@@ -56,7 +60,12 @@ class PendingUpload {
// Get combined tags from node profile and operator profile
Map<String, String> getCombinedTags() {
final tags = Map<String, String>.from(profile.tags);
// Deletions don't need tags
if (operation == UploadOperation.delete || profile == null) {
return {};
}
final tags = Map<String, String>.from(profile!.tags);
// Add operator profile tags (they override node profile tags if there are conflicts)
if (operatorProfile != null) {
@@ -64,8 +73,14 @@ class PendingUpload {
}
// Add direction if required
if (profile.requiresDirection) {
tags['direction'] = direction.toStringAsFixed(0);
if (profile!.requiresDirection) {
if (direction is String) {
tags['direction'] = direction;
} else if (direction is double) {
tags['direction'] = direction.toStringAsFixed(0);
} else {
tags['direction'] = '0';
}
}
return tags;
@@ -75,7 +90,7 @@ class PendingUpload {
'lat': coord.latitude,
'lon': coord.longitude,
'dir': direction,
'profile': profile.toJson(),
'profile': profile?.toJson(),
'operatorProfile': operatorProfile?.toJson(),
'uploadMode': uploadMode.index,
'operation': operation.index,
@@ -91,7 +106,7 @@ class PendingUpload {
direction: j['dir'],
profile: j['profile'] is Map<String, dynamic>
? NodeProfile.fromJson(j['profile'])
: NodeProfile.genericAlpr(),
: null, // Profile is optional for deletions
operatorProfile: j['operatorProfile'] != null
? OperatorProfile.fromJson(j['operatorProfile'])
: null,

View File

@@ -4,36 +4,24 @@ import 'package:latlong2/latlong.dart';
/// A suspected surveillance location from the CSV data
class SuspectedLocation {
final String ticketNo;
final String? urlFull;
final String? addr;
final String? street;
final String? city;
final String? state;
final String? digSiteIntersectingStreet;
final String? digWorkDoneFor;
final String? digSiteRemarks;
final Map<String, dynamic>? geoJson;
final LatLng centroid;
final List<LatLng> bounds;
final Map<String, dynamic>? geoJson;
final Map<String, dynamic> allFields; // All CSV fields except location and ticket_no
SuspectedLocation({
required this.ticketNo,
this.urlFull,
this.addr,
this.street,
this.city,
this.state,
this.digSiteIntersectingStreet,
this.digWorkDoneFor,
this.digSiteRemarks,
this.geoJson,
required this.centroid,
required this.bounds,
this.geoJson,
required this.allFields,
});
/// Create from CSV row data
factory SuspectedLocation.fromCsvRow(Map<String, dynamic> row) {
final locationString = row['location'] as String?;
final ticketNo = row['ticket_no']?.toString() ?? '';
LatLng centroid = const LatLng(0, 0);
List<LatLng> bounds = [];
Map<String, dynamic>? geoJson;
@@ -47,24 +35,22 @@ class SuspectedLocation {
bounds = coordinates.bounds;
} catch (e) {
// If GeoJSON parsing fails, use default coordinates
print('[SuspectedLocation] Failed to parse GeoJSON for ticket ${row['ticket_no']}: $e');
print('[SuspectedLocation] Failed to parse GeoJSON for ticket $ticketNo: $e');
print('[SuspectedLocation] Location string: $locationString');
}
}
// Store all fields except location and ticket_no
final allFields = Map<String, dynamic>.from(row);
allFields.remove('location');
allFields.remove('ticket_no');
return SuspectedLocation(
ticketNo: row['ticket_no']?.toString() ?? '',
urlFull: row['url_full']?.toString(),
addr: row['addr']?.toString(),
street: row['street']?.toString(),
city: row['city']?.toString(),
state: row['state']?.toString(),
digSiteIntersectingStreet: row['dig_site_intersecting_street']?.toString(),
digWorkDoneFor: row['dig_work_done_for']?.toString(),
digSiteRemarks: row['dig_site_remarks']?.toString(),
geoJson: geoJson,
ticketNo: ticketNo,
centroid: centroid,
bounds: bounds,
geoJson: geoJson,
allFields: allFields,
);
}
@@ -149,18 +135,11 @@ class SuspectedLocation {
/// Convert to JSON for storage
Map<String, dynamic> toJson() => {
'ticket_no': ticketNo,
'url_full': urlFull,
'addr': addr,
'street': street,
'city': city,
'state': state,
'dig_site_intersecting_street': digSiteIntersectingStreet,
'dig_work_done_for': digWorkDoneFor,
'dig_site_remarks': digSiteRemarks,
'geo_json': geoJson,
'centroid_lat': centroid.latitude,
'centroid_lng': centroid.longitude,
'bounds': bounds.map((p) => [p.latitude, p.longitude]).toList(),
'all_fields': allFields,
};
/// Create from stored JSON
@@ -173,26 +152,24 @@ class SuspectedLocation {
return SuspectedLocation(
ticketNo: json['ticket_no'] ?? '',
urlFull: json['url_full'],
addr: json['addr'],
street: json['street'],
city: json['city'],
state: json['state'],
digSiteIntersectingStreet: json['dig_site_intersecting_street'],
digWorkDoneFor: json['dig_work_done_for'],
digSiteRemarks: json['dig_site_remarks'],
geoJson: json['geo_json'],
centroid: LatLng(
(json['centroid_lat'] as num).toDouble(),
(json['centroid_lng'] as num).toDouble(),
),
bounds: bounds,
allFields: Map<String, dynamic>.from(json['all_fields'] ?? {}),
);
}
/// Get a formatted display address
String get displayAddress {
final parts = <String>[];
final addr = allFields['addr']?.toString();
final street = allFields['street']?.toString();
final city = allFields['city']?.toString();
final state = allFields['state']?.toString();
if (addr?.isNotEmpty == true) parts.add(addr!);
if (street?.isNotEmpty == true) parts.add(street!);
if (city?.isNotEmpty == true) parts.add(city!);

View File

@@ -20,7 +20,6 @@ class ProfileService {
// MUST convert to List before jsonEncode; the previous MappedIterable
// caused "Converting object to an encodable object failed".
final encodable = profiles
.where((p) => !p.builtin)
.map((p) => p.toJson())
.toList(); // <- crucial

View File

@@ -55,9 +55,9 @@ class SuspectedLocationService {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyEnabled, enabled);
// If enabling for the first time and no data, fetch it
// If enabling for the first time and no data, fetch it in background
if (enabled && !_cache.hasData) {
await _fetchData();
_fetchData(); // Don't await - let it run in background so UI updates immediately
}
// If disabling, clear the cache
@@ -138,16 +138,8 @@ class SuspectedLocationService {
final dataRows = csvData.skip(1);
debugPrint('[SuspectedLocationService] Data rows count: ${dataRows.length}');
// Find required column indices
// Find required column indices - we only need ticket_no and location
final ticketNoIndex = headers.indexOf('ticket_no');
final urlFullIndex = headers.indexOf('url_full');
final addrIndex = headers.indexOf('addr');
final streetIndex = headers.indexOf('street');
final cityIndex = headers.indexOf('city');
final stateIndex = headers.indexOf('state');
final digSiteIntersectingStreetIndex = headers.indexOf('dig_site_intersecting_street');
final digWorkDoneForIndex = headers.indexOf('dig_work_done_for');
final digSiteRemarksIndex = headers.indexOf('dig_site_remarks');
final locationIndex = headers.indexOf('location');
debugPrint('[SuspectedLocationService] Column indices - ticket_no: $ticketNoIndex, location: $locationIndex');
@@ -157,7 +149,7 @@ class SuspectedLocationService {
return false;
}
// Parse rows and store as raw data (don't process GeoJSON yet)
// Parse rows and store all data dynamically
final List<Map<String, dynamic>> rawDataList = [];
int rowIndex = 0;
int validRows = 0;
@@ -166,22 +158,14 @@ class SuspectedLocationService {
try {
final Map<String, dynamic> rowData = {};
if (ticketNoIndex < row.length) rowData['ticket_no'] = row[ticketNoIndex];
if (urlFullIndex != -1 && urlFullIndex < row.length) rowData['url_full'] = row[urlFullIndex];
if (addrIndex != -1 && addrIndex < row.length) rowData['addr'] = row[addrIndex];
if (streetIndex != -1 && streetIndex < row.length) rowData['street'] = row[streetIndex];
if (cityIndex != -1 && cityIndex < row.length) rowData['city'] = row[cityIndex];
if (stateIndex != -1 && stateIndex < row.length) rowData['state'] = row[stateIndex];
if (digSiteIntersectingStreetIndex != -1 && digSiteIntersectingStreetIndex < row.length) {
rowData['dig_site_intersecting_street'] = row[digSiteIntersectingStreetIndex];
// Store all columns dynamically
for (int i = 0; i < headers.length && i < row.length; i++) {
final headerName = headers[i];
final cellValue = row[i];
if (cellValue != null) {
rowData[headerName] = cellValue;
}
}
if (digWorkDoneForIndex != -1 && digWorkDoneForIndex < row.length) {
rowData['dig_work_done_for'] = row[digWorkDoneForIndex];
}
if (digSiteRemarksIndex != -1 && digSiteRemarksIndex < row.length) {
rowData['dig_site_remarks'] = row[digSiteRemarksIndex];
}
if (locationIndex < row.length) rowData['location'] = row[locationIndex];
// Basic validation - must have ticket_no and location
if (rowData['ticket_no']?.toString().isNotEmpty == true &&

View File

@@ -17,6 +17,12 @@ class Uploader {
try {
print('Uploader: Starting upload for node at ${p.coord.latitude}, ${p.coord.longitude}');
// Safety check: create and modify operations MUST have profiles
if ((p.operation == UploadOperation.create || p.operation == UploadOperation.modify) && p.profile == null) {
print('Uploader: ERROR - ${p.operation.name} operation attempted without profile data');
return false;
}
// 1. open changeset
String action;
switch (p.operation) {
@@ -30,11 +36,13 @@ class Uploader {
action = 'Delete';
break;
}
// Generate appropriate comment based on operation type
final profileName = p.profile?.name ?? 'surveillance';
final csXml = '''
<osm>
<changeset>
<tag k="created_by" v="$kClientName ${VersionService().version}"/>
<tag k="comment" v="$action ${p.profile.name} surveillance node"/>
<tag k="comment" v="$action $profileName surveillance node"/>
</changeset>
</osm>''';
print('Uploader: Creating changeset...');

View File

@@ -8,8 +8,14 @@ class OperatorProfileState extends ChangeNotifier {
List<OperatorProfile> get profiles => List.unmodifiable(_profiles);
Future<void> init() async {
Future<void> init({bool addDefaults = false}) async {
_profiles.addAll(await OperatorProfileService().load());
// Add default operator profiles if this is first launch
if (addDefaults) {
_profiles.addAll(OperatorProfile.getDefaults());
await OperatorProfileService().save(_profiles);
}
}
void addOrUpdateProfile(OperatorProfile p) {

View File

@@ -17,19 +17,16 @@ class ProfileState extends ChangeNotifier {
_profiles.where(isEnabled).toList(growable: false);
// Initialize profiles from built-in and custom sources
Future<void> init() async {
// Initialize profiles: built-in + custom
_profiles.add(NodeProfile.genericAlpr());
_profiles.add(NodeProfile.flock());
_profiles.add(NodeProfile.motorola());
_profiles.add(NodeProfile.genetec());
_profiles.add(NodeProfile.leonardo());
_profiles.add(NodeProfile.neology());
_profiles.add(NodeProfile.genericGunshotDetector());
_profiles.add(NodeProfile.shotspotter());
_profiles.add(NodeProfile.flockRaven());
Future<void> init({bool addDefaults = false}) async {
// Load custom profiles from storage
_profiles.addAll(await ProfileService().load());
// Add built-in profiles if this is first launch
if (addDefaults) {
_profiles.addAll(NodeProfile.getDefaults());
await ProfileService().save(_profiles);
}
// Load enabled profile IDs from prefs
final prefs = await SharedPreferences.getInstance();
final enabledIds = prefs.getStringList(_enabledPrefsKey);

View File

@@ -7,27 +7,45 @@ import '../models/osm_node.dart';
// ------------------ AddNodeSession ------------------
class AddNodeSession {
AddNodeSession({required this.profile, this.directionDegrees = 0});
NodeProfile profile;
NodeProfile? profile;
OperatorProfile? operatorProfile;
double directionDegrees;
LatLng? target;
List<double> directions; // All directions [90, 180, 270]
int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°)
AddNodeSession({
this.profile,
double initialDirection = 0,
this.operatorProfile,
this.target,
}) : directions = [initialDirection],
currentDirectionIndex = 0;
// Slider always shows the current direction being edited
double get directionDegrees => directions[currentDirectionIndex];
set directionDegrees(double value) => directions[currentDirectionIndex] = value;
}
// ------------------ EditNodeSession ------------------
class EditNodeSession {
final OsmNode originalNode; // The original node being edited
NodeProfile? profile;
OperatorProfile? operatorProfile;
LatLng target; // Current position (can be dragged)
List<double> directions; // All directions [90, 180, 270]
int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°)
EditNodeSession({
required this.originalNode,
required this.profile,
required this.directionDegrees,
this.profile,
required double initialDirection,
required this.target,
});
}) : directions = [initialDirection],
currentDirectionIndex = 0;
final OsmNode originalNode; // The original node being edited
NodeProfile profile;
OperatorProfile? operatorProfile;
double directionDegrees;
LatLng target; // Current position (can be dragged)
// Slider always shows the current direction being edited
double get directionDegrees => directions[currentDirectionIndex];
set directionDegrees(double value) => directions[currentDirectionIndex] = value;
}
class SessionState extends ChangeNotifier {
@@ -39,11 +57,8 @@ class SessionState extends ChangeNotifier {
EditNodeSession? get editSession => _editSession;
void startAddSession(List<NodeProfile> enabledProfiles) {
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
final defaultProfile = submittableProfiles.isNotEmpty
? submittableProfiles.first
: enabledProfiles.first; // Fallback to any enabled profile
_session = AddNodeSession(profile: defaultProfile);
// Start with no profile selected - force user to choose
_session = AddNodeSession();
_editSession = null; // Clear any edit session
notifyListeners();
}
@@ -52,11 +67,9 @@ class SessionState extends ChangeNotifier {
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
// Try to find a matching profile based on the node's tags
NodeProfile matchingProfile = submittableProfiles.isNotEmpty
? submittableProfiles.first
: enabledProfiles.first;
NodeProfile? matchingProfile;
// Attempt to find a better match by comparing tags
// Attempt to find a match by comparing tags
for (final profile in submittableProfiles) {
if (_profileMatchesTags(profile, node.tags)) {
matchingProfile = profile;
@@ -64,12 +77,20 @@ class SessionState extends ChangeNotifier {
}
}
// Start with no profile selected if no match found - force user to choose
// Initialize edit session with all existing directions
final existingDirections = node.directionDeg.isNotEmpty ? node.directionDeg : [0.0];
_editSession = EditNodeSession(
originalNode: node,
profile: matchingProfile,
directionDegrees: node.directionDeg ?? 0,
initialDirection: existingDirections.first,
target: node.coord,
);
// Replace the default single direction with all existing directions
_editSession!.directions = List<double>.from(existingDirections);
_editSession!.currentDirectionIndex = 0; // Start editing the first direction
_session = null; // Clear any add session
notifyListeners();
}
@@ -140,6 +161,49 @@ class SessionState extends ChangeNotifier {
if (dirty) notifyListeners();
}
// Add new direction at 0° and switch to editing it
void addDirection() {
if (_session != null) {
_session!.directions.add(0.0);
_session!.currentDirectionIndex = _session!.directions.length - 1;
notifyListeners();
} else if (_editSession != null) {
_editSession!.directions.add(0.0);
_editSession!.currentDirectionIndex = _editSession!.directions.length - 1;
notifyListeners();
}
}
// Remove currently selected direction
void removeDirection() {
if (_session != null && _session!.directions.length > 1) {
_session!.directions.removeAt(_session!.currentDirectionIndex);
if (_session!.currentDirectionIndex >= _session!.directions.length) {
_session!.currentDirectionIndex = _session!.directions.length - 1;
}
notifyListeners();
} else if (_editSession != null && _editSession!.directions.length > 1) {
_editSession!.directions.removeAt(_editSession!.currentDirectionIndex);
if (_editSession!.currentDirectionIndex >= _editSession!.directions.length) {
_editSession!.currentDirectionIndex = _editSession!.directions.length - 1;
}
notifyListeners();
}
}
// Cycle to next direction
void cycleDirection() {
if (_session != null && _session!.directions.length > 1) {
_session!.currentDirectionIndex = (_session!.currentDirectionIndex + 1) % _session!.directions.length;
notifyListeners();
} else if (_editSession != null && _editSession!.directions.length > 1) {
_editSession!.currentDirectionIndex = (_editSession!.currentDirectionIndex + 1) % _editSession!.directions.length;
notifyListeners();
}
}
void cancelSession() {
_session = null;
notifyListeners();
@@ -151,7 +215,7 @@ class SessionState extends ChangeNotifier {
}
AddNodeSession? commitSession() {
if (_session?.target == null) return null;
if (_session?.target == null || _session?.profile == null) return null;
final session = _session!;
_session = null;
@@ -160,7 +224,7 @@ class SessionState extends ChangeNotifier {
}
EditNodeSession? commitEditSession() {
if (_editSession == null) return null;
if (_editSession?.profile == null) return null;
final session = _editSession!;
_editSession = null;

View File

@@ -29,8 +29,8 @@ class UploadQueueState extends ChangeNotifier {
void addFromSession(AddNodeSession session, {required UploadMode uploadMode}) {
final upload = PendingUpload(
coord: session.target!,
direction: session.directionDegrees,
profile: session.profile,
direction: _formatDirectionsAsString(session.directions),
profile: session.profile!, // Safe to use ! because commitSession() checks for null
operatorProfile: session.operatorProfile,
uploadMode: uploadMode,
operation: UploadOperation.create,
@@ -63,8 +63,8 @@ class UploadQueueState extends ChangeNotifier {
void addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) {
final upload = PendingUpload(
coord: session.target,
direction: session.directionDegrees,
profile: session.profile,
direction: _formatDirectionsAsString(session.directions),
profile: session.profile!, // Safe to use ! because commitEditSession() checks for null
operatorProfile: session.operatorProfile,
uploadMode: uploadMode,
operation: UploadOperation.modify,
@@ -109,8 +109,8 @@ class UploadQueueState extends ChangeNotifier {
void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode}) {
final upload = PendingUpload(
coord: node.coord,
direction: node.directionDeg ?? 0, // Use existing direction or default to 0
profile: NodeProfile.genericAlpr(), // Dummy profile - not used for deletions
direction: node.directionDeg.isNotEmpty ? node.directionDeg.first : 0, // Direction not used for deletions but required for API
profile: null, // No profile needed for deletions - just delete by node ID
uploadMode: uploadMode,
operation: UploadOperation.delete,
originalNodeId: node.id,
@@ -293,6 +293,13 @@ class UploadQueueState extends ChangeNotifier {
}
}
// Helper method to format multiple directions as a string or number
dynamic _formatDirectionsAsString(List<double> directions) {
if (directions.isEmpty) return 0.0;
if (directions.length == 1) return directions.first;
return directions.map((d) => d.round().toString()).join(';');
}
// ---------- Queue persistence ----------
Future<void> _saveQueue() async {
final prefs = await SharedPreferences.getInstance();

View File

@@ -12,6 +12,112 @@ class AddNodeSheet extends StatelessWidget {
final AddNodeSession session;
Widget _buildDirectionControls(BuildContext context, AppState appState, AddNodeSession session, LocalizationService locService) {
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
// Format direction display text with bold for current direction
String directionsText = '';
if (requiresDirection) {
final directionsWithBold = <String>[];
for (int i = 0; i < session.directions.length; i++) {
final dirStr = session.directions[i].round().toString();
if (i == session.currentDirectionIndex) {
directionsWithBold.add('**$dirStr**'); // Mark for bold formatting
} else {
directionsWithBold.add(dirStr);
}
}
directionsText = directionsWithBold.join(', ');
}
return Column(
children: [
ListTile(
title: requiresDirection
? RichText(
text: TextSpan(
style: Theme.of(context).textTheme.titleMedium,
children: [
const TextSpan(text: 'Directions: '),
if (directionsText.isNotEmpty)
...directionsText.split('**').asMap().entries.map((entry) {
final isEven = entry.key % 2 == 0;
return TextSpan(
text: entry.value,
style: TextStyle(
fontWeight: isEven ? FontWeight.normal : FontWeight.bold,
),
);
}),
],
),
)
: Text(locService.t('addNode.direction', params: [session.directionDegrees.round().toString()])),
subtitle: Row(
children: [
// Slider takes most of the space
Expanded(
child: Slider(
min: 0,
max: 359,
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: requiresDirection ? (v) => appState.updateSession(directionDeg: v) : null,
),
),
// Buttons on the right (only show if direction is required)
if (requiresDirection) ...[
const SizedBox(width: 8),
// Remove button
IconButton(
icon: const Icon(Icons.remove, size: 20),
onPressed: session.directions.length > 1 ? () => appState.removeDirection() : null,
tooltip: 'Remove current direction',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
// Add button
IconButton(
icon: const Icon(Icons.add, size: 20),
onPressed: () => appState.addDirection(),
tooltip: 'Add new direction',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
// Cycle button
IconButton(
icon: const Icon(Icons.repeat, size: 20),
onPressed: session.directions.length > 1 ? () => appState.cycleDirection() : null,
tooltip: 'Cycle through directions',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
],
],
),
),
// Show info text when profile doesn't require direction
if (!requiresDirection)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.grey, size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('addNode.profileNoDirectionInfo'),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
),
),
],
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
@@ -34,7 +140,10 @@ class AddNodeSheet extends StatelessWidget {
}
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable;
final allowSubmit = appState.isLoggedIn &&
submittableProfiles.isNotEmpty &&
session.profile != null &&
session.profile!.isSubmittable;
void _openRefineTags() async {
final result = await Navigator.push<OperatorProfile?>(
@@ -66,44 +175,18 @@ class AddNodeSheet extends StatelessWidget {
const SizedBox(height: 16),
ListTile(
title: Text(locService.t('addNode.profile')),
trailing: DropdownButton<NodeProfile>(
trailing: DropdownButton<NodeProfile?>(
value: session.profile,
hint: Text(locService.t('addNode.selectProfile')),
items: submittableProfiles
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
.toList(),
onChanged: (p) =>
appState.updateSession(profile: p ?? session.profile),
onChanged: (p) => appState.updateSession(profile: p),
),
),
ListTile(
title: Text(locService.t('addNode.direction', params: [session.directionDegrees.round().toString()])),
subtitle: Slider(
min: 0,
max: 359,
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: session.profile.requiresDirection
? (v) => appState.updateSession(directionDeg: v)
: null, // Disables slider when requiresDirection is false
),
),
if (!session.profile.requiresDirection)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.grey, size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('addNode.profileNoDirectionInfo'),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
),
),
// Direction controls
_buildDirectionControls(context, appState, session, locService),
if (!appState.isLoggedIn)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
@@ -136,7 +219,23 @@ class AddNodeSheet extends StatelessWidget {
],
),
)
else if (!session.profile.isSubmittable)
else if (session.profile == null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.orange, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('addNode.profileRequired'),
style: const TextStyle(color: Colors.orange, fontSize: 13),
),
),
],
),
)
else if (!session.profile!.isSubmittable)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
@@ -158,7 +257,7 @@ class AddNodeSheet extends StatelessWidget {
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _openRefineTags,
onPressed: session.profile != null ? _openRefineTags : null, // Disabled when no profile selected
icon: const Icon(Icons.tune),
label: Text(session.operatorProfile != null
? locService.t('addNode.refineTagsWithProfile', params: [session.operatorProfile!.name])

View File

@@ -13,6 +13,112 @@ class EditNodeSheet extends StatelessWidget {
final EditNodeSession session;
Widget _buildDirectionControls(BuildContext context, AppState appState, EditNodeSession session, LocalizationService locService) {
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
// Format direction display text with bold for current direction
String directionsText = '';
if (requiresDirection) {
final directionsWithBold = <String>[];
for (int i = 0; i < session.directions.length; i++) {
final dirStr = session.directions[i].round().toString();
if (i == session.currentDirectionIndex) {
directionsWithBold.add('**$dirStr**'); // Mark for bold formatting
} else {
directionsWithBold.add(dirStr);
}
}
directionsText = directionsWithBold.join(', ');
}
return Column(
children: [
ListTile(
title: requiresDirection
? RichText(
text: TextSpan(
style: Theme.of(context).textTheme.titleMedium,
children: [
const TextSpan(text: 'Directions: '),
if (directionsText.isNotEmpty)
...directionsText.split('**').asMap().entries.map((entry) {
final isEven = entry.key % 2 == 0;
return TextSpan(
text: entry.value,
style: TextStyle(
fontWeight: isEven ? FontWeight.normal : FontWeight.bold,
),
);
}),
],
),
)
: Text(locService.t('editNode.direction', params: [session.directionDegrees.round().toString()])),
subtitle: Row(
children: [
// Slider takes most of the space
Expanded(
child: Slider(
min: 0,
max: 359,
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: requiresDirection ? (v) => appState.updateEditSession(directionDeg: v) : null,
),
),
// Buttons on the right (only show if direction is required)
if (requiresDirection) ...[
const SizedBox(width: 8),
// Remove button
IconButton(
icon: const Icon(Icons.remove, size: 20),
onPressed: session.directions.length > 1 ? () => appState.removeDirection() : null,
tooltip: 'Remove current direction',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
// Add button
IconButton(
icon: const Icon(Icons.add, size: 20),
onPressed: () => appState.addDirection(),
tooltip: 'Add new direction',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
// Cycle button
IconButton(
icon: const Icon(Icons.repeat, size: 20),
onPressed: session.directions.length > 1 ? () => appState.cycleDirection() : null,
tooltip: 'Cycle through directions',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
],
],
),
),
// Show info text when profile doesn't require direction
if (!requiresDirection)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.grey, size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(
'This profile does not require a direction.',
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
),
),
],
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
@@ -36,7 +142,10 @@ class EditNodeSheet extends StatelessWidget {
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final isSandboxMode = appState.uploadMode == UploadMode.sandbox;
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable;
final allowSubmit = appState.isLoggedIn &&
submittableProfiles.isNotEmpty &&
session.profile != null &&
session.profile!.isSubmittable;
void _openRefineTags() async {
final result = await Navigator.push<OperatorProfile?>(
@@ -73,44 +182,18 @@ class EditNodeSheet extends StatelessWidget {
const SizedBox(height: 16),
ListTile(
title: Text(locService.t('editNode.profile')),
trailing: DropdownButton<NodeProfile>(
trailing: DropdownButton<NodeProfile?>(
value: session.profile,
hint: Text(locService.t('editNode.selectProfile')),
items: submittableProfiles
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
.toList(),
onChanged: (p) =>
appState.updateEditSession(profile: p ?? session.profile),
onChanged: (p) => appState.updateEditSession(profile: p),
),
),
ListTile(
title: Text(locService.t('editNode.direction', params: [session.directionDegrees.round().toString()])),
subtitle: Slider(
min: 0,
max: 359,
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: session.profile.requiresDirection
? (v) => appState.updateEditSession(directionDeg: v)
: null, // Disables slider when requiresDirection is false
),
),
if (!session.profile.requiresDirection)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.grey, size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('editNode.profileNoDirectionInfo'),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
),
),
// Direction controls
_buildDirectionControls(context, appState, session, locService),
if (!appState.isLoggedIn)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
@@ -143,7 +226,23 @@ class EditNodeSheet extends StatelessWidget {
],
),
)
else if (!session.profile.isSubmittable)
else if (session.profile == null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.orange, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('editNode.profileRequired'),
style: const TextStyle(color: Colors.orange, fontSize: 13),
),
),
],
),
)
else if (!session.profile!.isSubmittable)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
@@ -165,7 +264,7 @@ class EditNodeSheet extends StatelessWidget {
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _openRefineTags,
onPressed: session.profile != null ? _openRefineTags : null, // Disabled when no profile selected
icon: const Icon(Icons.tune),
label: Text(session.operatorProfile != null
? locService.t('editNode.refineTagsWithProfile', params: [session.operatorProfile!.name])

View File

@@ -18,47 +18,81 @@ class DirectionConesBuilder {
}) {
final overlays = <Polygon>[];
// Add session cone if in add-camera mode and profile requires direction
if (session != null && session.target != null && session.profile.requiresDirection) {
// Add session cones if in add-camera mode and profile requires direction
if (session != null && session.target != null && session.profile?.requiresDirection == true) {
// Add current working direction (full opacity)
overlays.add(_buildCone(
session.target!,
session.directionDegrees,
zoom,
context: context,
isSession: true,
isActiveDirection: true,
));
// Add other directions (reduced opacity)
for (int i = 0; i < session.directions.length; i++) {
if (i != session.currentDirectionIndex) {
overlays.add(_buildCone(
session.target!,
session.directions[i],
zoom,
context: context,
isSession: true,
isActiveDirection: false,
));
}
}
}
// Add edit session cone if in edit-camera mode and profile requires direction
if (editSession != null && editSession.profile.requiresDirection) {
// Add edit session cones if in edit-camera mode and profile requires direction
if (editSession != null && editSession.profile?.requiresDirection == true) {
// Add current working direction (full opacity)
overlays.add(_buildCone(
editSession.target,
editSession.directionDegrees,
zoom,
context: context,
isSession: true,
isActiveDirection: true,
));
// Add other directions (reduced opacity)
for (int i = 0; i < editSession.directions.length; i++) {
if (i != editSession.currentDirectionIndex) {
overlays.add(_buildCone(
editSession.target,
editSession.directions[i],
zoom,
context: context,
isSession: true,
isActiveDirection: false,
));
}
}
}
// Add cones for cameras with direction (but exclude camera being edited)
overlays.addAll(
cameras
.where((n) => _isValidCameraWithDirection(n) &&
(editSession == null || n.id != editSession.originalNode.id))
.map((n) => _buildCone(
n.coord,
n.directionDeg!,
zoom,
context: context,
))
);
for (final node in cameras) {
if (_isValidCameraWithDirection(node) &&
(editSession == null || node.id != editSession.originalNode.id)) {
// Build a cone for each direction
for (final direction in node.directionDeg) {
overlays.add(_buildCone(
node.coord,
direction,
zoom,
context: context,
));
}
}
}
return overlays;
}
static bool _isValidCameraWithDirection(OsmNode node) {
return node.hasDirection &&
node.directionDeg != null &&
(node.coord.latitude != 0 || node.coord.longitude != 0) &&
node.coord.latitude.abs() <= 90 &&
node.coord.longitude.abs() <= 180;
@@ -76,6 +110,7 @@ class DirectionConesBuilder {
required BuildContext context,
bool isPending = false,
bool isSession = false,
bool isActiveDirection = true,
}) {
final halfAngle = kDirectionConeHalfAngle;
@@ -114,9 +149,15 @@ class DirectionConesBuilder {
points.add(project(angle, innerRadius));
}
// Adjust opacity based on direction state
double opacity = kDirectionConeOpacity;
if (isSession && !isActiveDirection) {
opacity = kDirectionConeOpacity * 0.4; // Reduced opacity for inactive session directions
}
return Polygon(
points: points,
color: kDirectionConeColor.withOpacity(kDirectionConeOpacity),
color: kDirectionConeColor.withOpacity(opacity),
borderColor: kDirectionConeColor,
borderStrokeWidth: getDirectionConeBorderWidth(context),
);

View File

@@ -19,36 +19,17 @@ class SuspectedLocationSheet extends StatelessWidget {
final appState = context.watch<AppState>();
final locService = LocalizationService.instance;
Future<void> _launchUrl() async {
if (location.urlFull?.isNotEmpty == true) {
final uri = Uri.parse(location.urlFull!);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not open URL: ${location.urlFull}'),
),
);
}
}
// Get all fields except location and ticket_no
final displayData = <String, String>{};
for (final entry in location.allFields.entries) {
final value = entry.value?.toString();
if (value != null && value.isNotEmpty) {
displayData[entry.key] = value;
}
}
// Create display data map using localized labels
final Map<String, String?> displayData = {
locService.t('suspectedLocation.ticketNo'): location.ticketNo,
locService.t('suspectedLocation.address'): location.addr,
locService.t('suspectedLocation.street'): location.street,
locService.t('suspectedLocation.city'): location.city,
locService.t('suspectedLocation.state'): location.state,
locService.t('suspectedLocation.intersectingStreet'): location.digSiteIntersectingStreet,
locService.t('suspectedLocation.workDoneFor'): location.digWorkDoneFor,
locService.t('suspectedLocation.remarks'): location.digSiteRemarks,
locService.t('suspectedLocation.url'): location.urlFull,
};
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
@@ -64,7 +45,7 @@ class SuspectedLocationSheet extends StatelessWidget {
const SizedBox(height: 12),
// Display all fields
...displayData.entries.where((e) => e.value?.isNotEmpty == true).map(
...displayData.entries.map(
(e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
@@ -79,11 +60,24 @@ class SuspectedLocationSheet extends StatelessWidget {
),
const SizedBox(width: 8),
Expanded(
child: e.key == 'URL' && e.value?.isNotEmpty == true
child: e.key.toLowerCase().contains('url') && e.value.isNotEmpty
? GestureDetector(
onTap: _launchUrl,
onTap: () async {
final uri = Uri.parse(e.value);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not open URL: ${e.value}'),
),
);
}
}
},
child: Text(
e.value!,
e.value,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
@@ -92,7 +86,7 @@ class SuspectedLocationSheet extends StatelessWidget {
),
)
: Text(
e.value ?? '',
e.value,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),

View File

@@ -62,6 +62,19 @@ class _WelcomeDialogState extends State<WelcomeDialog> {
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.withOpacity(0.3)),
),
child: Text(
locService.t('welcome.firsthandKnowledge'),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: Colors.deepOrange),
),
),
const SizedBox(height: 12),
Text(
locService.t('welcome.privacy'),
style: const TextStyle(fontSize: 13, fontStyle: FontStyle.italic),

View File

@@ -1,7 +1,7 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 1.2.7+6 # The thing after the + is the version code, incremented with each release
version: 1.2.8+7 # The thing after the + is the version code, incremented with each release
environment:
sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+

View File

@@ -1,83 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:deflockapp/models/pending_upload.dart';
import 'package:deflockapp/models/node_profile.dart';
import 'package:deflockapp/state/settings_state.dart';
void main() {
group('PendingUpload', () {
test('should serialize and deserialize upload mode correctly', () {
// Test each upload mode
final testModes = [
UploadMode.production,
UploadMode.sandbox,
UploadMode.simulate,
];
for (final mode in testModes) {
final original = PendingUpload(
coord: LatLng(37.7749, -122.4194),
direction: 90.0,
profile: NodeProfile.flock(),
uploadMode: mode,
);
// Serialize to JSON
final json = original.toJson();
// Deserialize from JSON
final restored = PendingUpload.fromJson(json);
// Verify upload mode is preserved
expect(restored.uploadMode, equals(mode));
expect(restored.uploadModeDisplayName, equals(original.uploadModeDisplayName));
// Verify other fields too
expect(restored.coord.latitude, equals(original.coord.latitude));
expect(restored.coord.longitude, equals(original.coord.longitude));
expect(restored.direction, equals(original.direction));
expect(restored.profile.id, equals(original.profile.id));
}
});
test('should handle legacy JSON without uploadMode', () {
// Simulate old JSON format without uploadMode field
final legacyJson = {
'lat': 37.7749,
'lon': -122.4194,
'dir': 90.0,
'profile': NodeProfile.flock().toJson(),
'originalNodeId': null,
'attempts': 0,
'error': false,
// Note: no 'uploadMode' field
};
final upload = PendingUpload.fromJson(legacyJson);
// Should default to production mode for legacy entries
expect(upload.uploadMode, equals(UploadMode.production));
expect(upload.uploadModeDisplayName, equals('Production'));
});
test('should correctly identify edits vs new cameras', () {
final newCamera = PendingUpload(
coord: LatLng(37.7749, -122.4194),
direction: 90.0,
profile: NodeProfile.flock(),
uploadMode: UploadMode.production,
);
final editCamera = PendingUpload(
coord: LatLng(37.7749, -122.4194),
direction: 90.0,
profile: CameraProfile.flock(),
uploadMode: UploadMode.production,
originalNodeId: 12345,
);
expect(newCamera.isEdit, isFalse);
expect(editCamera.isEdit, isTrue);
});
});
}