mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-13 09:12:56 +00:00
Compare commits
13 Commits
v1.2.7-rel
...
v1.2.8-rel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7842848152 | ||
|
|
07ced1bc11 | ||
|
|
335eb33613 | ||
|
|
c9a7045212 | ||
|
|
e861d00b68 | ||
|
|
d9f415c527 | ||
|
|
1993714752 | ||
|
|
b80e1094af | ||
|
|
0db4c0f80d | ||
|
|
f1f145a35f | ||
|
|
618d31d016 | ||
|
|
c8ae925dc1 | ||
|
|
2a7004e5a2 |
46
DEVELOPER.md
46
DEVELOPER.md
@@ -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.
|
||||
|
||||
|
||||
36
README.md
36
README.md
@@ -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 (Lowe’s 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "您必须登录才能编辑节点。请通过设置登录。",
|
||||
|
||||
@@ -20,161 +20,149 @@ class NodeProfile {
|
||||
this.editable = true,
|
||||
});
|
||||
|
||||
/// Built‑in 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,
|
||||
),
|
||||
];
|
||||
|
||||
/// Built‑in: 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,
|
||||
);
|
||||
|
||||
/// Built‑in: 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,
|
||||
);
|
||||
|
||||
/// Built‑in: 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,
|
||||
);
|
||||
|
||||
/// Built‑in: 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,
|
||||
);
|
||||
|
||||
/// Built‑in: 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,
|
||||
);
|
||||
|
||||
/// Built‑in: 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,
|
||||
);
|
||||
|
||||
/// Built‑in: 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,
|
||||
);
|
||||
|
||||
/// Built‑in: 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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 0‑359 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 0‑359 range
|
||||
final normalized = ((val % 360) + 360) % 360;
|
||||
directions.add(normalized);
|
||||
}
|
||||
|
||||
return directions;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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!);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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...');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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+
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user