diff --git a/DEVELOPER.md b/DEVELOPER.md new file mode 100644 index 0000000..db122be --- /dev/null +++ b/DEVELOPER.md @@ -0,0 +1,425 @@ +# Developer Documentation + +This document provides detailed technical information about the DeFlock app architecture, key design decisions, and development guidelines. + +--- + +## Philosophy: Brutalist Code + +Our development approach prioritizes **simplicity over cleverness**: + +- **Explicit over implicit**: Clear, readable code that states its intent +- **Few edge cases by design**: Avoid complex branching and special cases +- **Maintainable over efficient**: Choose the approach that's easier to understand and modify +- **Delete before adding**: Remove complexity when possible rather than adding features + +**Hierarchy of preferred code:** +1. **Code we don't write** (through thoughtful design and removing edge cases) +2. **Code we can remove** (by seeing problems from a new angle) +3. **Code that sadly must exist** (simple, explicit, maintainable) + +--- + +## Architecture Overview + +### State Management + +The app uses **Provider pattern** with modular state classes: + +``` +AppState (main coordinator) +├── AuthState (OAuth2 login/logout) +├── OperatorProfileState (operator tag sets) +├── ProfileState (node profiles & toggles) +├── SessionState (add/edit sessions) +├── SettingsState (preferences & tile providers) +└── UploadQueueState (pending operations) +``` + +**Why this approach:** +- **Separation of concerns**: Each state handles one domain +- **Testability**: Individual state classes can be unit tested +- **Brutalist**: No complex state orchestration, just simple delegation + +### Data Flow Architecture + +``` +UI Layer (Widgets) + ↕️ +AppState (Coordinator) + ↕️ +State Modules (AuthState, ProfileState, etc.) + ↕️ +Services (MapDataProvider, NodeCache, Uploader) + ↕️ +External APIs (OSM, Overpass, Tile providers) +``` + +**Key principles:** +- **Unidirectional data flow**: UI → AppState → Services → APIs +- **No direct service access from UI**: Everything goes through AppState +- **Clean boundaries**: Each layer has a clear responsibility + +--- + +## Core Components + +### 1. MapDataProvider + +**Purpose**: Unified interface for fetching map tiles and surveillance nodes + +**Design decisions:** +- **Pluggable sources**: Local (cached) vs Remote (live API) +- **Offline-first**: Always try local first, graceful degradation +- **Mode-aware**: Different behavior for production vs sandbox +- **Failure handling**: Never crash the UI, always provide fallbacks + +**Key methods:** +- `getNodes()`: Smart fetching with local/remote merging +- `getTile()`: Tile fetching with caching +- `_fetchRemoteNodes()`: Handles Overpass → OSM API fallback + +**Why unified interface:** +The app needs to seamlessly switch between multiple data sources (local cache, Overpass API, OSM API, offline areas) based on network status, upload mode, and zoom level. A single interface prevents the UI from needing to know about these complexities. + +### 2. Node Operations (Create/Edit/Delete) + +**Upload Operations Enum:** +```dart +enum UploadOperation { create, modify, delete } +``` + +**Why explicit enum vs boolean flags:** +- **Brutalist**: Three explicit states instead of nullable booleans +- **Extensible**: Easy to add new operations (like bulk operations) +- **Clear intent**: `operation == UploadOperation.delete` is unambiguous + +**Session Pattern:** +- `AddNodeSession`: For creating new nodes +- `EditNodeSession`: For modifying existing nodes +- No "DeleteSession": Deletions are immediate (simpler) + +**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. + +### 3. Upload Queue System + +**Design principles:** +- **Operation-agnostic**: Same queue handles create/modify/delete +- **Offline-capable**: Queue persists between app sessions +- **Visual feedback**: Each operation type has distinct UI state +- **Error recovery**: Retry mechanism with exponential backoff + +**Queue workflow:** +1. User action (add/edit/delete) → `PendingUpload` created +2. Immediate visual feedback (cache updated with temp markers) +3. Background uploader processes queue when online +4. Success → cache updated with real data, temp markers removed +5. Failure → error state, retry available + +**Why immediate visual feedback:** +Users expect instant response to their actions. By immediately updating the cache with temporary markers (e.g., `_pending_deletion`), the UI stays responsive while the actual API calls happen in background. + +### 4. Cache & Visual States + +**Node visual states:** +- **Blue ring**: Real nodes from OSM +- **Purple ring**: Pending uploads (new nodes) +- **Grey ring**: Original nodes with pending edits +- **Orange ring**: Node currently being edited +- **Red ring**: Nodes pending deletion + +**Cache tags for state tracking:** +```dart +'_pending_upload' // New node waiting to upload +'_pending_edit' // Original node has pending edits +'_pending_deletion' // Node queued for deletion +'_original_node_id' // For drawing connection lines +``` + +**Why underscore prefix:** +These are internal app tags, not OSM tags. The underscore prefix makes this explicit and prevents accidental upload to OSM. + +### 5. Multi-API Data Sources + +**Production mode:** Overpass API → OSM API fallback +**Sandbox mode:** OSM API only (Overpass doesn't have sandbox data) + +**Zoom level restrictions:** +- **Production (Overpass)**: Zoom ≥ 10 (established limit) +- **Sandbox (OSM API)**: Zoom ≥ 13 (stricter due to bbox limits) + +**Why different zoom limits:** +The OSM API returns ALL data types (nodes, ways, relations) in a bounding box and has stricter size limits. Overpass is more efficient for large areas. The zoom restrictions prevent API errors and excessive data transfer. + +### 6. Offline vs Online Mode Behavior + +**Mode combinations:** +``` +Production + Online → Local cache + Overpass API +Production + Offline → Local cache only +Sandbox + Online → OSM API only (no cache mixing) +Sandbox + Offline → No nodes (cache is production data) +``` + +**Why sandbox + offline = no nodes:** +Local cache contains production data. Showing production nodes in sandbox mode would be confusing and could lead to users trying to edit production nodes with sandbox credentials. + +--- + +## Key Design Decisions & Rationales + +### 1. Why Provider Pattern? + +**Alternatives considered:** +- BLoC: Too verbose for our needs +- Riverpod: Added complexity without clear benefit +- setState: Doesn't scale beyond single widgets + +**Why Provider won:** +- **Familiar**: Most Flutter developers know Provider +- **Simple**: Minimal boilerplate +- **Flexible**: Easy to compose multiple providers +- **Battle-tested**: Mature, stable library + +### 2. Why Separate State Classes? + +**Alternative**: Single monolithic AppState + +**Why modular state:** +- **Single responsibility**: Each state class has one concern +- **Testability**: Easier to unit test individual features +- **Maintainability**: Changes to auth don't affect profile logic +- **Team development**: Different developers can work on different states + +### 3. Why Upload Queue vs Direct API Calls? + +**Alternative**: Direct API calls from UI actions + +**Why queue approach:** +- **Offline capability**: Actions work without internet +- **User experience**: Instant feedback, no waiting for API calls +- **Error recovery**: Failed uploads can be retried +- **Batch processing**: Could optimize multiple operations +- **Visual feedback**: Users can see pending operations + +### 4. Why Overpass + OSM API vs Just One? + +**Why not just Overpass:** +- Overpass doesn't have sandbox data +- Overpass can be unreliable/slow +- OSM API is canonical source + +**Why not just OSM API:** +- OSM API has strict bbox size limits +- OSM API returns all data types (inefficient) +- Overpass is optimized for surveillance device queries + +**Result**: Use the best tool for each situation + +### 5. Why Zoom Level Restrictions? + +**Alternative**: Always fetch, handle errors gracefully + +**Why restrictions:** +- **Prevents API abuse**: Large bbox queries can overload servers +- **User experience**: Fetching 10,000 nodes causes UI lag +- **Battery life**: Excessive network requests drain battery +- **Clear feedback**: Users understand why nodes aren't showing + +--- + +## Development Guidelines + +### 1. Adding New Features + +**Before writing code:** +1. Can we solve this by removing existing code? +2. Can we simplify the problem to avoid edge cases? +3. Does this fit the existing patterns? + +**When adding new upload operations:** +1. Add to `UploadOperation` enum +2. Update `PendingUpload` serialization +3. Add visual state (color, icon) +4. Update uploader logic +5. Add cache cleanup handling + +### 2. Testing Philosophy + +**Priority order:** +1. **Integration tests**: Test complete user workflows +2. **Widget tests**: Test UI components with mock data +3. **Unit tests**: Test individual state classes + +**Why integration tests first:** +The most important thing is that user workflows work end-to-end. Unit tests can pass while the app is broken from a user perspective. + +### 3. Error Handling + +**Principles:** +- **Never crash the UI**: Always provide fallbacks +- **Fail gracefully**: Empty list is better than exception +- **User feedback**: Show meaningful error messages +- **Logging**: Use debugPrint for troubleshooting + +**Example pattern:** +```dart +try { + final result = await riskyOperation(); + return result; +} catch (e) { + debugPrint('Operation failed: $e'); + // Show user-friendly message + showSnackBar('Unable to load data. Please try again.'); + return []; +} +``` + +### 4. State Updates + +**Always notify listeners:** +```dart +void updateSomething() { + _something = newValue; + notifyListeners(); // Don't forget this! +} +``` + +**Batch related updates:** +```dart +void updateMultipleThings() { + _thing1 = value1; + _thing2 = value2; + _thing3 = value3; + notifyListeners(); // Single notification for all changes +} +``` + +--- + +## Build & Development Setup + +### Prerequisites +- **Flutter SDK**: Latest stable version +- **Xcode**: For iOS builds (macOS only) +- **Android Studio**: For Android builds +- **Git**: For version control + +### OAuth2 Setup + +**Required registrations:** +1. **Production OSM**: https://www.openstreetmap.org/oauth2/applications +2. **Sandbox OSM**: https://master.apis.dev.openstreetmap.org/oauth2/applications + +**Configuration:** +```bash +cp lib/keys.dart.example lib/keys.dart +# Edit keys.dart with your OAuth2 client IDs +``` + +### iOS Setup +```bash +cd ios && pod install +``` + +### Running +```bash +flutter pub get +flutter run +``` + +### Testing +```bash +# Run all tests +flutter test + +# Run with coverage +flutter test --coverage +``` + +--- + +## Code Organization + +``` +lib/ +├── models/ # Data classes +│ ├── osm_camera_node.dart +│ ├── pending_upload.dart +│ └── node_profile.dart +├── services/ # Business logic +│ ├── map_data_provider.dart +│ ├── uploader.dart +│ └── node_cache.dart +├── state/ # State management +│ ├── app_state.dart +│ ├── auth_state.dart +│ └── upload_queue_state.dart +├── widgets/ # UI components +│ ├── map_view.dart +│ ├── edit_node_sheet.dart +│ └── map/ # Map-specific widgets +├── screens/ # Full screens +│ ├── home_screen.dart +│ └── settings_screen.dart +└── localizations/ # i18n strings + ├── en.json + ├── de.json + ├── es.json + └── fr.json +``` + +**Principles:** +- **Models**: Pure data, no business logic +- **Services**: Stateless business logic +- **State**: Stateful coordination +- **Widgets**: UI only, delegate to AppState +- **Screens**: Compose widgets, handle navigation + +--- + +## Debugging Tips + +### Common Issues + +**Nodes not appearing:** +- Check zoom level (≥10 production, ≥13 sandbox) +- Check upload mode vs expected data source +- Check network connectivity +- Look for console errors + +**Upload failures:** +- Verify OAuth2 credentials +- Check upload mode matches login (production vs sandbox) +- Ensure node has required tags +- Check network connectivity + +**Cache issues:** +- Clear app data to reset cache +- Check if offline mode is affecting behavior +- Verify upload mode switches clear cache + +### Debug Logging + +**Enable verbose logging:** +```dart +debugPrint('[ComponentName] Detailed message: $data'); +``` + +**Key areas to log:** +- Network requests and responses +- Cache operations +- State transitions +- User actions + +### Performance + +**Monitor:** +- Memory usage during large node fetches +- UI responsiveness during background uploads +- Battery usage during GPS tracking + +--- + +This documentation should be updated as the architecture evolves. When making significant changes, update both the relevant section here and add a brief note explaining the rationale for the change. \ No newline at end of file diff --git a/README.md b/README.md index 2ccb743..08d2288 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,11 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with - **Multi-source tiles**: Switch between OpenStreetMap, Google Satellite, Esri imagery, Mapbox, and any custom providers - **Offline-first design**: Download a region for complete offline operation - **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, and gesture-friendly interactions -- **Device visualization**: Color-coded markers showing real devices (blue), pending uploads (purple), new devices (white), edited devices (grey), and devices being edited (orange) +- **Device visualization**: Color-coded markers showing real devices (blue), pending uploads (purple), pending edits (grey), devices being edited (orange), and pending deletions (red) ### Device Management - **Comprehensive profiles**: Built-in profiles for major manufacturers (Flock Safety, Motorola/Vigilant, Genetec, Leonardo/ELSAG, Neology) plus custom profile creation -- **Editing capabilities**: Update location, direction, and tags of existing devices +- **Full CRUD operations**: Create, edit, and delete surveillance devices - **Direction visualization**: Interactive field-of-view cones showing camera viewing angles - **Bulk operations**: Tag multiple devices efficiently with profile-based workflow @@ -52,7 +52,8 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with 1. **Install** the app on iOS or Android 2. **Enable location** permissions 3. **Log into OpenStreetMap**: Choose upload mode and get OAuth2 credentials -4. **Add your first device**: Tap the "tag 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, 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. @@ -60,37 +61,26 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with ## For Developers -### Architecture Highlights -- **Unified data provider**: All map tiles and surveillance device data route through `MapDataProvider` with pluggable remote/local sources -- **Modular settings**: Each settings section is a separate widget for maintainability -- **State management**: Provider pattern with clean separation of concerns -- **Offline-first**: Network calls are optional; app functions fully offline with downloaded data and queues uploads until online - -### Build Setup -**Prerequisites**: Flutter SDK, Xcode (iOS), Android Studio -**OAuth Setup**: Register apps at [openstreetmap.org/oauth2](https://www.openstreetmap.org/oauth2/applications) and [OSM Sandbox](https://master.apis.dev.openstreetmap.org/oauth2/applications) to get a client ID +**See [DEVELOPER.md](DEVELOPER.md)** for comprehensive technical documentation including: +- Architecture overview and design decisions +- Development setup and build instructions +- Code organization and contribution guidelines +- Debugging tips and troubleshooting +**Quick setup:** ```shell -# Basic setup flutter pub get cp lib/keys.dart.example lib/keys.dart -# Add your OAuth2 client IDs to keys.dart - -# iOS additional setup -cd ios && pod install - -# Run -flutter run +# Add OAuth2 client IDs, then: flutter run ``` --- ## Roadmap -### v1 todo/bug List +### Current Development - Update offline area nodes while browsing? -- Camera deletions -- Optional custom icons for camera profiles +- Optional custom icons for camera profiles - Upgrade device marker design (considering nullplate's svg) ### Future Features & Wishlist diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 408bae9..ecacc7e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -17,6 +17,7 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 + isCoreLibraryDesugaringEnabled = true } kotlinOptions { jvmTarget = JavaVersion.VERSION_11.toString() @@ -50,3 +51,7 @@ flutter { source = "../.." } +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") +} + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c47f597..ffccb1b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,9 @@ + + + _settingsState.maxCameras; UploadMode get uploadMode => _settingsState.uploadMode; FollowMeMode get followMeMode => _settingsState.followMeMode; + bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled; + int get proximityAlertDistance => _settingsState.proximityAlertDistance; // Tile provider state List get tileProviders => _settingsState.tileProviders; @@ -162,7 +164,7 @@ class AppState extends ChangeNotifier { _sessionState.startAddSession(enabledProfiles); } - void startEditSession(OsmCameraNode node) { + void startEditSession(OsmNode node) { _sessionState.startEditSession(node, enabledProfiles); } @@ -218,7 +220,7 @@ class AppState extends ChangeNotifier { } } - void deleteNode(OsmCameraNode node) { + void deleteNode(OsmNode node) { _uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode); _startUploader(); } @@ -270,6 +272,16 @@ class AppState extends ChangeNotifier { await _settingsState.setFollowMeMode(mode); } + /// Set proximity alerts enabled/disabled + Future setProximityAlertsEnabled(bool enabled) async { + await _settingsState.setProximityAlertsEnabled(enabled); + } + + /// Set proximity alert distance + Future setProximityAlertDistance(int distance) async { + await _settingsState.setProximityAlertDistance(distance); + } + // ---------- Queue Methods ---------- void clearQueue() { _uploadQueueState.clearQueue(); diff --git a/lib/dev_config.dart b/lib/dev_config.dart index db1446c..5084fd7 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -31,7 +31,7 @@ const double kAddPinYOffset = 0.0; // Client name and version for OSM uploads ("created_by" tag) const String kClientName = 'DeFlock'; -const String kClientVersion = '0.9.12'; +const String kClientVersion = '0.9.13'; // 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 @@ -46,6 +46,12 @@ const Duration kDebounceCameraRefresh = Duration(milliseconds: 500); const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600); const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rotation +// Proximity alerts configuration +const int kProximityAlertDefaultDistance = 200; // meters +const int kProximityAlertMinDistance = 50; // meters +const int kProximityAlertMaxDistance = 1000; // meters +const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node + // Last map location and settings storage const String kLastMapLatKey = 'last_map_latitude'; const String kLastMapLngKey = 'last_map_longitude'; diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 4a39dfd..9ed1a00 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -90,7 +90,6 @@ "simulate": "Simulieren", "productionDescription": "Hochladen in die Live-OSM-Datenbank (für alle Benutzer sichtbar)", "sandboxDescription": "Uploads gehen an die OSM Sandbox (sicher zum Testen, wird regelmäßig zurückgesetzt).", - "sandboxNote": "HINWEIS: Aufgrund von OpenStreetMap-Limitierungen werden Kameras, die an die Sandbox übermittelt werden, NICHT in der Karte dieser App angezeigt.", "simulateDescription": "Uploads simulieren (kontaktiert OSM-Server nicht)" }, "auth": { @@ -220,6 +219,7 @@ "noAreasTitle": "Keine Offline-Bereiche", "noAreasSubtitle": "Laden Sie einen Kartenbereich für die Offline-Nutzung herunter.", "provider": "Anbieter", + "maxZoom": "Max Zoom", "zoomLevels": "Z{}-{}", "latitude": "Breite", "longitude": "Länge", @@ -237,5 +237,22 @@ "megabytes": "MB", "kilobytes": "KB", "progress": "{}%" + }, + "refineTagsSheet": { + "title": "Tags Verfeinern", + "operatorProfile": "Betreiber-Profil", + "done": "Fertig", + "none": "Keine", + "noAdditionalOperatorTags": "Keine zusätzlichen Betreiber-Tags", + "additionalTags": "zusätzliche Tags", + "additionalTagsTitle": "Zusätzliche Tags", + "noTagsDefinedForProfile": "Keine Tags für dieses Betreiber-Profil definiert.", + "noOperatorProfiles": "Keine Betreiber-Profile definiert", + "noOperatorProfilesMessage": "Erstellen Sie Betreiber-Profile in den Einstellungen, um zusätzliche Tags auf Ihre Knoten-Übertragungen anzuwenden." + }, + "layerSelector": { + "cannotChangeTileTypes": "Kachel-Typen können während des Herunterladens von Offline-Bereichen nicht geändert werden", + "selectMapLayer": "Kartenschicht Auswählen", + "noTileProvidersAvailable": "Keine Kachel-Anbieter verfügbar" } } \ No newline at end of file diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 3f0409f..f3a8168 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -90,7 +90,6 @@ "simulate": "Simulate", "productionDescription": "Upload to the live OSM database (visible to all users)", "sandboxDescription": "Uploads go to the OSM Sandbox (safe for testing, resets regularly).", - "sandboxNote": "NOTE: Due to OpenStreetMap limitations, cameras submitted to the sandbox will NOT appear on the map in this app.", "simulateDescription": "Simulate uploads (does not contact OSM servers)" }, "auth": { @@ -220,6 +219,7 @@ "noAreasTitle": "No offline areas", "noAreasSubtitle": "Download a map area for offline use.", "provider": "Provider", + "maxZoom": "Max zoom", "zoomLevels": "Z{}-{}", "latitude": "Lat", "longitude": "Lon", @@ -237,5 +237,22 @@ "megabytes": "MB", "kilobytes": "KB", "progress": "{}%" + }, + "refineTagsSheet": { + "title": "Refine Tags", + "operatorProfile": "Operator Profile", + "done": "Done", + "none": "None", + "noAdditionalOperatorTags": "No additional operator tags", + "additionalTags": "additional tags", + "additionalTagsTitle": "Additional Tags", + "noTagsDefinedForProfile": "No tags defined for this operator profile.", + "noOperatorProfiles": "No operator profiles defined", + "noOperatorProfilesMessage": "Create operator profiles in Settings to apply additional tags to your node submissions." + }, + "layerSelector": { + "cannotChangeTileTypes": "Cannot change tile types while downloading offline areas", + "selectMapLayer": "Select Map Layer", + "noTileProvidersAvailable": "No tile providers available" } } \ No newline at end of file diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 38d2eae..6f8b035 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -90,7 +90,6 @@ "simulate": "Simular", "productionDescription": "Subir a la base de datos OSM en vivo (visible para todos los usuarios)", "sandboxDescription": "Las subidas van al Sandbox de OSM (seguro para pruebas, se reinicia regularmente).", - "sandboxNote": "NOTA: Debido a las limitaciones de OpenStreetMap, las cámaras enviadas al sandbox NO aparecerán en el mapa de esta aplicación.", "simulateDescription": "Simular subidas (no contacta servidores OSM)" }, "auth": { @@ -220,6 +219,7 @@ "noAreasTitle": "Sin áreas sin conexión", "noAreasSubtitle": "Descarga un área del mapa para uso sin conexión.", "provider": "Proveedor", + "maxZoom": "Zoom máx", "zoomLevels": "Z{}-{}", "latitude": "Lat", "longitude": "Lon", @@ -237,5 +237,22 @@ "megabytes": "MB", "kilobytes": "KB", "progress": "{}%" + }, + "refineTagsSheet": { + "title": "Refinar Etiquetas", + "operatorProfile": "Perfil de Operador", + "done": "Listo", + "none": "Ninguno", + "noAdditionalOperatorTags": "Sin etiquetas adicionales de operador", + "additionalTags": "etiquetas adicionales", + "additionalTagsTitle": "Etiquetas Adicionales", + "noTagsDefinedForProfile": "No hay etiquetas definidas para este perfil de operador.", + "noOperatorProfiles": "No hay perfiles de operador definidos", + "noOperatorProfilesMessage": "Cree perfiles de operador en Configuración para aplicar etiquetas adicionales a sus envíos de nodos." + }, + "layerSelector": { + "cannotChangeTileTypes": "No se pueden cambiar los tipos de teselas mientras se descargan áreas sin conexión", + "selectMapLayer": "Seleccionar Capa del Mapa", + "noTileProvidersAvailable": "No hay proveedores de teselas disponibles" } } \ No newline at end of file diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 209c921..429cde5 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -90,7 +90,6 @@ "simulate": "Simuler", "productionDescription": "Télécharger vers la base de données OSM en direct (visible pour tous les utilisateurs)", "sandboxDescription": "Les téléchargements vont vers le Sandbox OSM (sûr pour les tests, réinitialisé régulièrement).", - "sandboxNote": "NOTE: En raison des limitations d'OpenStreetMap, les caméras soumises au sandbox n'apparaîtront PAS sur la carte dans cette application.", "simulateDescription": "Simuler les téléchargements (ne contacte pas les serveurs OSM)" }, "auth": { @@ -220,6 +219,7 @@ "noAreasTitle": "Aucune zone hors ligne", "noAreasSubtitle": "Téléchargez une zone de carte pour utilisation hors ligne.", "provider": "Fournisseur", + "maxZoom": "Zoom max", "zoomLevels": "Z{}-{}", "latitude": "Lat", "longitude": "Lon", @@ -237,5 +237,22 @@ "megabytes": "Mo", "kilobytes": "Ko", "progress": "{}%" + }, + "refineTagsSheet": { + "title": "Affiner les Étiquettes", + "operatorProfile": "Profil d'Opérateur", + "done": "Terminé", + "none": "Aucun", + "noAdditionalOperatorTags": "Aucune étiquette d'opérateur supplémentaire", + "additionalTags": "étiquettes supplémentaires", + "additionalTagsTitle": "Étiquettes Supplémentaires", + "noTagsDefinedForProfile": "Aucune étiquette définie pour ce profil d'opérateur.", + "noOperatorProfiles": "Aucun profil d'opérateur défini", + "noOperatorProfilesMessage": "Créez des profils d'opérateur dans les Paramètres pour appliquer des étiquettes supplémentaires à vos soumissions de nœuds." + }, + "layerSelector": { + "cannotChangeTileTypes": "Impossible de changer les types de tuiles pendant le téléchargement des zones hors ligne", + "selectMapLayer": "Sélectionner la Couche de Carte", + "noTileProvidersAvailable": "Aucun fournisseur de tuiles disponible" } } \ No newline at end of file diff --git a/lib/localizations/it.json b/lib/localizations/it.json new file mode 100644 index 0000000..c80cb36 --- /dev/null +++ b/lib/localizations/it.json @@ -0,0 +1,258 @@ +{ + "language": { + "name": "Italiano" + }, + "app": { + "title": "DeFlock" + }, + "actions": { + "tagNode": "Nuovo Nodo", + "download": "Scarica", + "settings": "Impostazioni", + "edit": "Modifica", + "delete": "Elimina", + "cancel": "Annulla", + "ok": "OK", + "close": "Chiudi", + "submit": "Invia", + "saveEdit": "Salva Modifica", + "clear": "Pulisci" + }, + "followMe": { + "off": "Attiva seguimi (nord in alto)", + "northUp": "Attiva seguimi (rotazione)", + "rotating": "Disattiva seguimi" + }, + "settings": { + "title": "Impostazioni", + "language": "Lingua", + "systemDefault": "Predefinito del Sistema", + "aboutInfo": "Informazioni", + "aboutThisApp": "Informazioni su questa App", + "maxNodes": "Max nodi recuperati/disegnati", + "maxNodesSubtitle": "Imposta un limite superiore per il numero di nodi sulla mappa (predefinito: 250).", + "maxNodesWarning": "Probabilmente non vuoi farlo a meno che non sei assolutamente sicuro di avere una buona ragione per farlo.", + "offlineMode": "Modalità Offline", + "offlineModeSubtitle": "Disabilita tutte le richieste di rete tranne per aree locali/offline.", + "offlineModeWarningTitle": "Download Attivi", + "offlineModeWarningMessage": "L'attivazione della modalità offline cancellerà qualsiasi download di area attivo. Vuoi continuare?", + "enableOfflineMode": "Attiva Modalità Offline" + }, + "node": { + "title": "Nodo #{}", + "tagSheetTitle": "Tag Dispositivo di Sorveglianza", + "queuedForUpload": "Nodo in coda per il caricamento", + "editQueuedForUpload": "Modifica nodo in coda per il caricamento", + "deleteQueuedForUpload": "Eliminazione nodo in coda per il caricamento", + "confirmDeleteTitle": "Elimina Nodo", + "confirmDeleteMessage": "Sei sicuro di voler eliminare il nodo #{}? Questa azione non può essere annullata." + }, + "addNode": { + "profile": "Profilo", + "direction": "Direzione {}°", + "profileNoDirectionInfo": "Questo profilo non richiede una direzione.", + "mustBeLoggedIn": "Devi essere loggato per inviare nuovi nodi. Per favore accedi tramite Impostazioni.", + "enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per inviare nuovi nodi.", + "profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per inviare nuovi nodi.", + "refineTags": "Affina Tag", + "refineTagsWithProfile": "Affina Tag ({})" + }, + "editNode": { + "title": "Modifica Nodo #{}", + "profile": "Profilo", + "direction": "Direzione {}°", + "profileNoDirectionInfo": "Questo profilo non richiede una direzione.", + "mustBeLoggedIn": "Devi essere loggato per modificare i nodi. Per favore accedi tramite Impostazioni.", + "sandboxModeWarning": "Impossibile inviare modifiche di nodi di produzione alla sandbox. Passa alla modalità Produzione nelle Impostazioni per modificare i nodi.", + "enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per modificare i nodi.", + "profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per modificare i nodi.", + "refineTags": "Affina Tag", + "refineTagsWithProfile": "Affina Tag ({})" + }, + "download": { + "title": "Scarica Area Mappa", + "maxZoomLevel": "Livello zoom max", + "storageEstimate": "Stima archiviazione:", + "tilesAndSize": "{} tile, {} MB", + "minZoom": "Zoom min:", + "maxRecommendedZoom": "Zoom max raccomandato: Z{}", + "withinTileLimit": "Entro il limite di {} tile", + "exceedsTileLimit": "La selezione corrente supera il limite di {} tile", + "offlineModeWarning": "Download disabilitati in modalità offline. Disabilita la modalità offline per scaricare nuove aree.", + "downloadStarted": "Download avviato! Recupero tile e telecamere...", + "downloadFailed": "Impossibile avviare il download: {}" + }, + "uploadMode": { + "title": "Destinazione Upload", + "subtitle": "Scegli dove vengono caricate le telecamere", + "production": "Produzione", + "sandbox": "Sandbox", + "simulate": "Simula", + "productionDescription": "Carica nel database OSM dal vivo (visibile a tutti gli utenti)", + "sandboxDescription": "Gli upload vanno alla Sandbox OSM (sicuro per i test, si resetta regolarmente).", + "simulateDescription": "Simula upload (non contatta i server OSM)" + }, + "auth": { + "loggedInAs": "Loggato come {}", + "loginToOSM": "Accedi a OpenStreetMap", + "tapToLogout": "Tocca per disconnetterti", + "requiredToSubmit": "Richiesto per inviare dati delle telecamere", + "loggedOut": "Disconnesso", + "testConnection": "Testa Connessione", + "testConnectionSubtitle": "Verifica che le credenziali OSM funzionino", + "connectionOK": "Connessione OK - le credenziali sono valide", + "connectionFailed": "Connessione fallita - per favore accedi di nuovo" + }, + "queue": { + "pendingUploads": "Upload in sospeso: {}", + "simulateModeEnabled": "Modalità simulazione abilitata – upload simulati", + "sandboxMode": "Modalità sandbox – upload vanno alla Sandbox OSM", + "tapToViewQueue": "Tocca per vedere la coda", + "clearUploadQueue": "Pulisci Coda Upload", + "removeAllPending": "Rimuovi tutti i {} upload in sospeso", + "clearQueueTitle": "Pulisci Coda", + "clearQueueConfirm": "Rimuovere tutti i {} upload in sospeso?", + "queueCleared": "Coda pulita", + "uploadQueueTitle": "Coda Upload ({} elementi)", + "queueIsEmpty": "La coda è vuota", + "cameraWithIndex": "Telecamera {}", + "error": " (Errore)", + "completing": " (Completamento...)", + "destination": "Dest: {}", + "latitude": "Lat: {}", + "longitude": "Lon: {}", + "direction": "Direzione: {}°", + "attempts": "Tentativi: {}", + "uploadFailedRetry": "Upload fallito. Tocca riprova per tentare di nuovo.", + "retryUpload": "Riprova upload", + "clearAll": "Pulisci Tutto" + }, + "tileProviders": { + "title": "Fornitori di Tile", + "noProvidersConfigured": "Nessun fornitore di tile configurato", + "tileTypesCount": "{} tipi di tile", + "apiKeyConfigured": "Chiave API configurata", + "needsApiKey": "Richiede chiave API", + "editProvider": "Modifica Fornitore", + "addProvider": "Aggiungi Fornitore", + "deleteProvider": "Elimina Fornitore", + "deleteProviderConfirm": "Sei sicuro di voler eliminare \"{}\"?", + "providerName": "Nome Fornitore", + "providerNameHint": "es., Mappe Personalizzate Inc.", + "providerNameRequired": "Il nome del fornitore è obbligatorio", + "apiKey": "Chiave API (Opzionale)", + "apiKeyHint": "Inserisci la chiave API se richiesta dai tipi di tile", + "tileTypes": "Tipi di Tile", + "addType": "Aggiungi Tipo", + "noTileTypesConfigured": "Nessun tipo di tile configurato", + "atLeastOneTileTypeRequired": "È richiesto almeno un tipo di tile", + "manageTileProviders": "Gestisci Fornitori" + }, + "tileTypeEditor": { + "editTileType": "Modifica Tipo Tile", + "addTileType": "Aggiungi Tipo Tile", + "name": "Nome", + "nameHint": "es., Satellite", + "nameRequired": "Il nome è obbligatorio", + "urlTemplate": "Template URL", + "urlTemplateHint": "https://esempio.com/{z}/{x}/{y}.png", + "urlTemplateRequired": "Il template URL è obbligatorio", + "urlTemplatePlaceholders": "L'URL deve contenere i segnaposto {z}, {x} e {y}", + "attribution": "Attribuzione", + "attributionHint": "© Fornitore Mappe", + "attributionRequired": "L'attribuzione è obbligatoria", + "fetchPreview": "Ottieni Anteprima", + "previewTileLoaded": "Tile di anteprima caricato con successo", + "previewTileFailed": "Impossibile ottenere l'anteprima: {}", + "save": "Salva" + }, + "profiles": { + "nodeProfiles": "Profili Nodo", + "newProfile": "Nuovo Profilo", + "builtIn": "Integrato", + "custom": "Personalizzato", + "view": "Visualizza", + "deleteProfile": "Elimina Profilo", + "deleteProfileConfirm": "Sei sicuro di voler eliminare \"{}\"?", + "profileDeleted": "Profilo eliminato" + }, + "mapTiles": { + "title": "Tile Mappa", + "manageProviders": "Gestisci Fornitori" + }, + "profileEditor": { + "viewProfile": "Visualizza Profilo", + "newProfile": "Nuovo Profilo", + "editProfile": "Modifica Profilo", + "profileName": "Nome profilo", + "profileNameHint": "es., Telecamera ALPR Personalizzata", + "profileNameRequired": "Il nome del profilo è obbligatorio", + "requiresDirection": "Richiede Direzione", + "requiresDirectionSubtitle": "Se le telecamere di questo tipo necessitano di un tag direzione", + "submittable": "Inviabile", + "submittableSubtitle": "Se questo profilo può essere usato per invii di telecamere", + "osmTags": "Tag OSM", + "addTag": "Aggiungi Tag", + "saveProfile": "Salva Profilo", + "keyHint": "chiave", + "valueHint": "valore", + "atLeastOneTagRequired": "È richiesto almeno un tag", + "profileSaved": "Profilo \"{}\" salvato" + }, + "operatorProfileEditor": { + "newOperatorProfile": "Nuovo Profilo Operatore", + "editOperatorProfile": "Modifica Profilo Operatore", + "operatorName": "Nome operatore", + "operatorNameHint": "es., Dipartimento di Polizia di Austin", + "operatorNameRequired": "Il nome dell'operatore è obbligatorio", + "operatorProfileSaved": "Profilo operatore \"{}\" salvato" + }, + "operatorProfiles": { + "title": "Profili Operatore", + "noProfilesMessage": "Nessun profilo operatore definito. Creane uno per applicare tag operatore agli invii di nodi.", + "tagsCount": "{} tag", + "deleteOperatorProfile": "Elimina Profilo Operatore", + "deleteOperatorProfileConfirm": "Sei sicuro di voler eliminare \"{}\"?", + "operatorProfileDeleted": "Profilo operatore eliminato" + }, + "offlineAreas": { + "noAreasTitle": "Nessuna area offline", + "noAreasSubtitle": "Scarica un'area mappa per l'uso offline.", + "provider": "Fornitore", + "maxZoom": "Zoom max", + "zoomLevels": "Z{}-{}", + "latitude": "Lat", + "longitude": "Lon", + "tiles": "Tile", + "size": "Dimensione", + "cameras": "Telecamere", + "areaIdFallback": "Area {}...", + "renameArea": "Rinomina area", + "refreshWorldTiles": "Aggiorna/ri-scarica tile mondiali", + "deleteOfflineArea": "Elimina area offline", + "cancelDownload": "Annulla download", + "renameAreaDialogTitle": "Rinomina Area Offline", + "areaNameLabel": "Nome Area", + "renameButton": "Rinomina", + "megabytes": "MB", + "kilobytes": "KB", + "progress": "{}%" + }, + "refineTagsSheet": { + "title": "Affina Tag", + "operatorProfile": "Profilo Operatore", + "done": "Fatto", + "none": "Nessuno", + "noAdditionalOperatorTags": "Nessun tag operatore aggiuntivo", + "additionalTags": "tag aggiuntivi", + "additionalTagsTitle": "Tag Aggiuntivi", + "noTagsDefinedForProfile": "Nessun tag definito per questo profilo operatore.", + "noOperatorProfiles": "Nessun profilo operatore definito", + "noOperatorProfilesMessage": "Crea profili operatore nelle Impostazioni per applicare tag aggiuntivi ai tuoi invii di nodi." + }, + "layerSelector": { + "cannotChangeTileTypes": "Impossibile cambiare tipi di tile durante il download di aree offline", + "selectMapLayer": "Seleziona Livello Mappa", + "noTileProvidersAvailable": "Nessun fornitore di tile disponibile" + } +} \ No newline at end of file diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json new file mode 100644 index 0000000..3ba76e9 --- /dev/null +++ b/lib/localizations/pt.json @@ -0,0 +1,258 @@ +{ + "language": { + "name": "Português" + }, + "app": { + "title": "DeFlock" + }, + "actions": { + "tagNode": "Novo Nó", + "download": "Baixar", + "settings": "Configurações", + "edit": "Editar", + "delete": "Excluir", + "cancel": "Cancelar", + "ok": "OK", + "close": "Fechar", + "submit": "Enviar", + "saveEdit": "Salvar Edição", + "clear": "Limpar" + }, + "followMe": { + "off": "Ativar seguir-me (norte para cima)", + "northUp": "Ativar seguir-me (rotação)", + "rotating": "Desativar seguir-me" + }, + "settings": { + "title": "Configurações", + "language": "Idioma", + "systemDefault": "Padrão do Sistema", + "aboutInfo": "Sobre / Informações", + "aboutThisApp": "Sobre este App", + "maxNodes": "Máx. de nós obtidos/desenhados", + "maxNodesSubtitle": "Definir um limite superior para o número de nós no mapa (padrão: 250).", + "maxNodesWarning": "Você provavelmente não quer fazer isso a menos que tenha certeza absoluta de que tem uma boa razão para isso.", + "offlineMode": "Modo Offline", + "offlineModeSubtitle": "Desabilitar todas as requisições de rede exceto para áreas locais/offline.", + "offlineModeWarningTitle": "Downloads Ativos", + "offlineModeWarningMessage": "Ativar o modo offline cancelará qualquer download de área ativo. Deseja continuar?", + "enableOfflineMode": "Ativar Modo Offline" + }, + "node": { + "title": "Nó #{}", + "tagSheetTitle": "Tags do Dispositivo de Vigilância", + "queuedForUpload": "Nó na fila para envio", + "editQueuedForUpload": "Edição de nó na fila para envio", + "deleteQueuedForUpload": "Exclusão de nó na fila para envio", + "confirmDeleteTitle": "Excluir Nó", + "confirmDeleteMessage": "Tem certeza de que deseja excluir o nó #{}? Esta ação não pode ser desfeita." + }, + "addNode": { + "profile": "Perfil", + "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.", + "enableSubmittableProfile": "Ative um perfil enviável nas Configurações para enviar novos nós.", + "profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para enviar novos nós.", + "refineTags": "Refinar Tags", + "refineTagsWithProfile": "Refinar Tags ({})" + }, + "editNode": { + "title": "Editar Nó #{}", + "profile": "Perfil", + "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.", + "sandboxModeWarning": "Não é possível enviar edições de nós de produção para o sandbox. Mude para o modo Produção nas Configurações para editar nós.", + "enableSubmittableProfile": "Ative um perfil enviável nas Configurações para editar nós.", + "profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para editar nós.", + "refineTags": "Refinar Tags", + "refineTagsWithProfile": "Refinar Tags ({})" + }, + "download": { + "title": "Baixar Área do Mapa", + "maxZoomLevel": "Nível máx. de zoom", + "storageEstimate": "Estimativa de armazenamento:", + "tilesAndSize": "{} tiles, {} MB", + "minZoom": "Zoom mín.:", + "maxRecommendedZoom": "Zoom máx. recomendado: Z{}", + "withinTileLimit": "Dentro do limite de {} tiles", + "exceedsTileLimit": "A seleção atual excede o limite de {} tiles", + "offlineModeWarning": "Downloads desabilitados no modo offline. Desative o modo offline para baixar novas áreas.", + "downloadStarted": "Download iniciado! Buscando tiles e câmeras...", + "downloadFailed": "Falha ao iniciar o download: {}" + }, + "uploadMode": { + "title": "Destino do Upload", + "subtitle": "Escolha onde as câmeras são enviadas", + "production": "Produção", + "sandbox": "Sandbox", + "simulate": "Simular", + "productionDescription": "Enviar para o banco de dados OSM ao vivo (visível para todos os usuários)", + "sandboxDescription": "Uploads vão para o Sandbox OSM (seguro para testes, redefine regularmente).", + "simulateDescription": "Simular uploads (não contacta servidores OSM)" + }, + "auth": { + "loggedInAs": "Logado como {}", + "loginToOSM": "Fazer login no OpenStreetMap", + "tapToLogout": "Toque para sair", + "requiredToSubmit": "Necessário para enviar dados de câmeras", + "loggedOut": "Deslogado", + "testConnection": "Testar Conexão", + "testConnectionSubtitle": "Verificar se as credenciais OSM estão funcionando", + "connectionOK": "Conexão OK - credenciais são válidas", + "connectionFailed": "Conexão falhou - por favor, faça login novamente" + }, + "queue": { + "pendingUploads": "Uploads pendentes: {}", + "simulateModeEnabled": "Modo simulação ativado – uploads simulados", + "sandboxMode": "Modo sandbox – uploads vão para o Sandbox OSM", + "tapToViewQueue": "Toque para ver a fila", + "clearUploadQueue": "Limpar Fila de Upload", + "removeAllPending": "Remover todos os {} uploads pendentes", + "clearQueueTitle": "Limpar Fila", + "clearQueueConfirm": "Remover todos os {} uploads pendentes?", + "queueCleared": "Fila limpa", + "uploadQueueTitle": "Fila de Upload ({} itens)", + "queueIsEmpty": "A fila está vazia", + "cameraWithIndex": "Câmera {}", + "error": " (Erro)", + "completing": " (Completando...)", + "destination": "Dest: {}", + "latitude": "Lat: {}", + "longitude": "Lon: {}", + "direction": "Direção: {}°", + "attempts": "Tentativas: {}", + "uploadFailedRetry": "Upload falhou. Toque em tentar novamente para tentar novamente.", + "retryUpload": "Tentar upload novamente", + "clearAll": "Limpar Tudo" + }, + "tileProviders": { + "title": "Provedores de Tiles", + "noProvidersConfigured": "Nenhum provedor de tiles configurado", + "tileTypesCount": "{} tipos de tiles", + "apiKeyConfigured": "Chave API configurada", + "needsApiKey": "Precisa de chave API", + "editProvider": "Editar Provedor", + "addProvider": "Adicionar Provedor", + "deleteProvider": "Excluir Provedor", + "deleteProviderConfirm": "Tem certeza de que deseja excluir \"{}\"?", + "providerName": "Nome do Provedor", + "providerNameHint": "ex., Mapas Personalizados Inc.", + "providerNameRequired": "Nome do provedor é obrigatório", + "apiKey": "Chave API (Opcional)", + "apiKeyHint": "Insira a chave API se necessária pelos tipos de tiles", + "tileTypes": "Tipos de Tiles", + "addType": "Adicionar Tipo", + "noTileTypesConfigured": "Nenhum tipo de tile configurado", + "atLeastOneTileTypeRequired": "Pelo menos um tipo de tile é obrigatório", + "manageTileProviders": "Gerenciar Provedores" + }, + "tileTypeEditor": { + "editTileType": "Editar Tipo de Tile", + "addTileType": "Adicionar Tipo de Tile", + "name": "Nome", + "nameHint": "ex., Satélite", + "nameRequired": "Nome é obrigatório", + "urlTemplate": "Modelo de URL", + "urlTemplateHint": "https://exemplo.com/{z}/{x}/{y}.png", + "urlTemplateRequired": "Modelo de URL é obrigatório", + "urlTemplatePlaceholders": "URL deve conter os marcadores {z}, {x} e {y}", + "attribution": "Atribuição", + "attributionHint": "© Provedor de Mapas", + "attributionRequired": "Atribuição é obrigatória", + "fetchPreview": "Buscar Preview", + "previewTileLoaded": "Tile de preview carregado com sucesso", + "previewTileFailed": "Falha ao buscar preview: {}", + "save": "Salvar" + }, + "profiles": { + "nodeProfiles": "Perfis de Nó", + "newProfile": "Novo Perfil", + "builtIn": "Integrado", + "custom": "Personalizado", + "view": "Ver", + "deleteProfile": "Excluir Perfil", + "deleteProfileConfirm": "Tem certeza de que deseja excluir \"{}\"?", + "profileDeleted": "Perfil excluído" + }, + "mapTiles": { + "title": "Tiles do Mapa", + "manageProviders": "Gerenciar Provedores" + }, + "profileEditor": { + "viewProfile": "Ver Perfil", + "newProfile": "Novo Perfil", + "editProfile": "Editar Perfil", + "profileName": "Nome do perfil", + "profileNameHint": "ex., Câmera ALPR Personalizada", + "profileNameRequired": "Nome do perfil é obrigatório", + "requiresDirection": "Requer Direção", + "requiresDirectionSubtitle": "Se câmeras deste tipo precisam de uma tag de direção", + "submittable": "Enviável", + "submittableSubtitle": "Se este perfil pode ser usado para envios de câmeras", + "osmTags": "Tags OSM", + "addTag": "Adicionar Tag", + "saveProfile": "Salvar Perfil", + "keyHint": "chave", + "valueHint": "valor", + "atLeastOneTagRequired": "Pelo menos uma tag é obrigatória", + "profileSaved": "Perfil \"{}\" salvo" + }, + "operatorProfileEditor": { + "newOperatorProfile": "Novo Perfil de Operador", + "editOperatorProfile": "Editar Perfil de Operador", + "operatorName": "Nome do operador", + "operatorNameHint": "ex., Departamento de Polícia de Austin", + "operatorNameRequired": "Nome do operador é obrigatório", + "operatorProfileSaved": "Perfil de operador \"{}\" salvo" + }, + "operatorProfiles": { + "title": "Perfis de Operador", + "noProfilesMessage": "Nenhum perfil de operador definido. Crie um para aplicar tags de operador aos envios de nós.", + "tagsCount": "{} tags", + "deleteOperatorProfile": "Excluir Perfil de Operador", + "deleteOperatorProfileConfirm": "Tem certeza de que deseja excluir \"{}\"?", + "operatorProfileDeleted": "Perfil de operador excluído" + }, + "offlineAreas": { + "noAreasTitle": "Nenhuma área offline", + "noAreasSubtitle": "Baixe uma área do mapa para uso offline.", + "provider": "Provedor", + "maxZoom": "Zoom máx", + "zoomLevels": "Z{}-{}", + "latitude": "Lat", + "longitude": "Lon", + "tiles": "Tiles", + "size": "Tamanho", + "cameras": "Câmeras", + "areaIdFallback": "Área {}...", + "renameArea": "Renomear área", + "refreshWorldTiles": "Atualizar/rebaixar tiles mundiais", + "deleteOfflineArea": "Excluir área offline", + "cancelDownload": "Cancelar download", + "renameAreaDialogTitle": "Renomear Área Offline", + "areaNameLabel": "Nome da Área", + "renameButton": "Renomear", + "megabytes": "MB", + "kilobytes": "KB", + "progress": "{}%" + }, + "refineTagsSheet": { + "title": "Refinar Tags", + "operatorProfile": "Perfil de Operador", + "done": "Concluído", + "none": "Nenhum", + "noAdditionalOperatorTags": "Nenhuma tag adicional de operador", + "additionalTags": "tags adicionais", + "additionalTagsTitle": "Tags Adicionais", + "noTagsDefinedForProfile": "Nenhuma tag definida para este perfil de operador.", + "noOperatorProfiles": "Nenhum perfil de operador definido", + "noOperatorProfilesMessage": "Crie perfis de operador nas Configurações para aplicar tags adicionais aos seus envios de nós." + }, + "layerSelector": { + "cannotChangeTileTypes": "Não é possível alterar tipos de tiles durante o download de áreas offline", + "selectMapLayer": "Selecionar Camada do Mapa", + "noTileProvidersAvailable": "Nenhum provedor de tiles disponível" + } +} \ No newline at end of file diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json new file mode 100644 index 0000000..222fc4d --- /dev/null +++ b/lib/localizations/zh.json @@ -0,0 +1,258 @@ +{ + "language": { + "name": "中文" + }, + "app": { + "title": "DeFlock" + }, + "actions": { + "tagNode": "新建节点", + "download": "下载", + "settings": "设置", + "edit": "编辑", + "delete": "删除", + "cancel": "取消", + "ok": "确定", + "close": "关闭", + "submit": "提交", + "saveEdit": "保存编辑", + "clear": "清空" + }, + "followMe": { + "off": "启用跟随模式(北向上)", + "northUp": "启用跟随模式(旋转)", + "rotating": "禁用跟随模式" + }, + "settings": { + "title": "设置", + "language": "语言", + "systemDefault": "系统默认", + "aboutInfo": "关于 / 信息", + "aboutThisApp": "关于此应用", + "maxNodes": "最大节点获取/绘制数", + "maxNodesSubtitle": "设置地图上节点数量的上限(默认:250)。", + "maxNodesWarning": "除非您确定有充分的理由,否则您可能不想这样做。", + "offlineMode": "离线模式", + "offlineModeSubtitle": "禁用除本地/离线区域外的所有网络请求。", + "offlineModeWarningTitle": "活动下载", + "offlineModeWarningMessage": "启用离线模式将取消任何活动的区域下载。您要继续吗?", + "enableOfflineMode": "启用离线模式" + }, + "node": { + "title": "节点 #{}", + "tagSheetTitle": "监控设备标签", + "queuedForUpload": "节点已排队上传", + "editQueuedForUpload": "节点编辑已排队上传", + "deleteQueuedForUpload": "节点删除已排队上传", + "confirmDeleteTitle": "删除节点", + "confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。" + }, + "addNode": { + "profile": "配置文件", + "direction": "方向 {}°", + "profileNoDirectionInfo": "此配置文件不需要方向。", + "mustBeLoggedIn": "您必须登录才能提交新节点。请通过设置登录。", + "enableSubmittableProfile": "在设置中启用可提交的配置文件以提交新节点。", + "profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来提交新节点。", + "refineTags": "细化标签", + "refineTagsWithProfile": "细化标签({})" + }, + "editNode": { + "title": "编辑节点 #{}", + "profile": "配置文件", + "direction": "方向 {}°", + "profileNoDirectionInfo": "此配置文件不需要方向。", + "mustBeLoggedIn": "您必须登录才能编辑节点。请通过设置登录。", + "sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。", + "enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。", + "profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。", + "refineTags": "细化标签", + "refineTagsWithProfile": "细化标签({})" + }, + "download": { + "title": "下载地图区域", + "maxZoomLevel": "最大缩放级别", + "storageEstimate": "存储估算:", + "tilesAndSize": "{} 瓦片,{} MB", + "minZoom": "最小缩放:", + "maxRecommendedZoom": "最大推荐缩放:Z{}", + "withinTileLimit": "在 {} 瓦片限制内", + "exceedsTileLimit": "当前选择超出 {} 瓦片限制", + "offlineModeWarning": "离线模式下禁用下载。禁用离线模式以下载新区域。", + "downloadStarted": "下载已开始!正在获取瓦片和摄像头...", + "downloadFailed": "启动下载失败:{}" + }, + "uploadMode": { + "title": "上传目标", + "subtitle": "选择摄像头上传位置", + "production": "生产环境", + "sandbox": "沙盒", + "simulate": "模拟", + "productionDescription": "上传到实时 OSM 数据库(对所有用户可见)", + "sandboxDescription": "上传到 OSM 沙盒(测试安全,定期重置)。", + "simulateDescription": "模拟上传(不联系 OSM 服务器)" + }, + "auth": { + "loggedInAs": "已登录为 {}", + "loginToOSM": "登录 OpenStreetMap", + "tapToLogout": "点击登出", + "requiredToSubmit": "提交摄像头数据所需", + "loggedOut": "已登出", + "testConnection": "测试连接", + "testConnectionSubtitle": "验证 OSM 凭据是否有效", + "connectionOK": "连接正常 - 凭据有效", + "connectionFailed": "连接失败 - 请重新登录" + }, + "queue": { + "pendingUploads": "待上传:{}", + "simulateModeEnabled": "模拟模式已启用 – 上传已模拟", + "sandboxMode": "沙盒模式 – 上传到 OSM 沙盒", + "tapToViewQueue": "点击查看队列", + "clearUploadQueue": "清空上传队列", + "removeAllPending": "移除所有 {} 个待上传项", + "clearQueueTitle": "清空队列", + "clearQueueConfirm": "移除所有 {} 个待上传项?", + "queueCleared": "队列已清空", + "uploadQueueTitle": "上传队列({} 项)", + "queueIsEmpty": "队列为空", + "cameraWithIndex": "摄像头 {}", + "error": "(错误)", + "completing": "(完成中...)", + "destination": "目标:{}", + "latitude": "纬度:{}", + "longitude": "经度:{}", + "direction": "方向:{}°", + "attempts": "尝试次数:{}", + "uploadFailedRetry": "上传失败。点击重试再次尝试。", + "retryUpload": "重试上传", + "clearAll": "全部清空" + }, + "tileProviders": { + "title": "瓦片提供商", + "noProvidersConfigured": "未配置瓦片提供商", + "tileTypesCount": "{} 种瓦片类型", + "apiKeyConfigured": "API 密钥已配置", + "needsApiKey": "需要 API 密钥", + "editProvider": "编辑提供商", + "addProvider": "添加提供商", + "deleteProvider": "删除提供商", + "deleteProviderConfirm": "您确定要删除 \"{}\" 吗?", + "providerName": "提供商名称", + "providerNameHint": "例如,自定义地图公司", + "providerNameRequired": "提供商名称为必填项", + "apiKey": "API 密钥(可选)", + "apiKeyHint": "如果瓦片类型需要,请输入 API 密钥", + "tileTypes": "瓦片类型", + "addType": "添加类型", + "noTileTypesConfigured": "未配置瓦片类型", + "atLeastOneTileTypeRequired": "至少需要一种瓦片类型", + "manageTileProviders": "管理提供商" + }, + "tileTypeEditor": { + "editTileType": "编辑瓦片类型", + "addTileType": "添加瓦片类型", + "name": "名称", + "nameHint": "例如,卫星", + "nameRequired": "名称为必填项", + "urlTemplate": "URL 模板", + "urlTemplateHint": "https://example.com/{z}/{x}/{y}.png", + "urlTemplateRequired": "URL 模板为必填项", + "urlTemplatePlaceholders": "URL 必须包含 {z}、{x} 和 {y} 占位符", + "attribution": "归属", + "attributionHint": "© 地图提供商", + "attributionRequired": "归属为必填项", + "fetchPreview": "获取预览", + "previewTileLoaded": "预览瓦片加载成功", + "previewTileFailed": "获取预览失败:{}", + "save": "保存" + }, + "profiles": { + "nodeProfiles": "节点配置文件", + "newProfile": "新建配置文件", + "builtIn": "内置", + "custom": "自定义", + "view": "查看", + "deleteProfile": "删除配置文件", + "deleteProfileConfirm": "您确定要删除 \"{}\" 吗?", + "profileDeleted": "配置文件已删除" + }, + "mapTiles": { + "title": "地图瓦片", + "manageProviders": "管理提供商" + }, + "profileEditor": { + "viewProfile": "查看配置文件", + "newProfile": "新建配置文件", + "editProfile": "编辑配置文件", + "profileName": "配置文件名称", + "profileNameHint": "例如,自定义 ALPR 摄像头", + "profileNameRequired": "配置文件名称为必填项", + "requiresDirection": "需要方向", + "requiresDirectionSubtitle": "此类型的摄像头是否需要方向标签", + "submittable": "可提交", + "submittableSubtitle": "此配置文件是否可用于摄像头提交", + "osmTags": "OSM 标签", + "addTag": "添加标签", + "saveProfile": "保存配置文件", + "keyHint": "键", + "valueHint": "值", + "atLeastOneTagRequired": "至少需要一个标签", + "profileSaved": "配置文件 \"{}\" 已保存" + }, + "operatorProfileEditor": { + "newOperatorProfile": "新建运营商配置文件", + "editOperatorProfile": "编辑运营商配置文件", + "operatorName": "运营商名称", + "operatorNameHint": "例如,奥斯汀警察局", + "operatorNameRequired": "运营商名称为必填项", + "operatorProfileSaved": "运营商配置文件 \"{}\" 已保存" + }, + "operatorProfiles": { + "title": "运营商配置文件", + "noProfilesMessage": "未定义运营商配置文件。创建一个以将运营商标签应用于节点提交。", + "tagsCount": "{} 个标签", + "deleteOperatorProfile": "删除运营商配置文件", + "deleteOperatorProfileConfirm": "您确定要删除 \"{}\" 吗?", + "operatorProfileDeleted": "运营商配置文件已删除" + }, + "offlineAreas": { + "noAreasTitle": "无离线区域", + "noAreasSubtitle": "下载地图区域以供离线使用。", + "provider": "提供商", + "maxZoom": "最大缩放", + "zoomLevels": "Z{}-{}", + "latitude": "纬度", + "longitude": "经度", + "tiles": "瓦片", + "size": "大小", + "cameras": "摄像头", + "areaIdFallback": "区域 {}...", + "renameArea": "重命名区域", + "refreshWorldTiles": "刷新/重新下载世界瓦片", + "deleteOfflineArea": "删除离线区域", + "cancelDownload": "取消下载", + "renameAreaDialogTitle": "重命名离线区域", + "areaNameLabel": "区域名称", + "renameButton": "重命名", + "megabytes": "MB", + "kilobytes": "KB", + "progress": "{}%" + }, + "refineTagsSheet": { + "title": "细化标签", + "operatorProfile": "运营商配置文件", + "done": "完成", + "none": "无", + "noAdditionalOperatorTags": "无额外运营商标签", + "additionalTags": "额外标签", + "additionalTagsTitle": "额外标签", + "noTagsDefinedForProfile": "此运营商配置文件未定义标签。", + "noOperatorProfiles": "未定义运营商配置文件", + "noOperatorProfilesMessage": "在设置中创建运营商配置文件,以将额外标签应用于您的节点提交。" + }, + "layerSelector": { + "cannotChangeTileTypes": "在下载离线区域时无法更改瓦片类型", + "selectMapLayer": "选择地图图层", + "noTileProvidersAvailable": "无可用瓦片提供商" + } +} \ No newline at end of file diff --git a/lib/models/osm_camera_node.dart b/lib/models/osm_node.dart similarity index 91% rename from lib/models/osm_camera_node.dart rename to lib/models/osm_node.dart index dab9cd5..84c1a77 100644 --- a/lib/models/osm_camera_node.dart +++ b/lib/models/osm_node.dart @@ -1,11 +1,11 @@ import 'package:latlong2/latlong.dart'; -class OsmCameraNode { +class OsmNode { final int id; final LatLng coord; final Map tags; - OsmCameraNode({ + OsmNode({ required this.id, required this.coord, required this.tags, @@ -18,14 +18,14 @@ class OsmCameraNode { 'tags': tags, }; - factory OsmCameraNode.fromJson(Map json) { + factory OsmNode.fromJson(Map json) { final tags = {}; if (json['tags'] != null) { (json['tags'] as Map).forEach((k, v) { tags[k.toString()] = v.toString(); }); } - return OsmCameraNode( + return OsmNode( id: json['id'] is int ? json['id'] as int : int.tryParse(json['id'].toString()) ?? 0, coord: LatLng((json['lat'] as num).toDouble(), (json['lon'] as num).toDouble()), tags: tags, @@ -51,5 +51,4 @@ class OsmCameraNode { final normalized = ((val % 360) + 360) % 360; return normalized; } -} - +} \ No newline at end of file diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 9131e92..28fc96e 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -8,6 +8,7 @@ import 'settings_screen_sections/offline_areas_section.dart'; import 'settings_screen_sections/offline_mode_section.dart'; import 'settings_screen_sections/about_section.dart'; import 'settings_screen_sections/max_nodes_section.dart'; +import 'settings_screen_sections/proximity_alerts_section.dart'; import 'settings_screen_sections/tile_provider_section.dart'; import 'settings_screen_sections/language_section.dart'; import '../services/localization_service.dart'; @@ -40,6 +41,8 @@ class SettingsScreen extends StatelessWidget { const Divider(), const MaxNodesSection(), const Divider(), + const ProximityAlertsSection(), + const Divider(), const TileProviderSection(), const Divider(), const OfflineModeSection(), diff --git a/lib/screens/settings_screen_sections/offline_areas_section.dart b/lib/screens/settings_screen_sections/offline_areas_section.dart index 2c895cf..54e7ec3 100644 --- a/lib/screens/settings_screen_sections/offline_areas_section.dart +++ b/lib/screens/settings_screen_sections/offline_areas_section.dart @@ -49,7 +49,7 @@ class _OfflineAreasSectionState extends State { : '--'; String subtitle = '${locService.t('offlineAreas.provider')}: ${area.tileProviderDisplay}\n' + - 'Max zoom: Z${area.maxZoom}' + '\n' + + '${locService.t('offlineAreas.maxZoom')}: Z${area.maxZoom}' + '\n' + '${locService.t('offlineAreas.latitude')}: ${area.bounds.southWest.latitude.toStringAsFixed(3)}, ${area.bounds.southWest.longitude.toStringAsFixed(3)}\n' + '${locService.t('offlineAreas.latitude')}: ${area.bounds.northEast.latitude.toStringAsFixed(3)}, ${area.bounds.northEast.longitude.toStringAsFixed(3)}'; diff --git a/lib/screens/settings_screen_sections/proximity_alerts_section.dart b/lib/screens/settings_screen_sections/proximity_alerts_section.dart new file mode 100644 index 0000000..437147c --- /dev/null +++ b/lib/screens/settings_screen_sections/proximity_alerts_section.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../../app_state.dart'; +import '../../services/localization_service.dart'; +import '../../services/proximity_alert_service.dart'; +import '../../dev_config.dart'; + +/// Settings section for proximity alerts configuration +/// Follows brutalist principles: simple, explicit UI that matches existing patterns +class ProximityAlertsSection extends StatefulWidget { + const ProximityAlertsSection({super.key}); + + @override + State createState() => _ProximityAlertsSectionState(); +} + +class _ProximityAlertsSectionState extends State { + late final TextEditingController _distanceController; + bool _notificationsEnabled = false; + bool _checkingPermissions = false; + + @override + void initState() { + super.initState(); + final appState = context.read(); + _distanceController = TextEditingController( + text: appState.proximityAlertDistance.toString(), + ); + _checkNotificationPermissions(); + } + + Future _checkNotificationPermissions() async { + setState(() { + _checkingPermissions = true; + }); + + final enabled = await ProximityAlertService().areNotificationsEnabled(); + + if (mounted) { + setState(() { + _notificationsEnabled = enabled; + _checkingPermissions = false; + }); + } + } + + Future _requestNotificationPermissions() async { + setState(() { + _checkingPermissions = true; + }); + + final enabled = await ProximityAlertService().requestNotificationPermissions(); + + if (mounted) { + setState(() { + _notificationsEnabled = enabled; + _checkingPermissions = false; + }); + } + } + + @override + void dispose() { + _distanceController.dispose(); + super.dispose(); + } + + void _updateDistance(AppState appState) { + final text = _distanceController.text.trim(); + final distance = int.tryParse(text); + if (distance != null) { + appState.setProximityAlertDistance(distance); + } else { + // Reset to current value if invalid + _distanceController.text = appState.proximityAlertDistance.toString(); + } + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, appState, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Proximity Alerts', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + + // Enable/disable toggle + SwitchListTile( + title: const Text('Enable proximity alerts'), + subtitle: Text( + 'Get notified when approaching surveillance devices\n' + 'Uses extra battery for continuous location monitoring\n' + '${_notificationsEnabled ? "✓ Notifications enabled" : "⚠ Notifications disabled"}', + style: const TextStyle(fontSize: 12), + ), + value: appState.proximityAlertsEnabled, + onChanged: (enabled) { + appState.setProximityAlertsEnabled(enabled); + if (enabled && !_notificationsEnabled) { + // Automatically try to request permissions when enabling + _requestNotificationPermissions(); + } + }, + contentPadding: EdgeInsets.zero, + ), + + // Notification permissions section (only show when proximity alerts are enabled) + if (appState.proximityAlertsEnabled && !_notificationsEnabled && !_checkingPermissions) ...[ + const SizedBox(height: 8), + 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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.notifications_off, color: Colors.orange, size: 20), + const SizedBox(width: 8), + const Text( + 'Notification permission required', + style: TextStyle(fontWeight: FontWeight.w600), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Push notifications are disabled. You\'ll only see in-app alerts and won\'t be notified when the app is in background.', + style: TextStyle(fontSize: 12), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + onPressed: _requestNotificationPermissions, + icon: const Icon(Icons.settings, size: 16), + label: const Text('Enable Notifications'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 32), + textStyle: const TextStyle(fontSize: 12), + ), + ), + ], + ), + ), + ], + + // Loading indicator + if (_checkingPermissions) ...[ + const SizedBox(height: 8), + const Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 8), + Text('Checking permissions...', style: TextStyle(fontSize: 12)), + ], + ), + ], + + // Distance setting (only show when enabled) + if (appState.proximityAlertsEnabled) ...[ + const SizedBox(height: 12), + Row( + children: [ + const Text('Alert distance: '), + SizedBox( + width: 80, + child: TextField( + controller: _distanceController, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + decoration: const InputDecoration( + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _updateDistance(appState), + onEditingComplete: () => _updateDistance(appState), + ), + ), + const SizedBox(width: 8), + const Text('meters'), + ], + ), + const SizedBox(height: 8), + Text( + 'Range: $kProximityAlertMinDistance-$kProximityAlertMaxDistance meters (default: $kProximityAlertDefaultDistance)', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6), + ), + ), + ], + ], + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/screens/settings_screen_sections/upload_mode_section.dart b/lib/screens/settings_screen_sections/upload_mode_section.dart index c56d676..d5abcc0 100644 --- a/lib/screens/settings_screen_sections/upload_mode_section.dart +++ b/lib/screens/settings_screen_sections/upload_mode_section.dart @@ -53,19 +53,9 @@ class UploadModeSection extends StatelessWidget { style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7)) ); case UploadMode.sandbox: - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - locService.t('uploadMode.sandboxDescription'), - style: const TextStyle(fontSize: 12, color: Colors.orange), - ), - const SizedBox(height: 2), - Text( - locService.t('uploadMode.sandboxNote'), - style: const TextStyle(fontSize: 11, color: Colors.redAccent), - ), - ], + return Text( + locService.t('uploadMode.sandboxDescription'), + style: const TextStyle(fontSize: 12, color: Colors.orange), ); case UploadMode.simulate: default: diff --git a/lib/services/localization_service.dart b/lib/services/localization_service.dart index 78fc77f..41bf904 100644 --- a/lib/services/localization_service.dart +++ b/lib/services/localization_service.dart @@ -24,9 +24,44 @@ class LocalizationService extends ChangeNotifier { } Future _discoverAvailableLanguages() async { - // For now, we'll hardcode the languages we support - // In the future, this could scan the assets directory - _availableLanguages = ['en', 'es', 'fr', 'de']; + _availableLanguages = []; + + try { + // Get the asset manifest to find all localization files + final manifestContent = await rootBundle.loadString('AssetManifest.json'); + final Map manifestMap = json.decode(manifestContent); + + // Find all .json files in lib/localizations/ + final localizationFiles = manifestMap.keys + .where((String key) => key.startsWith('lib/localizations/') && key.endsWith('.json')) + .toList(); + + for (final filePath in localizationFiles) { + // Extract language code from filename (e.g., 'lib/localizations/pt.json' -> 'pt') + final fileName = filePath.split('/').last; + final languageCode = fileName.substring(0, fileName.length - 5); // Remove '.json' + + try { + // Try to load and parse the file to ensure it's valid + final jsonString = await rootBundle.loadString(filePath); + final parsedJson = json.decode(jsonString); + + // Basic validation - ensure it has the expected structure + if (parsedJson is Map && parsedJson.containsKey('language')) { + _availableLanguages.add(languageCode); + debugPrint('Found localization: $languageCode'); + } + } catch (e) { + debugPrint('Failed to load localization file $filePath: $e'); + } + } + } catch (e) { + debugPrint('Failed to read AssetManifest.json: $e'); + // If manifest reading fails, we'll have an empty list + // The system will handle this gracefully by falling back to 'en' in _loadSavedLanguage + } + + debugPrint('Available languages: $_availableLanguages'); } Future _loadSavedLanguage() async { diff --git a/lib/services/map_data_provider.dart b/lib/services/map_data_provider.dart index 9c5ad29..9b9bb83 100644 --- a/lib/services/map_data_provider.dart +++ b/lib/services/map_data_provider.dart @@ -3,7 +3,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter/foundation.dart'; import '../models/node_profile.dart'; -import '../models/osm_camera_node.dart'; +import '../models/osm_node.dart'; import '../app_state.dart'; import 'map_data_submodules/nodes_from_overpass.dart'; import 'map_data_submodules/nodes_from_osm_api.dart'; @@ -35,7 +35,7 @@ class MapDataProvider { /// Fetch surveillance nodes from OSM/Overpass or local storage. /// Remote is default. If source is MapSource.auto, remote is tried first unless offline. - Future> getNodes({ + Future> getNodes({ required LatLngBounds bounds, required List profiles, UploadMode uploadMode = UploadMode.production, @@ -70,7 +70,7 @@ class MapDataProvider { if (uploadMode == UploadMode.sandbox) { // Offline + Sandbox = no nodes (local cache is production data) debugPrint('[MapDataProvider] Offline + Sandbox mode: returning no nodes (local cache is production data)'); - return []; + return []; } else { // Offline + Production = use local cache return fetchLocalNodes( @@ -90,7 +90,7 @@ class MapDataProvider { ); } else { // Production mode: fetch both remote and local, then merge with deduplication - final List>> futures = []; + final List>> futures = []; // Always try to get local nodes (fast, cached) futures.add(fetchLocalNodes( @@ -107,7 +107,7 @@ class MapDataProvider { maxResults: AppState.instance.maxCameras, ).catchError((e) { debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Continuing with local only.'); - return []; // Return empty list on remote failure + return []; // Return empty list on remote failure })); // Wait for both, then merge with deduplication by node ID @@ -116,7 +116,7 @@ class MapDataProvider { final remoteNodes = results[1]; // Merge with deduplication - prefer remote data over local for same node ID - final Map mergedNodes = {}; + final Map mergedNodes = {}; // Add local nodes first for (final node in localNodes) { @@ -140,7 +140,7 @@ class MapDataProvider { /// Bulk/paged node fetch for offline downloads (handling paging, dedup, and Overpass retries) /// Only use for offline area download, not for map browsing! Ignores maxCameras config. - Future> getAllNodesForDownload({ + Future> getAllNodesForDownload({ required LatLngBounds bounds, required List profiles, UploadMode uploadMode = UploadMode.production, @@ -214,7 +214,7 @@ class MapDataProvider { } /// Fetch remote nodes with Overpass first, OSM API fallback - Future> _fetchRemoteNodes({ + Future> _fetchRemoteNodes({ required LatLngBounds bounds, required List profiles, UploadMode uploadMode = UploadMode.production, diff --git a/lib/services/map_data_submodules/nodes_from_local.dart b/lib/services/map_data_submodules/nodes_from_local.dart index 46ec4b4..4ca3728 100644 --- a/lib/services/map_data_submodules/nodes_from_local.dart +++ b/lib/services/map_data_submodules/nodes_from_local.dart @@ -3,19 +3,19 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; -import '../../models/osm_camera_node.dart'; +import '../../models/osm_node.dart'; import '../../models/node_profile.dart'; import '../offline_area_service.dart'; import '../offline_areas/offline_area_models.dart'; /// Fetch surveillance nodes from all offline areas intersecting the bounds/profile list. -Future> fetchLocalNodes({ +Future> fetchLocalNodes({ required LatLngBounds bounds, required List profiles, int? maxNodes, }) async { final areas = OfflineAreaService().offlineAreas; - final Map deduped = {}; + final Map deduped = {}; for (final area in areas) { if (area.status != OfflineAreaStatus.complete) continue; @@ -38,7 +38,7 @@ Future> fetchLocalNodes({ } // Try in-memory first, else load from disk -Future> _loadAreaNodes(OfflineArea area) async { +Future> _loadAreaNodes(OfflineArea area) async { if (area.nodes.isNotEmpty) { return area.nodes; } @@ -58,7 +58,7 @@ Future> _loadAreaNodes(OfflineArea area) async { try { final str = await fileToLoad.readAsString(); final jsonList = jsonDecode(str) as List; - return jsonList.map((e) => OsmCameraNode.fromJson(e)).toList(); + return jsonList.map((e) => OsmNode.fromJson(e)).toList(); } catch (e) { debugPrint('[_loadAreaNodes] Error loading nodes from ${fileToLoad.path}: $e'); } @@ -74,14 +74,14 @@ bool _pointInBounds(LatLng pt, LatLngBounds bounds) { pt.longitude <= bounds.northEast.longitude; } -bool _matchesAnyProfile(OsmCameraNode node, List profiles) { +bool _matchesAnyProfile(OsmNode node, List profiles) { for (final prof in profiles) { if (_nodeMatchesProfile(node, prof)) return true; } return false; } -bool _nodeMatchesProfile(OsmCameraNode node, NodeProfile profile) { +bool _nodeMatchesProfile(OsmNode node, NodeProfile profile) { for (final e in profile.tags.entries) { if (node.tags[e.key] != e.value) return false; // All profile tags must match } diff --git a/lib/services/map_data_submodules/nodes_from_osm_api.dart b/lib/services/map_data_submodules/nodes_from_osm_api.dart index 01a8363..71a8aee 100644 --- a/lib/services/map_data_submodules/nodes_from_osm_api.dart +++ b/lib/services/map_data_submodules/nodes_from_osm_api.dart @@ -6,13 +6,13 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:xml/xml.dart'; import '../../models/node_profile.dart'; -import '../../models/osm_camera_node.dart'; +import '../../models/osm_node.dart'; import '../../app_state.dart'; import '../network_status.dart'; /// Fetches surveillance nodes from the direct OSM API using bbox query. /// This is a fallback for when Overpass is not available (e.g., sandbox mode). -Future> fetchOsmApiNodes({ +Future> fetchOsmApiNodes({ required LatLngBounds bounds, required List profiles, UploadMode uploadMode = UploadMode.production, @@ -47,7 +47,7 @@ Future> fetchOsmApiNodes({ // Parse XML response final document = XmlDocument.parse(response.body); - final nodes = []; + final nodes = []; // Find all node elements for (final nodeElement in document.findAllElements('node')) { @@ -73,7 +73,7 @@ Future> fetchOsmApiNodes({ // Check if this node matches any of our profiles if (_nodeMatchesProfiles(tags, profiles)) { - nodes.add(OsmCameraNode( + nodes.add(OsmNode( id: id, coord: LatLng(lat, lon), tags: tags, diff --git a/lib/services/map_data_submodules/nodes_from_overpass.dart b/lib/services/map_data_submodules/nodes_from_overpass.dart index 0437090..6eb4cc2 100644 --- a/lib/services/map_data_submodules/nodes_from_overpass.dart +++ b/lib/services/map_data_submodules/nodes_from_overpass.dart @@ -5,13 +5,13 @@ import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart'; import '../../models/node_profile.dart'; -import '../../models/osm_camera_node.dart'; +import '../../models/osm_node.dart'; import '../../models/pending_upload.dart'; import '../../app_state.dart'; import '../network_status.dart'; /// Fetches surveillance nodes from the Overpass OSM API for the given bounds and profiles. -Future> fetchOverpassNodes({ +Future> fetchOverpassNodes({ required LatLngBounds bounds, required List profiles, UploadMode uploadMode = UploadMode.production, @@ -49,7 +49,7 @@ Future> fetchOverpassNodes({ NetworkStatus.instance.reportOverpassSuccess(); final nodes = elements.whereType>().map((element) { - return OsmCameraNode( + return OsmNode( id: element['id'], coord: LatLng(element['lat'], element['lon']), tags: Map.from(element['tags'] ?? {}), @@ -101,7 +101,7 @@ $outputClause } /// Clean up pending uploads that now appear in Overpass results -void _cleanupCompletedUploads(List overpassNodes) { +void _cleanupCompletedUploads(List overpassNodes) { try { final appState = AppState.instance; final pendingUploads = appState.pendingUploads; diff --git a/lib/services/node_cache.dart b/lib/services/node_cache.dart index 5aae931..df781b0 100644 --- a/lib/services/node_cache.dart +++ b/lib/services/node_cache.dart @@ -1,5 +1,5 @@ import 'package:latlong2/latlong.dart'; -import '../models/osm_camera_node.dart'; +import '../models/osm_node.dart'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; class NodeCache { @@ -8,10 +8,10 @@ class NodeCache { factory NodeCache() => instance; NodeCache._internal(); - final Map _nodes = {}; + final Map _nodes = {}; /// Add or update a batch of nodes in the cache. - void addOrUpdate(List nodes) { + void addOrUpdate(List nodes) { for (var node in nodes) { final existing = _nodes[node.id]; if (existing != null) { @@ -22,7 +22,7 @@ class NodeCache { mergedTags[entry.key] = entry.value; } } - _nodes[node.id] = OsmCameraNode( + _nodes[node.id] = OsmNode( id: node.id, coord: node.coord, tags: mergedTags, @@ -34,14 +34,14 @@ class NodeCache { } /// Query for all cached nodes currently within the given LatLngBounds. - List queryByBounds(LatLngBounds bounds) { + List queryByBounds(LatLngBounds bounds) { return _nodes.values .where((node) => _inBounds(node.coord, bounds)) .toList(); } /// Retrieve all cached nodes. - List getAll() => _nodes.values.toList(); + List getAll() => _nodes.values.toList(); /// Optionally clear the cache (rarely needed) void clear() => _nodes.clear(); @@ -53,7 +53,7 @@ class NodeCache { final cleanTags = Map.from(node.tags); cleanTags.remove('_pending_edit'); - _nodes[nodeId] = OsmCameraNode( + _nodes[nodeId] = OsmNode( id: node.id, coord: node.coord, tags: cleanTags, diff --git a/lib/services/offline_area_service.dart b/lib/services/offline_area_service.dart index 952684f..33e5c09 100644 --- a/lib/services/offline_area_service.dart +++ b/lib/services/offline_area_service.dart @@ -8,7 +8,7 @@ import 'offline_areas/offline_area_models.dart'; import 'offline_areas/offline_tile_utils.dart'; import 'offline_areas/offline_area_downloader.dart'; -import '../models/osm_camera_node.dart'; +import '../models/osm_node.dart'; import '../app_state.dart'; import 'map_data_provider.dart'; import 'package:deflockapp/dev_config.dart'; diff --git a/lib/services/offline_areas/offline_area_downloader.dart b/lib/services/offline_areas/offline_area_downloader.dart index fba5328..057bfab 100644 --- a/lib/services/offline_areas/offline_area_downloader.dart +++ b/lib/services/offline_areas/offline_area_downloader.dart @@ -6,7 +6,7 @@ import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; import '../../app_state.dart'; -import '../../models/osm_camera_node.dart'; +import '../../models/osm_node.dart'; import '../map_data_provider.dart'; import 'offline_area_models.dart'; import 'offline_tile_utils.dart'; @@ -182,7 +182,7 @@ class OfflineAreaDownloader { } /// Save nodes to disk as JSON - static Future saveNodes(List nodes, String dir) async { + static Future saveNodes(List nodes, String dir) async { final file = File('$dir/nodes.json'); await file.writeAsString(jsonEncode(nodes.map((n) => n.toJson()).toList())); } diff --git a/lib/services/offline_areas/offline_area_models.dart b/lib/services/offline_areas/offline_area_models.dart index f6f34c5..61906e7 100644 --- a/lib/services/offline_areas/offline_area_models.dart +++ b/lib/services/offline_areas/offline_area_models.dart @@ -1,6 +1,6 @@ import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; -import '../../models/osm_camera_node.dart'; +import '../../models/osm_node.dart'; /// Status of an offline area enum OfflineAreaStatus { downloading, complete, error, cancelled } @@ -17,7 +17,7 @@ class OfflineArea { double progress; // 0.0 - 1.0 int tilesDownloaded; int tilesTotal; - List nodes; + List nodes; int sizeBytes; // Disk size in bytes final bool isPermanent; // Not user-deletable if true @@ -88,7 +88,7 @@ class OfflineArea { tilesDownloaded: json['tilesDownloaded'] ?? 0, tilesTotal: json['tilesTotal'] ?? 0, nodes: (json['nodes'] as List? ?? json['cameras'] as List? ?? []) - .map((e) => OsmCameraNode.fromJson(e)).toList(), + .map((e) => OsmNode.fromJson(e)).toList(), sizeBytes: json['sizeBytes'] ?? 0, isPermanent: json['isPermanent'] ?? false, tileProviderId: json['tileProviderId'], diff --git a/lib/services/proximity_alert_service.dart b/lib/services/proximity_alert_service.dart new file mode 100644 index 0000000..ea1de30 --- /dev/null +++ b/lib/services/proximity_alert_service.dart @@ -0,0 +1,257 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; + +import '../models/osm_node.dart'; +import '../models/node_profile.dart'; +import '../dev_config.dart'; + +/// Simple data class for tracking recent proximity alerts to prevent spam +class RecentAlert { + final int nodeId; + final DateTime alertTime; + + RecentAlert({required this.nodeId, required this.alertTime}); +} + +/// Service for handling proximity alerts when approaching surveillance nodes +/// Follows brutalist principles: simple, explicit, easy to understand +class ProximityAlertService { + static final ProximityAlertService _instance = ProximityAlertService._internal(); + factory ProximityAlertService() => _instance; + ProximityAlertService._internal(); + + FlutterLocalNotificationsPlugin? _notifications; + bool _isInitialized = false; + + // Simple in-memory tracking of recent alerts to prevent spam + final List _recentAlerts = []; + static const Duration _alertCooldown = kProximityAlertCooldown; + + // Callback for showing in-app visual alerts + VoidCallback? _onVisualAlert; + + /// Initialize the notification plugin and request permissions + Future initialize({VoidCallback? onVisualAlert}) async { + _onVisualAlert = onVisualAlert; + + _notifications = FlutterLocalNotificationsPlugin(); + + const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); + const iosSettings = DarwinInitializationSettings( + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true, + ); + + const initSettings = InitializationSettings( + android: androidSettings, + iOS: iosSettings, + ); + + try { + final initialized = await _notifications!.initialize(initSettings); + _isInitialized = initialized ?? false; + + // Request notification permissions (especially important for Android 13+) + if (_isInitialized) { + await _requestNotificationPermissions(); + } + + debugPrint('[ProximityAlertService] Initialized: $_isInitialized'); + } catch (e) { + debugPrint('[ProximityAlertService] Failed to initialize: $e'); + _isInitialized = false; + } + } + + /// Request notification permissions on both platforms + Future _requestNotificationPermissions() async { + if (_notifications == null) return; + + try { + // Request permissions - this will show the permission dialog on Android 13+ + final result = await _notifications! + .resolvePlatformSpecificImplementation() + ?.requestNotificationsPermission(); + + debugPrint('[ProximityAlertService] Android notification permission result: $result'); + + // Also request for iOS (though this was already done in initialization) + await _notifications! + .resolvePlatformSpecificImplementation() + ?.requestPermissions( + alert: true, + badge: true, + sound: true, + ); + } catch (e) { + debugPrint('[ProximityAlertService] Failed to request permissions: $e'); + } + } + + /// Check proximity to nodes and trigger alerts if needed + /// This should be called on GPS position updates + Future checkProximity({ + required LatLng userLocation, + required List nodes, + required List enabledProfiles, + required int alertDistance, + }) async { + if (!_isInitialized || nodes.isEmpty) return; + + // Clean up old alerts (anything older than cooldown period) + final cutoffTime = DateTime.now().subtract(_alertCooldown); + _recentAlerts.removeWhere((alert) => alert.alertTime.isBefore(cutoffTime)); + + // Check each node for proximity + for (final node in nodes) { + // Skip if we recently alerted for this node + if (_recentAlerts.any((alert) => alert.nodeId == node.id)) { + continue; + } + + // Calculate distance using Geolocator's distanceBetween + final distance = Geolocator.distanceBetween( + userLocation.latitude, + userLocation.longitude, + node.coord.latitude, + node.coord.longitude, + ); + + // Check if within alert distance + if (distance <= alertDistance) { + // Determine node type for alert message + final nodeType = _getNodeTypeDescription(node, enabledProfiles); + + // Trigger both push notification and visual alert + await _showNotification(node, nodeType, distance.round()); + _showVisualAlert(); + + // Track this alert to prevent spam + _recentAlerts.add(RecentAlert( + nodeId: node.id, + alertTime: DateTime.now(), + )); + + debugPrint('[ProximityAlertService] Alert triggered for node ${node.id} ($nodeType) at ${distance.round()}m'); + } + } + } + + /// Show push notification for proximity alert + Future _showNotification(OsmNode node, String nodeType, int distance) async { + if (!_isInitialized || _notifications == null) return; + + const androidDetails = AndroidNotificationDetails( + 'proximity_alerts', + 'Proximity Alerts', + channelDescription: 'Notifications when approaching surveillance devices', + importance: Importance.high, + priority: Priority.high, + enableVibration: true, + playSound: true, + ); + + const iosDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: false, + presentSound: true, + ); + + const notificationDetails = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + final title = 'Surveillance Device Nearby'; + final body = '$nodeType detected ${distance}m ahead'; + + try { + await _notifications!.show( + node.id, // Use node ID as notification ID + title, + body, + notificationDetails, + ); + } catch (e) { + debugPrint('[ProximityAlertService] Failed to show notification: $e'); + } + } + + /// Trigger visual alert in the app UI + void _showVisualAlert() { + _onVisualAlert?.call(); + } + + /// Get a user-friendly description of the node type + String _getNodeTypeDescription(OsmNode node, List enabledProfiles) { + final tags = node.tags; + + // Check for specific surveillance types + if (tags.containsKey('man_made') && tags['man_made'] == 'surveillance') { + final surveillanceType = tags['surveillance:type'] ?? 'surveillance device'; + if (surveillanceType == 'camera') return 'Camera'; + if (surveillanceType == 'ALPR') return 'License plate reader'; + return 'Surveillance device'; + } + + // Check for emergency devices + if (tags.containsKey('emergency') && tags['emergency'] == 'siren') { + return 'Emergency siren'; + } + + // Fall back to checking enabled profiles to see what type this might be + for (final profile in enabledProfiles) { + bool matches = true; + for (final entry in profile.tags.entries) { + if (node.tags[entry.key] != entry.value) { + matches = false; + break; + } + } + if (matches) { + return profile.name; + } + } + + return 'Surveillance device'; + } + + /// Get count of recent alerts (for debugging/testing) + int get recentAlertCount => _recentAlerts.length; + + /// Clear recent alerts (for testing) + void clearRecentAlerts() { + _recentAlerts.clear(); + } + + /// Check if notification permissions are granted + Future areNotificationsEnabled() async { + if (!_isInitialized || _notifications == null) return false; + + try { + // Check Android permissions + final androidImpl = _notifications! + .resolvePlatformSpecificImplementation(); + if (androidImpl != null) { + final result = await androidImpl.areNotificationsEnabled(); + return result ?? false; + } + + // For iOS, assume enabled if we got this far (permissions were requested during init) + return true; + } catch (e) { + debugPrint('[ProximityAlertService] Failed to check notification permissions: $e'); + return false; + } + } + + /// Request permissions again (can be called from settings) + Future requestNotificationPermissions() async { + await _requestNotificationPermissions(); + return await areNotificationsEnabled(); + } +} \ No newline at end of file diff --git a/lib/state/session_state.dart b/lib/state/session_state.dart index ab60d0c..7f3d562 100644 --- a/lib/state/session_state.dart +++ b/lib/state/session_state.dart @@ -3,7 +3,7 @@ import 'package:latlong2/latlong.dart'; import '../models/node_profile.dart'; import '../models/operator_profile.dart'; -import '../models/osm_camera_node.dart'; +import '../models/osm_node.dart'; // ------------------ AddNodeSession ------------------ class AddNodeSession { @@ -23,7 +23,7 @@ class EditNodeSession { required this.target, }); - final OsmCameraNode originalNode; // The original node being edited + final OsmNode originalNode; // The original node being edited NodeProfile profile; OperatorProfile? operatorProfile; double directionDegrees; @@ -48,7 +48,7 @@ class SessionState extends ChangeNotifier { notifyListeners(); } - void startEditSession(OsmCameraNode node, List enabledProfiles) { + void startEditSession(OsmNode node, List enabledProfiles) { final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList(); // Try to find a matching profile based on the node's tags diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart index 0857960..dea3475 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -24,11 +24,15 @@ class SettingsState extends ChangeNotifier { static const String _selectedTileTypePrefsKey = 'selected_tile_type'; static const String _legacyTestModePrefsKey = 'test_mode'; static const String _followMeModePrefsKey = 'follow_me_mode'; + static const String _proximityAlertsEnabledPrefsKey = 'proximity_alerts_enabled'; + static const String _proximityAlertDistancePrefsKey = 'proximity_alert_distance'; bool _offlineMode = false; int _maxCameras = 250; UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production; FollowMeMode _followMeMode = FollowMeMode.northUp; + bool _proximityAlertsEnabled = false; + int _proximityAlertDistance = kProximityAlertDefaultDistance; List _tileProviders = []; String _selectedTileTypeId = ''; @@ -37,6 +41,8 @@ class SettingsState extends ChangeNotifier { int get maxCameras => _maxCameras; UploadMode get uploadMode => _uploadMode; FollowMeMode get followMeMode => _followMeMode; + bool get proximityAlertsEnabled => _proximityAlertsEnabled; + int get proximityAlertDistance => _proximityAlertDistance; List get tileProviders => List.unmodifiable(_tileProviders); String get selectedTileTypeId => _selectedTileTypeId; @@ -85,6 +91,10 @@ class SettingsState extends ChangeNotifier { _maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250; } + // Load proximity alerts settings + _proximityAlertsEnabled = prefs.getBool(_proximityAlertsEnabledPrefsKey) ?? false; + _proximityAlertDistance = prefs.getInt(_proximityAlertDistancePrefsKey) ?? kProximityAlertDefaultDistance; + // Load upload mode (including migration from old test_mode bool) if (prefs.containsKey(_uploadModePrefsKey)) { final idx = prefs.getInt(_uploadModePrefsKey) ?? 0; @@ -253,4 +263,26 @@ class SettingsState extends ChangeNotifier { } } + /// Set proximity alerts enabled/disabled + Future setProximityAlertsEnabled(bool enabled) async { + if (_proximityAlertsEnabled != enabled) { + _proximityAlertsEnabled = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_proximityAlertsEnabledPrefsKey, enabled); + notifyListeners(); + } + } + + /// Set proximity alert distance in meters + Future setProximityAlertDistance(int distance) async { + if (distance < kProximityAlertMinDistance) distance = kProximityAlertMinDistance; + if (distance > kProximityAlertMaxDistance) distance = kProximityAlertMaxDistance; + if (_proximityAlertDistance != distance) { + _proximityAlertDistance = distance; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_proximityAlertDistancePrefsKey, distance); + notifyListeners(); + } + } + } \ No newline at end of file diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index 9700488..704164c 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/pending_upload.dart'; -import '../models/osm_camera_node.dart'; +import '../models/osm_node.dart'; import '../models/node_profile.dart'; import '../services/node_cache.dart'; import '../services/uploader.dart'; @@ -46,7 +46,7 @@ class UploadQueueState extends ChangeNotifier { final tags = upload.getCombinedTags(); tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction - final tempNode = OsmCameraNode( + final tempNode = OsmNode( id: tempId, coord: upload.coord, tags: tags, @@ -80,7 +80,7 @@ class UploadQueueState extends ChangeNotifier { final originalTags = Map.from(session.originalNode.tags); originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit - final originalNode = OsmCameraNode( + final originalNode = OsmNode( id: session.originalNode.id, coord: session.originalNode.coord, // Keep at original location tags: originalTags, @@ -92,7 +92,7 @@ class UploadQueueState extends ChangeNotifier { editedTags['_pending_upload'] = 'true'; // Mark as pending upload editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing - final editedNode = OsmCameraNode( + final editedNode = OsmNode( id: tempId, coord: upload.coord, // At new location tags: editedTags, @@ -106,7 +106,7 @@ class UploadQueueState extends ChangeNotifier { } // Add a node deletion to the upload queue - void addFromNodeDeletion(OsmCameraNode node, {required UploadMode uploadMode}) { + void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode}) { final upload = PendingUpload( coord: node.coord, direction: node.directionDeg ?? 0, // Use existing direction or default to 0 @@ -123,7 +123,7 @@ class UploadQueueState extends ChangeNotifier { final deletionTags = Map.from(node.tags); deletionTags['_pending_deletion'] = 'true'; - final nodeWithDeletionTag = OsmCameraNode( + final nodeWithDeletionTag = OsmNode( id: node.id, coord: node.coord, tags: deletionTags, @@ -259,7 +259,7 @@ class UploadQueueState extends ChangeNotifier { // Create the node with real ID and clean tags (remove temp markers) final tags = item.getCombinedTags(); - final realNode = OsmCameraNode( + final realNode = OsmNode( id: realNodeId, coord: item.coord, tags: tags, // Clean tags without _pending_upload markers diff --git a/lib/widgets/camera_provider_with_cache.dart b/lib/widgets/camera_provider_with_cache.dart index 6e3ce12..2e81666 100644 --- a/lib/widgets/camera_provider_with_cache.dart +++ b/lib/widgets/camera_provider_with_cache.dart @@ -7,7 +7,7 @@ import '../services/map_data_provider.dart'; import '../services/node_cache.dart'; import '../services/network_status.dart'; import '../models/node_profile.dart'; -import '../models/osm_camera_node.dart'; +import '../models/osm_node.dart'; import '../app_state.dart'; /// Provides surveillance nodes for a map view, using an in-memory cache and optionally @@ -21,7 +21,7 @@ class CameraProviderWithCache extends ChangeNotifier { /// Call this to get (quickly) all cached overlays for the given view. /// Filters by currently enabled profiles. - List getCachedNodesForBounds(LatLngBounds bounds) { + List getCachedNodesForBounds(LatLngBounds bounds) { final allNodes = NodeCache.instance.queryByBounds(bounds); final enabledProfiles = AppState.instance.enabledProfiles; @@ -79,7 +79,7 @@ class CameraProviderWithCache extends ChangeNotifier { } /// Check if a node matches any of the provided profiles - bool _matchesAnyProfile(OsmCameraNode node, List profiles) { + bool _matchesAnyProfile(OsmNode node, List profiles) { for (final profile in profiles) { if (_nodeMatchesProfile(node, profile)) return true; } @@ -87,7 +87,7 @@ class CameraProviderWithCache extends ChangeNotifier { } /// Check if a node matches a specific profile (all profile tags must match) - bool _nodeMatchesProfile(OsmCameraNode node, NodeProfile profile) { + bool _nodeMatchesProfile(OsmNode node, NodeProfile profile) { for (final entry in profile.tags.entries) { if (node.tags[entry.key] != entry.value) return false; } diff --git a/lib/widgets/map/camera_markers.dart b/lib/widgets/map/camera_markers.dart index 8de43fa..7672f94 100644 --- a/lib/widgets/map/camera_markers.dart +++ b/lib/widgets/map/camera_markers.dart @@ -4,13 +4,13 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import '../../dev_config.dart'; -import '../../models/osm_camera_node.dart'; +import '../../models/osm_node.dart'; import '../node_tag_sheet.dart'; import '../camera_icon.dart'; /// Smart marker widget for camera with single/double tap distinction class CameraMapMarker extends StatefulWidget { - final OsmCameraNode node; + final OsmNode node; final MapController mapController; const CameraMapMarker({required this.node, required this.mapController, Key? key}) : super(key: key); @@ -76,7 +76,7 @@ class _CameraMapMarkerState extends State { /// Helper class to build marker layers for cameras and user location class CameraMarkersBuilder { static List buildCameraMarkers({ - required List cameras, + required List cameras, required MapController mapController, LatLng? userLocation, }) { @@ -104,7 +104,7 @@ class CameraMarkersBuilder { return markers; } - static bool _isValidCameraCoordinate(OsmCameraNode node) { + static bool _isValidCameraCoordinate(OsmNode node) { return (node.coord.latitude != 0 || node.coord.longitude != 0) && node.coord.latitude.abs() <= 90 && node.coord.longitude.abs() <= 180; diff --git a/lib/widgets/map/direction_cones.dart b/lib/widgets/map/direction_cones.dart index a43d1ff..f8393d9 100644 --- a/lib/widgets/map/direction_cones.dart +++ b/lib/widgets/map/direction_cones.dart @@ -5,12 +5,12 @@ import 'package:latlong2/latlong.dart'; import '../../app_state.dart'; import '../../dev_config.dart'; -import '../../models/osm_camera_node.dart'; +import '../../models/osm_node.dart'; /// Helper class to build direction cone polygons for cameras class DirectionConesBuilder { static List buildDirectionCones({ - required List cameras, + required List cameras, required double zoom, AddNodeSession? session, EditNodeSession? editSession, @@ -52,7 +52,7 @@ class DirectionConesBuilder { return overlays; } - static bool _isValidCameraWithDirection(OsmCameraNode node) { + static bool _isValidCameraWithDirection(OsmNode node) { return node.hasDirection && node.directionDeg != null && (node.coord.latitude != 0 || node.coord.longitude != 0) && @@ -60,7 +60,7 @@ class DirectionConesBuilder { node.coord.longitude.abs() <= 180; } - static bool _isPendingUpload(OsmCameraNode node) { + static bool _isPendingUpload(OsmNode node) { return node.tags.containsKey('_pending_upload') && node.tags['_pending_upload'] == 'true'; } diff --git a/lib/widgets/map/gps_controller.dart b/lib/widgets/map/gps_controller.dart index c8a3033..570debd 100644 --- a/lib/widgets/map/gps_controller.dart +++ b/lib/widgets/map/gps_controller.dart @@ -6,6 +6,9 @@ import 'package:latlong2/latlong.dart'; import '../../dev_config.dart'; import '../../app_state.dart' show FollowMeMode; +import '../../services/proximity_alert_service.dart'; +import '../../models/osm_node.dart'; +import '../../models/node_profile.dart'; /// Manages GPS location tracking, follow-me modes, and location-based map animations. /// Handles GPS permissions, position streams, and follow-me behavior. @@ -81,6 +84,11 @@ class GpsController { required FollowMeMode followMeMode, required AnimatedMapController controller, required VoidCallback onLocationUpdated, + // Optional parameters for proximity alerts + bool proximityAlertsEnabled = false, + int proximityAlertDistance = 200, + List nearbyNodes = const [], + List enabledProfiles = const [], }) { final latLng = LatLng(position.latitude, position.longitude); _currentLatLng = latLng; @@ -88,6 +96,16 @@ class GpsController { // Notify that location was updated (for setState, etc.) onLocationUpdated(); + // Check proximity alerts if enabled + if (proximityAlertsEnabled && nearbyNodes.isNotEmpty) { + ProximityAlertService().checkProximity( + userLocation: latLng, + nodes: nearbyNodes, + enabledProfiles: enabledProfiles, + alertDistance: proximityAlertDistance, + ); + } + // Handle follow-me animations if enabled - use current mode from app state if (followMeMode != FollowMeMode.off) { debugPrint('[GpsController] GPS position update: ${latLng.latitude}, ${latLng.longitude}, follow-me: $followMeMode'); @@ -131,6 +149,10 @@ class GpsController { required AnimatedMapController controller, required VoidCallback onLocationUpdated, required FollowMeMode Function() getCurrentFollowMeMode, + required bool Function() getProximityAlertsEnabled, + required int Function() getProximityAlertDistance, + required List Function() getNearbyNodes, + required List Function() getEnabledProfiles, }) async { final perm = await Geolocator.requestPermission(); if (perm == LocationPermission.denied || @@ -142,11 +164,20 @@ class GpsController { _positionSub = Geolocator.getPositionStream().listen((Position position) { // Get the current follow-me mode from the app state each time final currentFollowMeMode = getCurrentFollowMeMode(); + final proximityAlertsEnabled = getProximityAlertsEnabled(); + final proximityAlertDistance = getProximityAlertDistance(); + final nearbyNodes = getNearbyNodes(); + final enabledProfiles = getEnabledProfiles(); + processPositionUpdate( position: position, followMeMode: currentFollowMeMode, controller: controller, onLocationUpdated: onLocationUpdated, + proximityAlertsEnabled: proximityAlertsEnabled, + proximityAlertDistance: proximityAlertDistance, + nearbyNodes: nearbyNodes, + enabledProfiles: enabledProfiles, ); }); } diff --git a/lib/widgets/map/layer_selector_button.dart b/lib/widgets/map/layer_selector_button.dart index 439275b..f820289 100644 --- a/lib/widgets/map/layer_selector_button.dart +++ b/lib/widgets/map/layer_selector_button.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../../app_state.dart'; import '../../models/tile_provider.dart'; import '../../services/offline_area_service.dart'; +import '../../services/localization_service.dart'; class LayerSelectorButton extends StatelessWidget { const LayerSelectorButton({super.key}); @@ -22,9 +23,9 @@ class LayerSelectorButton extends StatelessWidget { final offlineService = OfflineAreaService(); if (offlineService.hasActiveDownloads) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Cannot change tile types while downloading offline areas'), - duration: Duration(seconds: 3), + SnackBar( + content: Text(LocalizationService.instance.t('layerSelector.cannotChangeTileTypes')), + duration: const Duration(seconds: 3), ), ); return; @@ -58,6 +59,7 @@ class _LayerSelectorDialogState extends State<_LayerSelectorDialog> { Widget build(BuildContext context) { final appState = context.watch(); final providers = appState.tileProviders; + final locService = LocalizationService.instance; // Group tile types by provider for display final providerGroups = >{}; @@ -86,9 +88,9 @@ class _LayerSelectorDialogState extends State<_LayerSelectorDialog> { children: [ const Icon(Icons.layers), const SizedBox(width: 8), - const Text( - 'Select Map Layer', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + Text( + locService.t('layerSelector.selectMapLayer'), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), ), const Spacer(), IconButton( @@ -104,10 +106,10 @@ class _LayerSelectorDialogState extends State<_LayerSelectorDialog> { padding: EdgeInsets.zero, children: [ if (providerGroups.isEmpty) - const Padding( - padding: EdgeInsets.all(24), + Padding( + padding: const EdgeInsets.all(24), child: Center( - child: Text('No tile providers available'), + child: Text(locService.t('layerSelector.noTileProvidersAvailable')), ), ) else diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 36e81c3..47e90f8 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -7,7 +7,7 @@ import 'package:provider/provider.dart'; import '../app_state.dart'; import '../services/offline_area_service.dart'; import '../services/network_status.dart'; -import '../models/osm_camera_node.dart'; +import '../models/osm_node.dart'; import '../models/node_profile.dart'; import '../models/tile_provider.dart'; import 'debouncer.dart'; @@ -21,8 +21,10 @@ import 'map/tile_layer_manager.dart'; import 'map/camera_refresh_controller.dart'; import 'map/gps_controller.dart'; import 'network_status_indicator.dart'; +import 'proximity_alert_banner.dart'; import '../dev_config.dart'; import '../app_state.dart' show FollowMeMode; +import '../services/proximity_alert_service.dart'; class MapView extends StatefulWidget { final AnimatedMapController controller; @@ -55,6 +57,9 @@ class MapViewState extends State { // Track zoom to clear queue on zoom changes double? _lastZoom; + + // State for proximity alert banner + bool _showProximityBanner = false; @override void initState() { @@ -68,6 +73,17 @@ class MapViewState extends State { _cameraController.initialize(onCamerasUpdated: _onCamerasUpdated); _gpsController = GpsController(); + // Initialize proximity alert service + ProximityAlertService().initialize( + onVisualAlert: () { + if (mounted) { + setState(() { + _showProximityBanner = true; + }); + } + }, + ); + // Load last map position before initializing GPS _positionManager.loadLastMapPosition().then((_) { // Move to last known position after loading and widget is built @@ -93,6 +109,59 @@ class MapViewState extends State { } return FollowMeMode.off; }, + getProximityAlertsEnabled: () { + if (mounted) { + try { + return context.read().proximityAlertsEnabled; + } catch (e) { + debugPrint('[MapView] Could not read proximity alerts enabled: $e'); + return false; + } + } + return false; + }, + getProximityAlertDistance: () { + if (mounted) { + try { + return context.read().proximityAlertDistance; + } catch (e) { + debugPrint('[MapView] Could not read proximity alert distance: $e'); + return 200; + } + } + return 200; + }, + getNearbyNodes: () { + if (mounted) { + try { + final cameraProvider = context.read(); + LatLngBounds? mapBounds; + try { + mapBounds = _controller.mapController.camera.visibleBounds; + } catch (_) { + return []; + } + return mapBounds != null + ? cameraProvider.getCachedNodesForBounds(mapBounds) + : []; + } catch (e) { + debugPrint('[MapView] Could not get nearby nodes: $e'); + return []; + } + } + return []; + }, + getEnabledProfiles: () { + if (mounted) { + try { + return context.read().enabledProfiles; + } catch (e) { + debugPrint('[MapView] Could not read enabled profiles: $e'); + return []; + } + } + return []; + }, ); // Fetch initial cameras @@ -265,7 +334,7 @@ class MapViewState extends State { } final cameras = (mapBounds != null) ? cameraProvider.getCachedNodesForBounds(mapBounds) - : []; + : []; final markers = CameraMarkersBuilder.buildCameraMarkers( cameras: cameras, @@ -402,12 +471,22 @@ class MapViewState extends State { // Network status indicator (top-left) const NetworkStatusIndicator(), + + // Proximity alert banner (top) + ProximityAlertBanner( + isVisible: _showProximityBanner, + onDismiss: () { + setState(() { + _showProximityBanner = false; + }); + }, + ), ], ); } /// Build polylines connecting original cameras to their edited positions - List _buildEditLines(List cameras) { + List _buildEditLines(List cameras) { final lines = []; // Create a lookup map of original node IDs to their coordinates diff --git a/lib/widgets/node_tag_sheet.dart b/lib/widgets/node_tag_sheet.dart index 32a0cd5..0774bf4 100644 --- a/lib/widgets/node_tag_sheet.dart +++ b/lib/widgets/node_tag_sheet.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../models/osm_camera_node.dart'; +import '../models/osm_node.dart'; import '../app_state.dart'; import '../services/localization_service.dart'; class NodeTagSheet extends StatelessWidget { - final OsmCameraNode node; + final OsmNode node; const NodeTagSheet({super.key, required this.node}); diff --git a/lib/widgets/proximity_alert_banner.dart b/lib/widgets/proximity_alert_banner.dart new file mode 100644 index 0000000..154f3c7 --- /dev/null +++ b/lib/widgets/proximity_alert_banner.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; + +/// Simple red banner that flashes briefly when proximity alert is triggered +/// Follows brutalist principles: simple, explicit functionality +class ProximityAlertBanner extends StatefulWidget { + final bool isVisible; + final VoidCallback? onDismiss; + + const ProximityAlertBanner({ + super.key, + required this.isVisible, + this.onDismiss, + }); + + @override + State createState() => _ProximityAlertBannerState(); +} + +class _ProximityAlertBannerState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + _animation = CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + ); + } + + @override + void didUpdateWidget(ProximityAlertBanner oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.isVisible != oldWidget.isVisible) { + if (widget.isVisible) { + _controller.forward(); + // Auto-hide after 3 seconds + Future.delayed(const Duration(seconds: 3), () { + if (mounted) { + _controller.reverse().then((_) { + widget.onDismiss?.call(); + }); + } + }); + } else { + _controller.reverse(); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + if (_animation.value == 0.0) { + return const SizedBox.shrink(); + } + + return Positioned( + top: MediaQuery.of(context).padding.top, + left: 0, + right: 0, + child: Transform.translate( + offset: Offset(0, -60 * (1 - _animation.value)), + child: Container( + height: 60, + decoration: BoxDecoration( + color: Colors.red.shade600, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + _controller.reverse().then((_) { + widget.onDismiss?.call(); + }); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + const Icon( + Icons.warning, + color: Colors.white, + size: 24, + ), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Surveillance device nearby', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + const Icon( + Icons.close, + color: Colors.white, + size: 20, + ), + ], + ), + ), + ), + ), + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/widgets/refine_tags_sheet.dart b/lib/widgets/refine_tags_sheet.dart index 98800dd..befa7c5 100644 --- a/lib/widgets/refine_tags_sheet.dart +++ b/lib/widgets/refine_tags_sheet.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../app_state.dart'; import '../models/operator_profile.dart'; +import '../services/localization_service.dart'; class RefineTagsSheet extends StatefulWidget { const RefineTagsSheet({ @@ -29,10 +30,11 @@ class _RefineTagsSheetState extends State { Widget build(BuildContext context) { final appState = context.watch(); final operatorProfiles = appState.operatorProfiles; + final locService = LocalizationService.instance; return Scaffold( appBar: AppBar( - title: const Text('Refine Tags'), + title: Text(locService.t('refineTagsSheet.title')), leading: IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context, widget.selectedOperatorProfile), @@ -40,34 +42,34 @@ class _RefineTagsSheetState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context, _selectedOperatorProfile), - child: const Text('Done'), + child: Text(locService.t('refineTagsSheet.done')), ), ], ), body: ListView( padding: const EdgeInsets.all(16), children: [ - const Text( - 'Operator Profile', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + Text( + locService.t('refineTagsSheet.operatorProfile'), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), if (operatorProfiles.isEmpty) - const Card( + Card( child: Padding( - padding: EdgeInsets.all(16.0), + padding: const EdgeInsets.all(16.0), child: Column( children: [ - Icon(Icons.info_outline, color: Colors.grey, size: 48), - SizedBox(height: 8), + const Icon(Icons.info_outline, color: Colors.grey, size: 48), + const SizedBox(height: 8), Text( - 'No operator profiles defined', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + locService.t('refineTagsSheet.noOperatorProfiles'), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), - SizedBox(height: 4), + const SizedBox(height: 4), Text( - 'Create operator profiles in Settings to apply additional tags to your node submissions.', - style: TextStyle(color: Colors.grey), + locService.t('refineTagsSheet.noOperatorProfilesMessage'), + style: const TextStyle(color: Colors.grey), textAlign: TextAlign.center, ), ], @@ -79,15 +81,15 @@ class _RefineTagsSheetState extends State { child: Column( children: [ RadioListTile( - title: const Text('None'), - subtitle: const Text('No additional operator tags'), + title: Text(locService.t('refineTagsSheet.none')), + subtitle: Text(locService.t('refineTagsSheet.noAdditionalOperatorTags')), value: null, groupValue: _selectedOperatorProfile, onChanged: (value) => setState(() => _selectedOperatorProfile = value), ), ...operatorProfiles.map((profile) => RadioListTile( title: Text(profile.name), - subtitle: Text('${profile.tags.length} additional tags'), + subtitle: Text('${profile.tags.length} ${locService.t('refineTagsSheet.additionalTags')}'), value: profile, groupValue: _selectedOperatorProfile, onChanged: (value) => setState(() => _selectedOperatorProfile = value), @@ -97,9 +99,9 @@ class _RefineTagsSheetState extends State { ), const SizedBox(height: 16), if (_selectedOperatorProfile != null) ...[ - const Text( - 'Additional Tags', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + Text( + locService.t('refineTagsSheet.additionalTagsTitle'), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Card( @@ -114,9 +116,9 @@ class _RefineTagsSheetState extends State { ), const SizedBox(height: 8), if (_selectedOperatorProfile!.tags.isEmpty) - const Text( - 'No tags defined for this operator profile.', - style: TextStyle(color: Colors.grey), + Text( + locService.t('refineTagsSheet.noTagsDefinedForProfile'), + style: const TextStyle(color: Colors.grey), ) else ...(_selectedOperatorProfile!.tags.entries.map((entry) => diff --git a/pubspec.lock b/pubspec.lock index b522d63..2285fa5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" desktop_webview_window: dependency: transitive description: @@ -150,6 +158,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.14.4" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" + url: "https://pub.dev" + source: hosted + version: "17.2.4" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af + url: "https://pub.dev" + source: hosted + version: "4.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" + url: "https://pub.dev" + source: hosted + version: "7.2.0" flutter_map: dependency: "direct main" description: @@ -624,6 +656,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + timezone: + dependency: transitive + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + url: "https://pub.dev" + source: hosted + version: "0.9.4" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d8f8ed9..2f10f15 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: http: ^1.2.1 flutter_svg: ^2.0.10 xml: ^6.4.2 + flutter_local_notifications: ^17.2.2 # Auth, storage, prefs oauth2_client: ^4.2.0