Compare commits

..

30 Commits

Author SHA1 Message Date
stopflock
ec63aed459 v1 2025-10-01 13:57:08 -05:00
stopflock
b440629ad6 Simultaneous animations for smoothness 2025-10-01 13:45:23 -05:00
stopflock
322b9fae62 bump version, disable dev mode 2025-10-01 13:37:36 -05:00
stopflock
792f94065d highlight selected node 2025-10-01 13:36:51 -05:00
stopflock
1aeae18ebc Better handoff from tags to edit sheet 2025-10-01 13:27:17 -05:00
stopflock
96b82ef416 Proper centering for all node sheets. still jumps between tags and edit. 2025-10-01 12:34:02 -05:00
stopflock
adec4b175f Tablet thing fixed 2025-10-01 12:01:33 -05:00
stopflock
583499ccd1 Tablet thing fixed - tweaking needed 2025-10-01 11:52:46 -05:00
stopflock
7ff9273f47 fix readme edits.. 2025-09-30 15:16:40 -05:00
stopflock
03654f354e update readme todos 2025-09-30 15:13:58 -05:00
stopflock
9e97b69b85 Passes sniff test, needs sandbox testing 2025-09-30 14:43:36 -05:00
stopflock
fe554230b6 Constrain button bar width 2025-09-30 13:54:05 -05:00
stopflock
41ee9cab10 language order; english always first after system default 2025-09-30 09:57:40 -05:00
stopflock
ab567b1da4 languages and about get their own section 2025-09-30 09:50:24 -05:00
stopflock
e79790c30d Proximity alerts localizations 2025-09-30 09:14:21 -05:00
stopflock
d397610121 Move settings around part 4 2025-09-30 00:59:27 -05:00
stopflock
3a985d2f8f Move settings around part 3 2025-09-30 00:17:25 -05:00
stopflock
b3a87fc56a Move settings around part 2 2025-09-29 23:56:28 -05:00
stopflock
82501a3131 Move settings around part 1 2025-09-29 23:21:25 -05:00
stopflock
e71adab87e Change preview tile, add to dev_config 2025-09-29 22:18:33 -05:00
stopflock
2d29c93145 Area size estimates based on preview tile size if available 2025-09-29 21:43:55 -05:00
stopflock
caa20140b4 Fix display issue when refreshing offline areas 2025-09-29 21:32:39 -05:00
stopflock
2b26bf9188 Preview tile fetching on startup, offline area refresh, more cameras->nodes 2025-09-29 21:28:40 -05:00
stopflock
1140e6300a Merge pull request #16 from FoggedLens/approach-notifications
Approach notifications
2025-09-29 19:40:28 -05:00
stopflock
7a1b1befb4 Ask for notification permission on android, test button 2025-09-29 19:30:10 -05:00
stopflock
71fa212d71 More camera -> node, notifications for approaching 2025-09-29 19:22:22 -05:00
stopflock
6b5f05d036 Italian and Chinese coz why not 2025-09-29 12:20:35 -05:00
stopflock
87256e2c74 Bump version, dynamic localizations, add portuguese 2025-09-29 11:56:28 -05:00
stopflock
4a7a99502c Finish localizations, remove now-inaccurate sandbox note 2025-09-29 11:42:48 -05:00
stopflock
5c80fdc169 Update readme, create dev notes 2025-09-29 11:16:35 -05:00
65 changed files with 3635 additions and 561 deletions

425
DEVELOPER.md Normal file
View File

@@ -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 <EmptyResult>[];
}
```
### 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.

View File

@@ -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,48 +61,42 @@ 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
- Update offline area nodes while browsing?
- Camera deletions
- Optional custom icons for camera profiles
- Upgrade device marker design (considering nullplate's svg)
### Current Development
- Clean cache when nodes have disappeared / been deletd by others
- Clean up dev_config
- Improve offline area node refresh live display
- Add default operator profiles (Lowes etc)
### Future Features & Wishlist
- Update offline area nodes while browsing?
- Suspected locations toggle (alprwatch.com/flock/utilities)
- Jump to location by coordinates, address, or POI name
- Route planning that avoids surveillance devices (alprwatch.com/directions)
- Suspected locations toggle (alprwatch.com/flock/utilities)
- Location-based notifications when approaching surveillance devices
- Red/yellow ring for devices missing specific tag details
### Maybes
- Yellow ring for devices missing specific tag details?
- "Cache accumulating" offline area?
- "Offline areas" as tile provider?
- Custom device providers and OSM/Overpass alternatives
- Optional custom icons for camera profiles?
- Upgrade device marker design? (considering nullplate's svg)
- Custom device providers and OSM/Overpass alternatives?
---

View File

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

View File

@@ -6,6 +6,9 @@
<!-- Location permissions for bluedot positioning -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<!-- Notification permission for proximity alerts -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:name="${applicationName}"

View File

@@ -4,11 +4,12 @@ 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';
import 'models/pending_upload.dart';
import 'models/tile_provider.dart';
import 'services/offline_area_service.dart';
import 'services/node_cache.dart';
import 'services/tile_preview_service.dart';
import 'widgets/camera_provider_with_cache.dart';
import 'state/auth_state.dart';
import 'state/operator_profile_state.dart';
@@ -79,6 +80,8 @@ class AppState extends ChangeNotifier {
int get maxCameras => _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<TileProvider> get tileProviders => _settingsState.tileProviders;
@@ -99,6 +102,10 @@ class AppState extends ChangeNotifier {
Future<void> _init() async {
// Initialize all state modules
await _settingsState.init();
// Attempt to fetch missing tile type preview tiles (fails silently)
_fetchMissingTilePreviews();
await _operatorProfileState.init();
await _profileState.init();
await _uploadQueueState.init();
@@ -162,7 +169,7 @@ class AppState extends ChangeNotifier {
_sessionState.startAddSession(enabledProfiles);
}
void startEditSession(OsmCameraNode node) {
void startEditSession(OsmNode node) {
_sessionState.startEditSession(node, enabledProfiles);
}
@@ -218,7 +225,7 @@ class AppState extends ChangeNotifier {
}
}
void deleteNode(OsmCameraNode node) {
void deleteNode(OsmNode node) {
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode);
_startUploader();
}
@@ -270,6 +277,16 @@ class AppState extends ChangeNotifier {
await _settingsState.setFollowMeMode(mode);
}
/// Set proximity alerts enabled/disabled
Future<void> setProximityAlertsEnabled(bool enabled) async {
await _settingsState.setProximityAlertsEnabled(enabled);
}
/// Set proximity alert distance
Future<void> setProximityAlertDistance(int distance) async {
await _settingsState.setProximityAlertDistance(distance);
}
// ---------- Queue Methods ----------
void clearQueue() {
_uploadQueueState.clearQueue();
@@ -285,6 +302,15 @@ class AppState extends ChangeNotifier {
}
// ---------- Private Methods ----------
/// Attempts to fetch missing tile preview images in the background (fire and forget)
void _fetchMissingTilePreviews() {
// Run asynchronously without awaiting to avoid blocking app startup
TilePreviewService.fetchMissingPreviews(_settingsState).catchError((error) {
// Silently ignore errors - this is best effort
debugPrint('AppState: Tile preview fetching failed silently: $error');
});
}
void _startUploader() {
_uploadQueueState.startUploader(
offlineMode: offlineMode,

View File

@@ -3,8 +3,13 @@ import 'package:flutter/material.dart';
/// Developer/build-time configuration for global/non-user-tunable constants.
// Example: Default tile storage estimate (KB per tile), for size estimates
const double kTileEstimateKb = 25.0;
// Fallback tile storage estimate (KB per tile), used when no preview tile data is available
const double kFallbackTileEstimateKb = 25.0;
// Preview tile coordinates for tile provider previews and size estimates
const int kPreviewTileZoom = 18;
const int kPreviewTileY = 101300;
const int kPreviewTileX = 41904;
// Direction cone for map view
const double kDirectionConeHalfAngle = 30.0; // degrees
@@ -31,7 +36,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 = '1.0.0';
// 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 +51,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';

View File

@@ -29,14 +29,36 @@
"systemDefault": "Systemstandard",
"aboutInfo": "Über / Informationen",
"aboutThisApp": "Über Diese App",
"maxNodes": "Max. geladene/angezeigte Knoten",
"aboutSubtitle": "App-Informationen und Credits",
"languageSubtitle": "Wählen Sie Ihre bevorzugte Sprache",
"maxNodes": "Max. angezeigte Knoten",
"maxNodesSubtitle": "Obergrenze für die Anzahl der Knoten auf der Karte festlegen (Standard: 250).",
"maxNodesWarning": "Sie möchten das wahrscheinlich nicht tun, es sei denn, Sie sind absolut sicher, dass Sie einen guten Grund dafür haben.",
"offlineMode": "Offline-Modus",
"offlineModeSubtitle": "Alle Netzwerkanfragen außer für lokale/Offline-Bereiche deaktivieren.",
"offlineModeWarningTitle": "Aktive Downloads",
"offlineModeWarningMessage": "Die Aktivierung des Offline-Modus bricht alle aktiven Bereichsdownloads ab. Möchten Sie fortfahren?",
"enableOfflineMode": "Offline-Modus Aktivieren"
"enableOfflineMode": "Offline-Modus Aktivieren",
"profiles": "Profile",
"profilesSubtitle": "Knoten- und Betreiberprofile verwalten",
"offlineSettings": "Offline-Einstellungen",
"offlineSettingsSubtitle": "Offline-Modus und heruntergeladene Bereiche verwalten",
"advancedSettings": "Erweiterte Einstellungen",
"advancedSettingsSubtitle": "Leistungs-, Warnungs- und Kachelanbieter-Einstellungen",
"proximityAlerts": "Näherungswarnungen"
},
"proximityAlerts": {
"getNotified": "Benachrichtigung erhalten beim Annähern an Überwachungsgeräte",
"batteryUsage": "Verbraucht zusätzlich Batterie für kontinuierliche Standortüberwachung",
"notificationsEnabled": "✓ Benachrichtigungen aktiviert",
"notificationsDisabled": "⚠ Benachrichtigungen deaktiviert",
"permissionRequired": "Benachrichtigungsberechtigung erforderlich",
"permissionExplanation": "Push-Benachrichtigungen sind deaktiviert. Sie sehen nur In-App-Warnungen und werden nicht benachrichtigt, wenn die App im Hintergrund läuft.",
"enableNotifications": "Benachrichtigungen Aktivieren",
"checkingPermissions": "Berechtigungen prüfen...",
"alertDistance": "Warnentfernung: ",
"meters": "Meter",
"rangeInfo": "Bereich: {}-{} Meter (Standard: {})"
},
"node": {
"title": "Knoten #{}",
@@ -79,7 +101,7 @@
"withinTileLimit": "Innerhalb {} Kachel-Limit",
"exceedsTileLimit": "Aktuelle Auswahl überschreitet {} Kachel-Limit",
"offlineModeWarning": "Downloads im Offline-Modus deaktiviert. Deaktivieren Sie den Offline-Modus, um neue Bereiche herunterzuladen.",
"downloadStarted": "Download gestartet! Lade Kacheln und Kameras...",
"downloadStarted": "Download gestartet! Lade Kacheln und Knoten...",
"downloadFailed": "Download konnte nicht gestartet werden: {}"
},
"uploadMode": {
@@ -90,7 +112,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": {
@@ -217,15 +238,17 @@
"operatorProfileDeleted": "Betreiber-Profil gelöscht"
},
"offlineAreas": {
"title": "Offline-Bereiche",
"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",
"tiles": "Kacheln",
"size": "Größe",
"cameras": "Kameras",
"nodes": "Knoten",
"areaIdFallback": "Bereich {}...",
"renameArea": "Bereich umbenennen",
"refreshWorldTiles": "Welt-Kacheln aktualisieren/neu herunterladen",
@@ -236,6 +259,33 @@
"renameButton": "Umbenennen",
"megabytes": "MB",
"kilobytes": "KB",
"progress": "{}%"
"progress": "{}%",
"refreshArea": "Bereich aktualisieren",
"refreshAreaDialogTitle": "Offline-Bereich aktualisieren",
"refreshAreaDialogSubtitle": "Wählen Sie aus, was für diesen Bereich aktualisiert werden soll:",
"refreshTiles": "Karten-Kacheln aktualisieren",
"refreshTilesSubtitle": "Alle Kacheln für aktualisierte Bilder erneut herunterladen",
"refreshNodes": "Knoten aktualisieren",
"refreshNodesSubtitle": "Knotendaten für diesen Bereich erneut abrufen",
"startRefresh": "Aktualisierung starten",
"refreshStarted": "Aktualisierung gestartet!",
"refreshFailed": "Aktualisierung fehlgeschlagen: {}"
},
"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"
}
}

View File

@@ -29,14 +29,36 @@
"systemDefault": "System Default",
"aboutInfo": "About / Info",
"aboutThisApp": "About This App",
"maxNodes": "Max nodes fetched/drawn",
"aboutSubtitle": "App information and credits",
"languageSubtitle": "Choose your preferred language",
"maxNodes": "Max nodes drawn",
"maxNodesSubtitle": "Set an upper limit for the number of nodes on the map (default: 250).",
"maxNodesWarning": "You probably don't want to do that unless you are absolutely sure you have a good reason for it.",
"offlineMode": "Offline Mode",
"offlineModeSubtitle": "Disable all network requests except for local/offline areas.",
"offlineModeWarningTitle": "Active Downloads",
"offlineModeWarningMessage": "Enabling offline mode will cancel any active area downloads. Do you want to continue?",
"enableOfflineMode": "Enable Offline Mode"
"enableOfflineMode": "Enable Offline Mode",
"profiles": "Profiles",
"profilesSubtitle": "Manage node and operator profiles",
"offlineSettings": "Offline Settings",
"offlineSettingsSubtitle": "Manage offline mode and downloaded areas",
"advancedSettings": "Advanced Settings",
"advancedSettingsSubtitle": "Performance, alerts, and tile provider settings",
"proximityAlerts": "Proximity Alerts"
},
"proximityAlerts": {
"getNotified": "Get notified when approaching surveillance devices",
"batteryUsage": "Uses extra battery for continuous location monitoring",
"notificationsEnabled": "✓ Notifications enabled",
"notificationsDisabled": "⚠ Notifications disabled",
"permissionRequired": "Notification permission required",
"permissionExplanation": "Push notifications are disabled. You'll only see in-app alerts and won't be notified when the app is in background.",
"enableNotifications": "Enable Notifications",
"checkingPermissions": "Checking permissions...",
"alertDistance": "Alert distance: ",
"meters": "meters",
"rangeInfo": "Range: {}-{} meters (default: {})"
},
"node": {
"title": "Node #{}",
@@ -79,7 +101,7 @@
"withinTileLimit": "Within {} tile limit",
"exceedsTileLimit": "Current selection exceeds {} tile limit",
"offlineModeWarning": "Downloads disabled while in offline mode. Disable offline mode to download new areas.",
"downloadStarted": "Download started! Fetching tiles and cameras...",
"downloadStarted": "Download started! Fetching tiles and nodes...",
"downloadFailed": "Failed to start download: {}"
},
"uploadMode": {
@@ -90,7 +112,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": {
@@ -217,15 +238,17 @@
"operatorProfileDeleted": "Operator profile deleted"
},
"offlineAreas": {
"title": "Offline Areas",
"noAreasTitle": "No offline areas",
"noAreasSubtitle": "Download a map area for offline use.",
"provider": "Provider",
"maxZoom": "Max zoom",
"zoomLevels": "Z{}-{}",
"latitude": "Lat",
"longitude": "Lon",
"tiles": "Tiles",
"size": "Size",
"cameras": "Cameras",
"nodes": "Nodes",
"areaIdFallback": "Area {}...",
"renameArea": "Rename area",
"refreshWorldTiles": "Refresh/re-download world tiles",
@@ -236,6 +259,33 @@
"renameButton": "Rename",
"megabytes": "MB",
"kilobytes": "KB",
"progress": "{}%"
"progress": "{}%",
"refreshArea": "Refresh area",
"refreshAreaDialogTitle": "Refresh Offline Area",
"refreshAreaDialogSubtitle": "Choose what to refresh for this area:",
"refreshTiles": "Refresh Map Tiles",
"refreshTilesSubtitle": "Re-download all tiles for updated imagery",
"refreshNodes": "Refresh Nodes",
"refreshNodesSubtitle": "Re-fetch node data for this area",
"startRefresh": "Start Refresh",
"refreshStarted": "Refresh started!",
"refreshFailed": "Refresh failed: {}"
},
"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"
}
}

View File

@@ -29,14 +29,36 @@
"systemDefault": "Sistema por Defecto",
"aboutInfo": "Acerca de / Información",
"aboutThisApp": "Acerca de Esta App",
"maxNodes": "Máx. nodos obtenidos/dibujados",
"aboutSubtitle": "Información de la aplicación y créditos",
"languageSubtitle": "Elige tu idioma preferido",
"maxNodes": "Máx. nodos dibujados",
"maxNodesSubtitle": "Establecer un límite superior para el número de nodos en el mapa (predeterminado: 250).",
"maxNodesWarning": "Probablemente no quieras hacer eso a menos que estés absolutamente seguro de que tienes una buena razón para ello.",
"offlineMode": "Modo Sin Conexión",
"offlineModeSubtitle": "Deshabilitar todas las solicitudes de red excepto para áreas locales/sin conexión.",
"offlineModeWarningTitle": "Descargas Activas",
"offlineModeWarningMessage": "Habilitar el modo sin conexión cancelará cualquier descarga de área activa. ¿Desea continuar?",
"enableOfflineMode": "Habilitar Modo Sin Conexión"
"enableOfflineMode": "Habilitar Modo Sin Conexión",
"profiles": "Perfiles",
"profilesSubtitle": "Gestionar perfiles de nodos y operadores",
"offlineSettings": "Configuración Sin Conexión",
"offlineSettingsSubtitle": "Gestionar modo sin conexión y áreas descargadas",
"advancedSettings": "Configuración Avanzada",
"advancedSettingsSubtitle": "Configuración de rendimiento, alertas y proveedores de teselas",
"proximityAlerts": "Alertas de Proximidad"
},
"proximityAlerts": {
"getNotified": "Recibe notificaciones al acercarte a dispositivos de vigilancia",
"batteryUsage": "Usa batería extra para monitoreo continuo de ubicación",
"notificationsEnabled": "✓ Notificaciones habilitadas",
"notificationsDisabled": "⚠ Notificaciones deshabilitadas",
"permissionRequired": "Permiso de notificación requerido",
"permissionExplanation": "Las notificaciones push están deshabilitadas. Solo verás alertas dentro de la app y no serás notificado cuando la app esté en segundo plano.",
"enableNotifications": "Habilitar Notificaciones",
"checkingPermissions": "Verificando permisos...",
"alertDistance": "Distancia de alerta: ",
"meters": "metros",
"rangeInfo": "Rango: {}-{} metros (predeterminado: {})"
},
"node": {
"title": "Nodo #{}",
@@ -79,7 +101,7 @@
"withinTileLimit": "Dentro del límite de {} mosaicos",
"exceedsTileLimit": "La selección actual excede el límite de {} mosaicos",
"offlineModeWarning": "Descargas deshabilitadas en modo sin conexión. Deshabilite el modo sin conexión para descargar nuevas áreas.",
"downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y cámaras...",
"downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y nodos...",
"downloadFailed": "Error al iniciar la descarga: {}"
},
"uploadMode": {
@@ -90,7 +112,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": {
@@ -217,15 +238,17 @@
"operatorProfileDeleted": "Perfil de operador eliminado"
},
"offlineAreas": {
"title": "Áreas Sin Conexión",
"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",
"tiles": "Teselas",
"size": "Tamaño",
"cameras": "Cámaras",
"nodes": "Nodos",
"areaIdFallback": "Área {}...",
"renameArea": "Renombrar área",
"refreshWorldTiles": "Actualizar/re-descargar teselas mundiales",
@@ -236,6 +259,33 @@
"renameButton": "Renombrar",
"megabytes": "MB",
"kilobytes": "KB",
"progress": "{}%"
"progress": "{}%",
"refreshArea": "Actualizar área",
"refreshAreaDialogTitle": "Actualizar Área sin Conexión",
"refreshAreaDialogSubtitle": "Elija qué actualizar para esta área:",
"refreshTiles": "Actualizar Mosaicos del Mapa",
"refreshTilesSubtitle": "Volver a descargar todos los mosaicos para imágenes actualizadas",
"refreshNodes": "Actualizar Nodos",
"refreshNodesSubtitle": "Volver a obtener datos de nodos para esta área",
"startRefresh": "Iniciar Actualización",
"refreshStarted": "¡Actualización iniciada!",
"refreshFailed": "Actualización falló: {}"
},
"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"
}
}

View File

@@ -29,14 +29,36 @@
"systemDefault": "Par Défaut du Système",
"aboutInfo": "À Propos / Informations",
"aboutThisApp": "À Propos de Cette App",
"maxNodes": "Max. nœuds récupérés/dessinés",
"aboutSubtitle": "Informations sur l'application et crédits",
"languageSubtitle": "Choisissez votre langue préférée",
"maxNodes": "Max. nœuds dessinés",
"maxNodesSubtitle": "Définir une limite supérieure pour le nombre de nœuds sur la carte (par défaut: 250).",
"maxNodesWarning": "Vous ne voulez probablement pas faire cela à moins d'être absolument sûr d'avoir une bonne raison de le faire.",
"offlineMode": "Mode Hors Ligne",
"offlineModeSubtitle": "Désactiver toutes les requêtes réseau sauf pour les zones locales/hors ligne.",
"offlineModeWarningTitle": "Téléchargements Actifs",
"offlineModeWarningMessage": "L'activation du mode hors ligne annulera tous les téléchargements de zone actifs. Voulez-vous continuer?",
"enableOfflineMode": "Activer le Mode Hors Ligne"
"enableOfflineMode": "Activer le Mode Hors Ligne",
"profiles": "Profils",
"profilesSubtitle": "Gérer les profils de nœuds et d'opérateurs",
"offlineSettings": "Paramètres Hors Ligne",
"offlineSettingsSubtitle": "Gérer le mode hors ligne et les zones téléchargées",
"advancedSettings": "Paramètres Avancés",
"advancedSettingsSubtitle": "Paramètres de performance, alertes et fournisseurs de tuiles",
"proximityAlerts": "Alertes de Proximité"
},
"proximityAlerts": {
"getNotified": "Recevoir des notifications en s'approchant de dispositifs de surveillance",
"batteryUsage": "Utilise de la batterie supplémentaire pour la surveillance continue de la localisation",
"notificationsEnabled": "✓ Notifications activées",
"notificationsDisabled": "⚠ Notifications désactivées",
"permissionRequired": "Autorisation de notification requise",
"permissionExplanation": "Les notifications push sont désactivées. Vous ne verrez que des alertes dans l'application et ne serez pas notifié lorsque l'application est en arrière-plan.",
"enableNotifications": "Activer les Notifications",
"checkingPermissions": "Vérification des autorisations...",
"alertDistance": "Distance d'alerte : ",
"meters": "mètres",
"rangeInfo": "Plage : {}-{} mètres (par défaut : {})"
},
"node": {
"title": "Nœud #{}",
@@ -79,7 +101,7 @@
"withinTileLimit": "Dans la limite de {} tuiles",
"exceedsTileLimit": "La sélection actuelle dépasse la limite de {} tuiles",
"offlineModeWarning": "Téléchargements désactivés en mode hors ligne. Désactivez le mode hors ligne pour télécharger de nouvelles zones.",
"downloadStarted": "Téléchargement démarré! Récupération des tuiles et caméras...",
"downloadStarted": "Téléchargement démarré! Récupération des tuiles et nœuds...",
"downloadFailed": "Échec du démarrage du téléchargement: {}"
},
"uploadMode": {
@@ -90,7 +112,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": {
@@ -217,15 +238,17 @@
"operatorProfileDeleted": "Profil d'opérateur supprimé"
},
"offlineAreas": {
"title": "Zones Hors Ligne",
"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",
"tiles": "Tuiles",
"size": "Taille",
"cameras": "Caméras",
"nodes": "Nœuds",
"areaIdFallback": "Zone {}...",
"renameArea": "Renommer la zone",
"refreshWorldTiles": "Actualiser/re-télécharger les tuiles mondiales",
@@ -236,6 +259,33 @@
"renameButton": "Renommer",
"megabytes": "Mo",
"kilobytes": "Ko",
"progress": "{}%"
"progress": "{}%",
"refreshArea": "Actualiser la zone",
"refreshAreaDialogTitle": "Actualiser la Zone Hors Ligne",
"refreshAreaDialogSubtitle": "Choisissez quoi actualiser pour cette zone :",
"refreshTiles": "Actualiser les Tuiles de Carte",
"refreshTilesSubtitle": "Télécharger à nouveau toutes les tuiles pour des images mises à jour",
"refreshNodes": "Actualiser les Nœuds",
"refreshNodesSubtitle": "Récupérer à nouveau les données de nœuds pour cette zone",
"startRefresh": "Démarrer l'Actualisation",
"refreshStarted": "Actualisation démarrée !",
"refreshFailed": "Actualisation échouée : {}"
},
"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"
}
}

291
lib/localizations/it.json Normal file
View File

@@ -0,0 +1,291 @@
{
"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",
"aboutSubtitle": "Informazioni sull'applicazione e crediti",
"languageSubtitle": "Scegli la tua lingua preferita",
"maxNodes": "Max nodi 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",
"profiles": "Profili",
"profilesSubtitle": "Gestisci profili di nodi e operatori",
"offlineSettings": "Impostazioni Offline",
"offlineSettingsSubtitle": "Gestisci modalità offline e aree scaricate",
"advancedSettings": "Impostazioni Avanzate",
"advancedSettingsSubtitle": "Impostazioni di prestazioni, avvisi e fornitori di tessere",
"proximityAlerts": "Avvisi di Prossimità"
},
"proximityAlerts": {
"getNotified": "Ricevi notifiche quando ti avvicini a dispositivi di sorveglianza",
"batteryUsage": "Utilizza batteria extra per il monitoraggio continuo della posizione",
"notificationsEnabled": "✓ Notifiche abilitate",
"notificationsDisabled": "⚠ Notifiche disabilitate",
"permissionRequired": "Autorizzazione notifica richiesta",
"permissionExplanation": "Le notifiche push sono disabilitate. Vedrai solo avvisi nell'app e non sarai notificato quando l'app è in background.",
"enableNotifications": "Abilita Notifiche",
"checkingPermissions": "Controllo autorizzazioni...",
"alertDistance": "Distanza di avviso: ",
"meters": "metri",
"rangeInfo": "Intervallo: {}-{} metri (predefinito: {})"
},
"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 nodi...",
"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": {
"title": "Aree Offline",
"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",
"nodes": "Nodi",
"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": "{}%",
"refreshArea": "Aggiorna area",
"refreshAreaDialogTitle": "Aggiorna Area Offline",
"refreshAreaDialogSubtitle": "Scegli cosa aggiornare per quest'area:",
"refreshTiles": "Aggiorna Tile Mappa",
"refreshTilesSubtitle": "Riscarica tutte le tile per immagini aggiornate",
"refreshNodes": "Aggiorna Nodi",
"refreshNodesSubtitle": "Ricarica i dati dei nodi per quest'area",
"startRefresh": "Avvia Aggiornamento",
"refreshStarted": "Aggiornamento avviato!",
"refreshFailed": "Aggiornamento fallito: {}"
},
"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"
}
}

291
lib/localizations/pt.json Normal file
View File

@@ -0,0 +1,291 @@
{
"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",
"aboutSubtitle": "Informações do aplicativo e créditos",
"languageSubtitle": "Escolha seu idioma preferido",
"maxNodes": "Máx. de nós 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",
"profiles": "Perfis",
"profilesSubtitle": "Gerenciar perfis de nós e operadores",
"offlineSettings": "Configurações Offline",
"offlineSettingsSubtitle": "Gerenciar modo offline e áreas baixadas",
"advancedSettings": "Configurações Avançadas",
"advancedSettingsSubtitle": "Configurações de desempenho, alertas e provedores de mapas",
"proximityAlerts": "Alertas de Proximidade"
},
"proximityAlerts": {
"getNotified": "Receba notificações ao se aproximar de dispositivos de vigilância",
"batteryUsage": "Usa bateria extra para monitoramento contínuo de localização",
"notificationsEnabled": "✓ Notificações habilitadas",
"notificationsDisabled": "⚠ Notificações desabilitadas",
"permissionRequired": "Permissão de notificação necessária",
"permissionExplanation": "Notificações push estão desabilitadas. Você só verá alertas dentro do app e não será notificado quando o app estiver em segundo plano.",
"enableNotifications": "Habilitar Notificações",
"checkingPermissions": "Verificando permissões...",
"alertDistance": "Distância de alerta: ",
"meters": "metros",
"rangeInfo": "Faixa: {}-{} metros (padrão: {})"
},
"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 nós...",
"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": {
"title": "Áreas Offline",
"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",
"nodes": "Nós",
"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": "{}%",
"refreshArea": "Atualizar área",
"refreshAreaDialogTitle": "Atualizar Área Offline",
"refreshAreaDialogSubtitle": "Escolha o que atualizar para esta área:",
"refreshTiles": "Atualizar Tiles do Mapa",
"refreshTilesSubtitle": "Baixar novamente todos os tiles para imagens atualizadas",
"refreshNodes": "Atualizar Nós",
"refreshNodesSubtitle": "Buscar novamente os dados dos nós para esta área",
"startRefresh": "Iniciar Atualização",
"refreshStarted": "Atualização iniciada!",
"refreshFailed": "Atualização falhou: {}"
},
"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"
}
}

291
lib/localizations/zh.json Normal file
View File

@@ -0,0 +1,291 @@
{
"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": "关于此应用",
"aboutSubtitle": "应用程序信息和鸣谢",
"languageSubtitle": "选择您的首选语言",
"maxNodes": "最大节点绘制数",
"maxNodesSubtitle": "设置地图上节点数量的上限默认250。",
"maxNodesWarning": "除非您确定有充分的理由,否则您可能不想这样做。",
"offlineMode": "离线模式",
"offlineModeSubtitle": "禁用除本地/离线区域外的所有网络请求。",
"offlineModeWarningTitle": "活动下载",
"offlineModeWarningMessage": "启用离线模式将取消任何活动的区域下载。您要继续吗?",
"enableOfflineMode": "启用离线模式",
"profiles": "配置文件",
"profilesSubtitle": "管理节点和操作员配置文件",
"offlineSettings": "离线设置",
"offlineSettingsSubtitle": "管理离线模式和已下载区域",
"advancedSettings": "高级设置",
"advancedSettingsSubtitle": "性能、警报和地图提供商设置",
"proximityAlerts": "邻近警报"
},
"proximityAlerts": {
"getNotified": "接近监控设备时接收通知",
"batteryUsage": "使用额外电量进行连续位置监控",
"notificationsEnabled": "✓ 通知已启用",
"notificationsDisabled": "⚠ 通知已禁用",
"permissionRequired": "需要通知权限",
"permissionExplanation": "推送通知已禁用。您只会看到应用内警报,当应用在后台时不会收到通知。",
"enableNotifications": "启用通知",
"checkingPermissions": "检查权限中...",
"alertDistance": "警报距离:",
"meters": "米",
"rangeInfo": "范围:{}-{} 米(默认:{}"
},
"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": {
"title": "离线区域",
"noAreasTitle": "无离线区域",
"noAreasSubtitle": "下载地图区域以供离线使用。",
"provider": "提供商",
"maxZoom": "最大缩放",
"zoomLevels": "Z{}-{}",
"latitude": "纬度",
"longitude": "经度",
"tiles": "瓦片",
"size": "大小",
"nodes": "节点",
"areaIdFallback": "区域 {}...",
"renameArea": "重命名区域",
"refreshWorldTiles": "刷新/重新下载世界瓦片",
"deleteOfflineArea": "删除离线区域",
"cancelDownload": "取消下载",
"renameAreaDialogTitle": "重命名离线区域",
"areaNameLabel": "区域名称",
"renameButton": "重命名",
"megabytes": "MB",
"kilobytes": "KB",
"progress": "{}%",
"refreshArea": "刷新区域",
"refreshAreaDialogTitle": "刷新离线区域",
"refreshAreaDialogSubtitle": "选择要为此区域刷新的内容:",
"refreshTiles": "刷新地图瓦片",
"refreshTilesSubtitle": "重新下载所有瓦片以获取更新的图像",
"refreshNodes": "刷新节点",
"refreshNodesSubtitle": "重新获取此区域的节点数据",
"startRefresh": "开始刷新",
"refreshStarted": "刷新已开始!",
"refreshFailed": "刷新失败:{}"
},
"refineTagsSheet": {
"title": "细化标签",
"operatorProfile": "运营商配置文件",
"done": "完成",
"none": "无",
"noAdditionalOperatorTags": "无额外运营商标签",
"additionalTags": "额外标签",
"additionalTagsTitle": "额外标签",
"noTagsDefinedForProfile": "此运营商配置文件未定义标签。",
"noOperatorProfiles": "未定义运营商配置文件",
"noOperatorProfilesMessage": "在设置中创建运营商配置文件,以将额外标签应用于您的节点提交。"
},
"layerSelector": {
"cannotChangeTileTypes": "在下载离线区域时无法更改瓦片类型",
"selectMapLayer": "选择地图图层",
"noTileProvidersAvailable": "无可用瓦片提供商"
}
}

View File

@@ -4,6 +4,11 @@ import 'package:provider/provider.dart';
import 'app_state.dart';
import 'screens/home_screen.dart';
import 'screens/settings_screen.dart';
import 'screens/profiles_settings_screen.dart';
import 'screens/offline_settings_screen.dart';
import 'screens/advanced_settings_screen.dart';
import 'screens/language_settings_screen.dart';
import 'screens/about_screen.dart';
import 'services/localization_service.dart';
@@ -58,6 +63,11 @@ class DeFlockApp extends StatelessWidget {
routes: {
'/': (context) => const HomeScreen(),
'/settings': (context) => const SettingsScreen(),
'/settings/profiles': (context) => const ProfilesSettingsScreen(),
'/settings/offline': (context) => const OfflineSettingsScreen(),
'/settings/advanced': (context) => const AdvancedSettingsScreen(),
'/settings/language': (context) => const LanguageSettingsScreen(),
'/settings/about': (context) => const AboutScreen(),
},
initialRoute: '/',
);

View File

@@ -1,11 +1,11 @@
import 'package:latlong2/latlong.dart';
class OsmCameraNode {
class OsmNode {
final int id;
final LatLng coord;
final Map<String, String> tags;
OsmCameraNode({
OsmNode({
required this.id,
required this.coord,
required this.tags,
@@ -18,14 +18,14 @@ class OsmCameraNode {
'tags': tags,
};
factory OsmCameraNode.fromJson(Map<String, dynamic> json) {
factory OsmNode.fromJson(Map<String, dynamic> json) {
final tags = <String, String>{};
if (json['tags'] != null) {
(json['tags'] as Map<String, dynamic>).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;
}
}
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import '../services/localization_service.dart';
class AboutScreen extends StatelessWidget {
const AboutScreen({super.key});
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => Scaffold(
appBar: AppBar(
title: Text(locService.t('settings.aboutThisApp')),
),
body: FutureBuilder<String>(
future: DefaultAssetBundle.of(context).loadString('assets/info.txt'),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Error loading info: ${snapshot.error}',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Text(
snapshot.data ?? 'No info available.',
style: Theme.of(context).textTheme.bodyLarge,
),
);
},
),
),
);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'settings/sections/max_nodes_section.dart';
import 'settings/sections/proximity_alerts_section.dart';
import 'settings/sections/tile_provider_section.dart';
import '../services/localization_service.dart';
class AdvancedSettingsScreen extends StatelessWidget {
const AdvancedSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => Scaffold(
appBar: AppBar(
title: Text(locService.t('settings.advancedSettings')),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: const [
MaxNodesSection(),
Divider(),
ProximityAlertsSection(),
Divider(),
TileProviderSection(),
],
),
),
);
}
}

View File

@@ -11,9 +11,11 @@ import '../services/localization_service.dart';
import '../widgets/add_node_sheet.dart';
import '../widgets/edit_node_sheet.dart';
import '../widgets/node_tag_sheet.dart';
import '../widgets/camera_provider_with_cache.dart';
import '../widgets/download_area_dialog.dart';
import '../widgets/measured_sheet.dart';
import '../models/osm_node.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@@ -28,9 +30,16 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
late final AnimatedMapController _mapController;
bool _editSheetShown = false;
// Track sheet heights for map padding
// Track sheet heights for map positioning
double _addSheetHeight = 0.0;
double _editSheetHeight = 0.0;
double _tagSheetHeight = 0.0;
// Flag to prevent map bounce when transitioning from tag sheet to edit sheet
bool _transitioningToEdit = false;
// Track selected node for highlighting
int? _selectedNodeId;
@override
void initState() {
@@ -110,24 +119,97 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
// Disable follow-me when editing a camera so the map doesn't jump around
appState.setFollowMeMode(FollowMeMode.off);
// Set transition flag to prevent map bounce
_transitioningToEdit = true;
// Close any existing tag sheet first
if (_tagSheetHeight > 0) {
Navigator.of(context).pop();
}
final session = appState.editSession!; // should be non-null when this is called
// Small delay to let tag sheet close smoothly
Future.delayed(const Duration(milliseconds: 150), () {
if (!mounted) return;
final controller = _scaffoldKey.currentState!.showBottomSheet(
(ctx) => MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_editSheetHeight = height;
// Clear transition flag and reset tag sheet height once edit sheet starts sizing
if (height > 0 && _transitioningToEdit) {
_transitioningToEdit = false;
_tagSheetHeight = 0.0; // Now safe to reset
_selectedNodeId = null; // Clear selection when moving to edit
}
});
},
child: EditNodeSheet(session: session),
),
);
// Reset height when sheet is dismissed
controller.closed.then((_) {
setState(() {
_editSheetHeight = 0.0;
_transitioningToEdit = false;
});
});
});
}
void openNodeTagSheet(OsmNode node) {
setState(() {
_selectedNodeId = node.id; // Track selected node for highlighting
});
// Start smooth centering animation simultaneously with sheet opening
// Use the same duration as SheetAwareMap (300ms) for coordinated animation
try {
_mapController.animateTo(
dest: node.coord,
zoom: _mapController.mapController.camera.zoom,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
} catch (_) {
// Map controller not ready, fallback to immediate move
try {
_mapController.mapController.move(node.coord, _mapController.mapController.camera.zoom);
} catch (_) {
// Controller really not ready, skip centering
}
}
final controller = _scaffoldKey.currentState!.showBottomSheet(
(ctx) => MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_editSheetHeight = height;
_tagSheetHeight = height;
});
},
child: EditNodeSheet(session: session),
child: NodeTagSheet(
node: node,
onEditPressed: () {
final appState = context.read<AppState>();
appState.startEditSession(node);
// This will trigger _openEditNodeSheet via the existing auto-show logic
},
),
),
);
// Reset height when sheet is dismissed
// Reset height and selection when sheet is dismissed (unless transitioning to edit)
controller.closed.then((_) {
setState(() {
_editSheetHeight = 0.0;
});
if (!_transitioningToEdit) {
setState(() {
_tagSheetHeight = 0.0;
_selectedNodeId = null; // Clear selection
});
}
// If transitioning to edit, keep the height until edit sheet takes over
});
}
@@ -143,9 +225,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_editSheetShown = false;
}
// Calculate bottom padding for map (90% of active sheet height)
final activeSheetHeight = _addSheetHeight > 0 ? _addSheetHeight : _editSheetHeight;
final mapBottomPadding = activeSheetHeight * 0.9;
// Pass the active sheet height directly to the map
final activeSheetHeight = _addSheetHeight > 0
? _addSheetHeight
: (_editSheetHeight > 0
? _editSheetHeight
: _tagSheetHeight);
return MultiProvider(
providers: [
@@ -190,7 +275,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
key: _mapViewKey,
controller: _mapController,
followMeMode: appState.followMeMode,
bottomPadding: mapBottomPadding,
sheetHeight: activeSheetHeight,
selectedNodeId: _selectedNodeId,
onNodeTap: openNodeTagSheet,
onUserGesture: () {
if (appState.followMeMode != FollowMeMode.off) {
appState.setFollowMeMode(FollowMeMode.off);
@@ -205,21 +292,23 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
left: 8,
right: 8,
),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Theme.of(context).shadowColor.withOpacity(0.3),
blurRadius: 10,
offset: Offset(0, -2),
)
],
),
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Theme.of(context).shadowColor.withOpacity(0.3),
blurRadius: 10,
offset: Offset(0, -2),
)
],
),
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
Expanded(
child: AnimatedBuilder(
@@ -256,6 +345,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
],
),
),
),
),
),
],

View File

@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'settings/sections/language_section.dart';
import '../services/localization_service.dart';
class LanguageSettingsScreen extends StatelessWidget {
const LanguageSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => Scaffold(
appBar: AppBar(
title: Text(locService.t('settings.language')),
),
body: const Padding(
padding: EdgeInsets.all(16),
child: LanguageSection(),
),
),
);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'settings/sections/offline_mode_section.dart';
import 'settings/sections/offline_areas_section.dart';
import '../services/localization_service.dart';
class OfflineSettingsScreen extends StatelessWidget {
const OfflineSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => Scaffold(
appBar: AppBar(
title: Text(locService.t('settings.offlineSettings')),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: const [
OfflineModeSection(),
Divider(),
OfflineAreasSection(),
],
),
),
);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'settings/sections/node_profiles_section.dart';
import 'settings/sections/operator_profiles_section.dart';
import '../services/localization_service.dart';
class ProfilesSettingsScreen extends StatelessWidget {
const ProfilesSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => Scaffold(
appBar: AppBar(
title: Text(locService.t('settings.profiles')),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: const [
NodeProfilesSection(),
Divider(),
OperatorProfilesSection(),
],
),
),
);
}
}

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import '../../services/localization_service.dart';
import '../../../services/localization_service.dart';
class AboutSection extends StatelessWidget {
const AboutSection({super.key});

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../services/localization_service.dart';
import '../../../app_state.dart';
import '../../../services/localization_service.dart';
class AuthSection extends StatelessWidget {
const AuthSection({super.key});

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../services/localization_service.dart';
import '../../../services/localization_service.dart';
class LanguageSection extends StatefulWidget {
const LanguageSection({super.key});
@@ -57,15 +57,6 @@ class _LanguageSectionState extends State<LanguageSection> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
locService.t('settings.language'),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
),
// System Default option
RadioListTile<String?>(
title: Text(locService.t('settings.systemDefault')),
@@ -73,8 +64,18 @@ class _LanguageSectionState extends State<LanguageSection> {
groupValue: _selectedLanguage,
onChanged: _setLanguage,
),
// Dynamic language options
...locService.availableLanguages.map((langCode) =>
// English always appears second (if available)
if (locService.availableLanguages.contains('en'))
RadioListTile<String>(
title: Text(_languageNames['en'] ?? 'English'),
value: 'en',
groupValue: _selectedLanguage,
onChanged: _setLanguage,
),
// Other language options (excluding English since it's already shown)
...locService.availableLanguages
.where((langCode) => langCode != 'en')
.map((langCode) =>
RadioListTile<String>(
title: Text(_languageNames[langCode] ?? langCode.toUpperCase()),
value: langCode,

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../services/localization_service.dart';
import '../../../app_state.dart';
import '../../../services/localization_service.dart';
class MaxNodesSection extends StatefulWidget {
const MaxNodesSection({super.key});
@@ -39,13 +39,17 @@ class _MaxNodesSectionState extends State<MaxNodesSection> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('settings.maxNodes'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
ListTile(
leading: const Icon(Icons.filter_alt),
title: Text(locService.t('settings.maxNodes')),
title: Text(locService.t('settings.maxNodesSubtitle')),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(locService.t('settings.maxNodesSubtitle')),
if (showWarning)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),

View File

@@ -1,13 +1,13 @@
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../models/node_profile.dart';
import '../../services/localization_service.dart';
import '../profile_editor.dart';
import '../../../app_state.dart';
import '../../../models/node_profile.dart';
import '../../../services/localization_service.dart';
import '../../profile_editor.dart';
class ProfileListSection extends StatelessWidget {
const ProfileListSection({super.key});
class NodeProfilesSection extends StatelessWidget {
const NodeProfilesSection({super.key});
@override
Widget build(BuildContext context) {
@@ -22,7 +22,10 @@ class ProfileListSection extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(locService.t('profiles.nodeProfiles'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
Text(
locService.t('profiles.nodeProfiles'),
style: Theme.of(context).textTheme.titleMedium,
),
TextButton.icon(
onPressed: () => Navigator.push(
context,

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import '../../services/offline_area_service.dart';
import '../../services/offline_areas/offline_area_models.dart';
import '../../services/localization_service.dart';
import '../../../services/offline_area_service.dart';
import '../../../services/offline_areas/offline_area_models.dart';
import '../../../services/localization_service.dart';
class OfflineAreasSection extends StatefulWidget {
const OfflineAreasSection({super.key});
@@ -24,6 +24,39 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
});
}
void _showRefreshDialog(OfflineArea area) {
showDialog(
context: context,
builder: (context) => _RefreshAreaDialog(
area: area,
onRefresh: (refreshTiles, refreshNodes) {
try {
// ignore: unawaited_futures
service.refreshArea(
id: area.id,
refreshTiles: refreshTiles,
refreshNodes: refreshNodes,
onProgress: (progress) => setState(() {}),
onComplete: (status) => setState(() {}),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(LocalizationService.instance.t('offlineAreas.refreshStarted')),
),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(LocalizationService.instance.t('offlineAreas.refreshFailed', params: [e.toString()])),
),
);
}
},
),
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
@@ -32,16 +65,22 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
final locService = LocalizationService.instance;
final areas = service.offlineAreas;
if (areas.isEmpty) {
return ListTile(
leading: const Icon(Icons.download_for_offline),
title: Text(locService.t('offlineAreas.noAreasTitle')),
subtitle: Text(locService.t('offlineAreas.noAreasSubtitle')),
);
}
return Column(
children: areas.map((area) {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('offlineAreas.title'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
if (areas.isEmpty)
ListTile(
leading: const Icon(Icons.download_for_offline),
title: Text(locService.t('offlineAreas.noAreasTitle')),
subtitle: Text(locService.t('offlineAreas.noAreasSubtitle')),
)
else
...areas.map((area) {
String diskStr = area.sizeBytes > 0
? area.sizeBytes > 1024 * 1024
? "${(area.sizeBytes / (1024 * 1024)).toStringAsFixed(2)} ${locService.t('offlineAreas.megabytes')}"
@@ -49,7 +88,7 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
: '--';
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)}';
@@ -59,7 +98,7 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
subtitle += '\n${locService.t('offlineAreas.tiles')}: ${area.tilesTotal}';
}
subtitle += '\n${locService.t('offlineAreas.size')}: $diskStr';
subtitle += '\n${locService.t('offlineAreas.cameras')}: ${area.nodes.length}';
subtitle += '\n${locService.t('offlineAreas.nodes')}: ${area.nodes.length}';
return Card(
child: ListTile(
leading: Icon(area.status == OfflineAreaStatus.complete
@@ -113,7 +152,12 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
}
},
),
if (area.status != OfflineAreaStatus.downloading)
if (area.status != OfflineAreaStatus.downloading) ...[
IconButton(
icon: const Icon(Icons.refresh, color: Colors.blue),
tooltip: locService.t('offlineAreas.refreshArea'),
onPressed: () => _showRefreshDialog(area),
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: locService.t('offlineAreas.deleteOfflineArea'),
@@ -122,6 +166,7 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
setState(() {});
},
),
],
],
),
subtitle: Text(subtitle),
@@ -163,8 +208,73 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
),
);
}).toList(),
);
],
);
},
);
}
}
class _RefreshAreaDialog extends StatefulWidget {
final OfflineArea area;
final Function(bool refreshTiles, bool refreshNodes) onRefresh;
const _RefreshAreaDialog({
required this.area,
required this.onRefresh,
});
@override
State<_RefreshAreaDialog> createState() => _RefreshAreaDialogState();
}
class _RefreshAreaDialogState extends State<_RefreshAreaDialog> {
bool _refreshTiles = true;
bool _refreshNodes = true;
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AlertDialog(
title: Text(locService.t('offlineAreas.refreshAreaDialogTitle')),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(locService.t('offlineAreas.refreshAreaDialogSubtitle')),
const SizedBox(height: 16),
CheckboxListTile(
title: Text(locService.t('offlineAreas.refreshTiles')),
subtitle: Text(locService.t('offlineAreas.refreshTilesSubtitle')),
value: _refreshTiles,
onChanged: (value) => setState(() => _refreshTiles = value ?? true),
dense: true,
),
CheckboxListTile(
title: Text(locService.t('offlineAreas.refreshNodes')),
subtitle: Text(locService.t('offlineAreas.refreshNodesSubtitle')),
value: _refreshNodes,
onChanged: (value) => setState(() => _refreshNodes = value ?? true),
dense: true,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(locService.t('actions.cancel')),
),
ElevatedButton(
onPressed: (_refreshTiles || _refreshNodes)
? () {
Navigator.of(context).pop();
widget.onRefresh(_refreshTiles, _refreshNodes);
}
: null,
child: Text(locService.t('offlineAreas.startRefresh')),
),
],
);
}
}

View File

@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../services/offline_area_service.dart';
import '../../services/localization_service.dart';
import '../../../app_state.dart';
import '../../../services/offline_area_service.dart';
import '../../../services/localization_service.dart';
class OfflineModeSection extends StatelessWidget {
const OfflineModeSection({super.key});
@@ -61,14 +61,23 @@ class OfflineModeSection extends StatelessWidget {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
return ListTile(
leading: const Icon(Icons.wifi_off),
title: Text(locService.t('settings.offlineMode')),
subtitle: Text(locService.t('settings.offlineModeSubtitle')),
trailing: Switch(
value: appState.offlineMode,
onChanged: (value) => _handleOfflineModeChange(context, appState, value),
),
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('settings.offlineMode'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
ListTile(
leading: const Icon(Icons.wifi_off),
title: Text(locService.t('settings.offlineModeSubtitle')),
trailing: Switch(
value: appState.offlineMode,
onChanged: (value) => _handleOfflineModeChange(context, appState, value),
),
),
],
);
},
);

View File

@@ -1,13 +1,13 @@
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../models/operator_profile.dart';
import '../../services/localization_service.dart';
import '../operator_profile_editor.dart';
import '../../../app_state.dart';
import '../../../models/operator_profile.dart';
import '../../../services/localization_service.dart';
import '../../operator_profile_editor.dart';
class OperatorProfileListSection extends StatelessWidget {
const OperatorProfileListSection({super.key});
class OperatorProfilesSection extends StatelessWidget {
const OperatorProfilesSection({super.key});
@override
Widget build(BuildContext context) {
@@ -22,7 +22,10 @@ class OperatorProfileListSection extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(locService.t('operatorProfiles.title'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
Text(
locService.t('operatorProfiles.title'),
style: Theme.of(context).textTheme.titleMedium,
),
TextButton.icon(
onPressed: () => Navigator.push(
context,

View File

@@ -0,0 +1,221 @@
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<ProximityAlertsSection> createState() => _ProximityAlertsSectionState();
}
class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
late final TextEditingController _distanceController;
bool _notificationsEnabled = false;
bool _checkingPermissions = false;
@override
void initState() {
super.initState();
final appState = context.read<AppState>();
_distanceController = TextEditingController(
text: appState.proximityAlertDistance.toString(),
);
_checkNotificationPermissions();
}
Future<void> _checkNotificationPermissions() async {
setState(() {
_checkingPermissions = true;
});
final enabled = await ProximityAlertService().areNotificationsEnabled();
if (mounted) {
setState(() {
_notificationsEnabled = enabled;
_checkingPermissions = false;
});
}
}
Future<void> _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<AppState>(
builder: (context, appState, child) {
final locService = LocalizationService.instance;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('settings.proximityAlerts'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
// Enable/disable toggle
SwitchListTile(
title: Text(locService.t('proximityAlerts.getNotified')),
subtitle: Text(
'${locService.t('proximityAlerts.batteryUsage')}\n'
'${_notificationsEnabled ? locService.t('proximityAlerts.notificationsEnabled') : locService.t('proximityAlerts.notificationsDisabled')}',
style: 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),
Text(
locService.t('proximityAlerts.permissionRequired'),
style: TextStyle(fontWeight: FontWeight.w600),
),
],
),
const SizedBox(height: 8),
Text(
locService.t('proximityAlerts.permissionExplanation'),
style: TextStyle(fontSize: 12),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: _requestNotificationPermissions,
icon: const Icon(Icons.settings, size: 16),
label: Text(locService.t('proximityAlerts.enableNotifications')),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 32),
textStyle: const TextStyle(fontSize: 12),
),
),
],
),
),
],
// Loading indicator
if (_checkingPermissions) ...[
const SizedBox(height: 8),
Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 8),
Text(locService.t('proximityAlerts.checkingPermissions'), style: TextStyle(fontSize: 12)),
],
),
],
// Distance setting (only show when enabled)
if (appState.proximityAlertsEnabled) ...[
const SizedBox(height: 12),
Row(
children: [
Text(locService.t('proximityAlerts.alertDistance')),
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),
Text(locService.t('proximityAlerts.meters')),
],
),
const SizedBox(height: 8),
Text(
locService.t('proximityAlerts.rangeInfo', params: [
kProximityAlertMinDistance.toString(),
kProximityAlertMaxDistance.toString(),
kProximityAlertDefaultDistance.toString(),
]),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
),
),
],
],
);
},
);
}
}

View File

@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../services/localization_service.dart';
import '../../state/settings_state.dart';
import '../../../app_state.dart';
import '../../../services/localization_service.dart';
import '../../../state/settings_state.dart';
class QueueSection extends StatelessWidget {
const QueueSection({super.key});

View File

@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../app_state.dart';
import '../../../models/tile_provider.dart';
import '../../../services/localization_service.dart';
import '../../tile_provider_editor_screen.dart';
class TileProviderSection extends StatelessWidget {
const TileProviderSection({super.key});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
final providers = appState.tileProviders;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
locService.t('mapTiles.title'),
style: Theme.of(context).textTheme.titleMedium,
),
TextButton.icon(
onPressed: () => _addProvider(context),
icon: const Icon(Icons.add),
label: Text(locService.t('tileProviders.addProvider')),
),
],
),
const SizedBox(height: 8),
if (providers.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(locService.t('tileProviders.noProvidersConfigured')),
),
)
else
...providers.map((provider) => _buildProviderTile(context, provider, appState)).toList(),
],
);
},
);
}
Widget _buildProviderTile(BuildContext context, TileProvider provider, AppState appState) {
final locService = LocalizationService.instance;
final isSelected = appState.selectedTileProvider?.id == provider.id;
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
title: Text(
provider.name,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : null,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(locService.t('tileProviders.tileTypesCount', params: [provider.tileTypes.length.toString()])),
if (provider.apiKey?.isNotEmpty == true)
Text(
locService.t('tileProviders.apiKeyConfigured'),
style: const TextStyle(
fontStyle: FontStyle.italic,
fontSize: 12,
),
),
if (!provider.isUsable)
Text(
locService.t('tileProviders.needsApiKey'),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
],
),
leading: CircleAvatar(
backgroundColor: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceVariant,
child: Icon(
Icons.map,
color: isSelected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
trailing: appState.tileProviders.length > 1
? PopupMenuButton<String>(
onSelected: (action) {
switch (action) {
case 'edit':
_editProvider(context, provider);
break;
case 'delete':
_deleteProvider(context, provider);
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit),
const SizedBox(width: 8),
Text(locService.t('actions.edit')),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete),
const SizedBox(width: 8),
Text(locService.t('tileProviders.deleteProvider')),
],
),
),
],
)
: const Icon(Icons.lock, size: 16), // Can't delete last provider
onTap: () => _editProvider(context, provider),
),
);
}
void _addProvider(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const TileProviderEditorScreen(),
),
);
}
void _editProvider(BuildContext context, TileProvider provider) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => TileProviderEditorScreen(provider: provider),
),
);
}
void _deleteProvider(BuildContext context, TileProvider provider) {
final locService = LocalizationService.instance;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(locService.t('tileProviders.deleteProvider')),
content: Text(locService.t('tileProviders.deleteProviderConfirm', params: [provider.name])),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(locService.t('actions.cancel')),
),
TextButton(
onPressed: () {
context.read<AppState>().deleteTileProvider(provider.id);
Navigator.of(context).pop();
},
child: Text(locService.t('tileProviders.deleteProvider')),
),
],
),
);
}
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../services/localization_service.dart';
import '../../../app_state.dart';
import '../../../services/localization_service.dart';
class UploadModeSection extends StatelessWidget {
const UploadModeSection({super.key});
@@ -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:

View File

@@ -1,15 +1,7 @@
import 'package:flutter/material.dart';
import 'settings_screen_sections/auth_section.dart';
import 'settings_screen_sections/upload_mode_section.dart';
import 'settings_screen_sections/profile_list_section.dart';
import 'settings_screen_sections/operator_profile_list_section.dart';
import 'settings_screen_sections/queue_section.dart';
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/tile_provider_section.dart';
import 'settings_screen_sections/language_section.dart';
import 'settings/sections/auth_section.dart';
import 'settings/sections/upload_mode_section.dart';
import 'settings/sections/queue_section.dart';
import '../services/localization_service.dart';
import '../dev_config.dart';
@@ -18,10 +10,12 @@ class SettingsScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => Scaffold(
appBar: AppBar(title: Text(LocalizationService.instance.t('settings.title'))),
appBar: AppBar(title: Text(locService.t('settings.title'))),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
@@ -34,24 +28,70 @@ class SettingsScreen extends StatelessWidget {
const Divider(),
const QueueSection(),
const Divider(),
const ProfileListSection(),
// Navigation to sub-pages
_buildNavigationTile(
context,
icon: Icons.account_tree,
title: locService.t('settings.profiles'),
subtitle: locService.t('settings.profilesSubtitle'),
onTap: () => Navigator.pushNamed(context, '/settings/profiles'),
),
const Divider(),
const OperatorProfileListSection(),
_buildNavigationTile(
context,
icon: Icons.cloud_off,
title: locService.t('settings.offlineSettings'),
subtitle: locService.t('settings.offlineSettingsSubtitle'),
onTap: () => Navigator.pushNamed(context, '/settings/offline'),
),
const Divider(),
const MaxNodesSection(),
_buildNavigationTile(
context,
icon: Icons.tune,
title: locService.t('settings.advancedSettings'),
subtitle: locService.t('settings.advancedSettingsSubtitle'),
onTap: () => Navigator.pushNamed(context, '/settings/advanced'),
),
const Divider(),
const TileProviderSection(),
_buildNavigationTile(
context,
icon: Icons.language,
title: locService.t('settings.language'),
subtitle: locService.t('settings.languageSubtitle'),
onTap: () => Navigator.pushNamed(context, '/settings/language'),
),
const Divider(),
const OfflineModeSection(),
const Divider(),
const OfflineAreasSection(),
const Divider(),
const LanguageSection(),
const Divider(),
const AboutSection(),
_buildNavigationTile(
context,
icon: Icons.info_outline,
title: locService.t('settings.aboutInfo'),
subtitle: locService.t('settings.aboutSubtitle'),
onTap: () => Navigator.pushNamed(context, '/settings/about'),
),
],
),
),
);
}
Widget _buildNavigationTile(
BuildContext context, {
required IconData icon,
required String title,
required String subtitle,
required VoidCallback onTap,
}) {
return ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(subtitle),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: onTap,
);
}
}

View File

@@ -1,46 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../models/tile_provider.dart';
import '../../services/localization_service.dart';
import '../tile_provider_management_screen.dart';
class TileProviderSection extends StatelessWidget {
const TileProviderSection({super.key});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('mapTiles.title'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const TileProviderManagementScreen(),
),
);
},
icon: const Icon(Icons.settings),
label: Text(locService.t('mapTiles.manageProviders')),
),
),
],
);
},
);
}
}

View File

@@ -7,6 +7,7 @@ import 'package:collection/collection.dart';
import '../app_state.dart';
import '../models/tile_provider.dart';
import '../services/localization_service.dart';
import '../dev_config.dart';
class TileProviderEditorScreen extends StatefulWidget {
final TileProvider? provider; // null for adding new provider
@@ -378,11 +379,11 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
});
try {
// Use a sample tile (zoom 10, somewhere in the world)
// Use a sample tile from configured preview location
final url = _urlController.text
.replaceAll('{z}', '10')
.replaceAll('{x}', '512')
.replaceAll('{y}', '384');
.replaceAll('{z}', kPreviewTileZoom.toString())
.replaceAll('{x}', kPreviewTileX.toString())
.replaceAll('{y}', kPreviewTileY.toString());
final response = await http.get(Uri.parse(url));

View File

@@ -1,168 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
import '../models/tile_provider.dart';
import '../services/localization_service.dart';
import 'tile_provider_editor_screen.dart';
class TileProviderManagementScreen extends StatelessWidget {
const TileProviderManagementScreen({super.key});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
final providers = appState.tileProviders;
return Scaffold(
appBar: AppBar(
title: Text(locService.t('tileProviders.title')),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _addProvider(context),
),
],
),
body: providers.isEmpty
? Center(
child: Text(locService.t('tileProviders.noProvidersConfigured')),
)
: ListView.builder(
itemCount: providers.length,
itemBuilder: (context, index) {
final provider = providers[index];
final isSelected = appState.selectedTileProvider?.id == provider.id;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
title: Text(
provider.name,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : null,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(locService.t('tileProviders.tileTypesCount', params: [provider.tileTypes.length.toString()])),
if (provider.apiKey?.isNotEmpty == true)
Text(
locService.t('tileProviders.apiKeyConfigured'),
style: const TextStyle(
fontStyle: FontStyle.italic,
fontSize: 12,
),
),
if (!provider.isUsable)
Text(
locService.t('tileProviders.needsApiKey'),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
],
),
leading: CircleAvatar(
backgroundColor: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceVariant,
child: Icon(
Icons.map,
color: isSelected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
trailing: providers.length > 1
? PopupMenuButton<String>(
onSelected: (action) {
switch (action) {
case 'edit':
_editProvider(context, provider);
break;
case 'delete':
_deleteProvider(context, provider);
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit),
const SizedBox(width: 8),
Text(locService.t('actions.edit')),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete),
const SizedBox(width: 8),
Text(locService.t('tileProviders.deleteProvider')),
],
),
),
],
)
: const Icon(Icons.lock, size: 16), // Can't delete last provider
onTap: () => _editProvider(context, provider),
),
);
},
),
);
},
);
}
void _addProvider(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const TileProviderEditorScreen(),
),
);
}
void _editProvider(BuildContext context, TileProvider provider) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => TileProviderEditorScreen(provider: provider),
),
);
}
void _deleteProvider(BuildContext context, TileProvider provider) {
final locService = LocalizationService.instance;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(locService.t('tileProviders.deleteProvider')),
content: Text(locService.t('tileProviders.deleteProviderConfirm', params: [provider.name])),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(locService.t('actions.cancel')),
),
TextButton(
onPressed: () {
context.read<AppState>().deleteTileProvider(provider.id);
Navigator.of(context).pop();
},
child: Text(locService.t('tileProviders.deleteProvider')),
),
],
),
);
}
}

View File

@@ -24,9 +24,44 @@ class LocalizationService extends ChangeNotifier {
}
Future<void> _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<String, dynamic> 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<void> _loadSavedLanguage() async {

View File

@@ -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<List<OsmCameraNode>> getNodes({
Future<List<OsmNode>> getNodes({
required LatLngBounds bounds,
required List<NodeProfile> 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 <OsmCameraNode>[];
return <OsmNode>[];
} 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<Future<List<OsmCameraNode>>> futures = [];
final List<Future<List<OsmNode>>> 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 <OsmCameraNode>[]; // Return empty list on remote failure
return <OsmNode>[]; // 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<int, OsmCameraNode> mergedNodes = {};
final Map<int, OsmNode> 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<List<OsmCameraNode>> getAllNodesForDownload({
Future<List<OsmNode>> getAllNodesForDownload({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
@@ -214,7 +214,7 @@ class MapDataProvider {
}
/// Fetch remote nodes with Overpass first, OSM API fallback
Future<List<OsmCameraNode>> _fetchRemoteNodes({
Future<List<OsmNode>> _fetchRemoteNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,

View File

@@ -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<List<OsmCameraNode>> fetchLocalNodes({
Future<List<OsmNode>> fetchLocalNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
int? maxNodes,
}) async {
final areas = OfflineAreaService().offlineAreas;
final Map<int, OsmCameraNode> deduped = {};
final Map<int, OsmNode> deduped = {};
for (final area in areas) {
if (area.status != OfflineAreaStatus.complete) continue;
@@ -38,7 +38,7 @@ Future<List<OsmCameraNode>> fetchLocalNodes({
}
// Try in-memory first, else load from disk
Future<List<OsmCameraNode>> _loadAreaNodes(OfflineArea area) async {
Future<List<OsmNode>> _loadAreaNodes(OfflineArea area) async {
if (area.nodes.isNotEmpty) {
return area.nodes;
}
@@ -58,7 +58,7 @@ Future<List<OsmCameraNode>> _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<NodeProfile> profiles) {
bool _matchesAnyProfile(OsmNode node, List<NodeProfile> 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
}

View File

@@ -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<List<OsmCameraNode>> fetchOsmApiNodes({
Future<List<OsmNode>> fetchOsmApiNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
@@ -47,7 +47,7 @@ Future<List<OsmCameraNode>> fetchOsmApiNodes({
// Parse XML response
final document = XmlDocument.parse(response.body);
final nodes = <OsmCameraNode>[];
final nodes = <OsmNode>[];
// Find all node elements
for (final nodeElement in document.findAllElements('node')) {
@@ -73,7 +73,7 @@ Future<List<OsmCameraNode>> 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,

View File

@@ -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<List<OsmCameraNode>> fetchOverpassNodes({
Future<List<OsmNode>> fetchOverpassNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
@@ -49,7 +49,7 @@ Future<List<OsmCameraNode>> fetchOverpassNodes({
NetworkStatus.instance.reportOverpassSuccess();
final nodes = elements.whereType<Map<String, dynamic>>().map((element) {
return OsmCameraNode(
return OsmNode(
id: element['id'],
coord: LatLng(element['lat'], element['lon']),
tags: Map<String, String>.from(element['tags'] ?? {}),
@@ -101,7 +101,7 @@ $outputClause
}
/// Clean up pending uploads that now appear in Overpass results
void _cleanupCompletedUploads(List<OsmCameraNode> overpassNodes) {
void _cleanupCompletedUploads(List<OsmNode> overpassNodes) {
try {
final appState = AppState.instance;
final pendingUploads = appState.pendingUploads;

View File

@@ -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<int, OsmCameraNode> _nodes = {};
final Map<int, OsmNode> _nodes = {};
/// Add or update a batch of nodes in the cache.
void addOrUpdate(List<OsmCameraNode> nodes) {
void addOrUpdate(List<OsmNode> 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<OsmCameraNode> queryByBounds(LatLngBounds bounds) {
List<OsmNode> queryByBounds(LatLngBounds bounds) {
return _nodes.values
.where((node) => _inBounds(node.coord, bounds))
.toList();
}
/// Retrieve all cached nodes.
List<OsmCameraNode> getAll() => _nodes.values.toList();
List<OsmNode> getAll() => _nodes.values.toList();
/// Optionally clear the cache (rarely needed)
void clear() => _nodes.clear();
@@ -53,7 +53,7 @@ class NodeCache {
final cleanTags = Map<String, String>.from(node.tags);
cleanTags.remove('_pending_edit');
_nodes[nodeId] = OsmCameraNode(
_nodes[nodeId] = OsmNode(
id: node.id,
coord: node.coord,
tags: cleanTags,

View File

@@ -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';
@@ -272,6 +272,120 @@ class OfflineAreaService {
_areas.remove(area);
await saveAreasToDisk();
}
/// Refresh/update an existing offline area - tiles, nodes, or both
Future<void> refreshArea({
required String id,
required bool refreshTiles,
required bool refreshNodes,
void Function(double progress)? onProgress,
void Function(OfflineAreaStatus status)? onComplete,
}) async {
final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found');
if (area.status == OfflineAreaStatus.downloading) {
throw 'Area is already downloading';
}
// Set area to downloading state
area.status = OfflineAreaStatus.downloading;
area.progress = 0.0;
// Only reset tile count if we're actually refreshing tiles
if (refreshTiles) {
area.tilesDownloaded = 0;
}
await saveAreasToDisk();
try {
bool success = true;
if (refreshTiles && refreshNodes) {
// Refresh both - use the full download process
success = await OfflineAreaDownloader.downloadArea(
area: area,
bounds: area.bounds,
minZoom: area.minZoom,
maxZoom: area.maxZoom,
directory: area.directory,
onProgress: onProgress,
saveAreasToDisk: saveAreasToDisk,
getAreaSizeBytes: getAreaSizeBytes,
);
} else if (refreshTiles) {
// Refresh tiles only
success = await _refreshTilesOnly(area, onProgress);
} else if (refreshNodes) {
// Refresh nodes only
success = await _refreshNodesOnly(area, onProgress);
} else {
// Neither option selected - shouldn't happen but handle gracefully
success = true;
area.progress = 1.0;
}
await getAreaSizeBytes(area);
if (success) {
area.status = OfflineAreaStatus.complete;
area.progress = 1.0;
debugPrint('Area $id: refresh completed successfully.');
} else {
area.status = OfflineAreaStatus.error;
debugPrint('Area $id: refresh failed after maximum retry attempts.');
}
await saveAreasToDisk();
onComplete?.call(area.status);
} catch (e) {
area.status = OfflineAreaStatus.error;
await saveAreasToDisk();
onComplete?.call(area.status);
debugPrint('Area $id: refresh failed with exception: $e');
}
}
/// Refresh only the tiles for an area
Future<bool> _refreshTilesOnly(OfflineArea area, void Function(double progress)? onProgress) async {
final allTiles = computeTileList(area.bounds, area.minZoom, area.maxZoom);
area.tilesTotal = allTiles.length;
return await OfflineAreaDownloader.downloadTilesWithRetry(
area: area,
allTiles: allTiles,
directory: area.directory,
onProgress: onProgress,
saveAreasToDisk: saveAreasToDisk,
getAreaSizeBytes: getAreaSizeBytes,
);
}
/// Refresh only the nodes for an area
Future<bool> _refreshNodesOnly(OfflineArea area, void Function(double progress)? onProgress) async {
try {
// Use the same logic as in the downloader for consistency
final nodeZoom = (area.minZoom + 1).clamp(8, 16);
final expandedNodeBounds = OfflineAreaDownloader.calculateNodeBounds(area.bounds, nodeZoom);
final nodes = await MapDataProvider().getAllNodesForDownload(
bounds: expandedNodeBounds,
profiles: AppState.instance.profiles,
);
area.nodes = nodes;
await OfflineAreaDownloader.saveNodes(nodes, area.directory);
// Set progress to complete for nodes-only refresh
onProgress?.call(1.0);
area.progress = 1.0;
debugPrint('Area ${area.id}: Refreshed ${nodes.length} nodes');
return true;
} catch (e) {
debugPrint('Area ${area.id}: Failed to refresh nodes: $e');
return false;
}
}
/// Remove any legacy world areas from previous versions
Future<void> _cleanupLegacyWorldAreas() async {

View File

@@ -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';
@@ -30,7 +30,7 @@ class OfflineAreaDownloader {
area.tilesTotal = allTiles.length;
// Download tiles with retry logic
final success = await _downloadTilesWithRetry(
final success = await downloadTilesWithRetry(
area: area,
allTiles: allTiles,
directory: directory,
@@ -51,7 +51,7 @@ class OfflineAreaDownloader {
}
/// Download tiles with retry logic
static Future<bool> _downloadTilesWithRetry({
static Future<bool> downloadTilesWithRetry({
required OfflineArea area,
required Set<List<int>> allTiles,
required String directory,
@@ -138,7 +138,7 @@ class OfflineAreaDownloader {
// Modest expansion: use tiles at minZoom + 1 instead of minZoom
// This gives a reasonable buffer without capturing entire states
final nodeZoom = (minZoom + 1).clamp(8, 16); // Reasonable bounds for node fetching
final expandedNodeBounds = _calculateNodeBounds(bounds, nodeZoom);
final expandedNodeBounds = calculateNodeBounds(bounds, nodeZoom);
final nodes = await MapDataProvider().getAllNodesForDownload(
bounds: expandedNodeBounds,
@@ -150,7 +150,7 @@ class OfflineAreaDownloader {
}
/// Calculate expanded bounds that cover the entire tile area at minimum zoom
static LatLngBounds _calculateNodeBounds(LatLngBounds visibleBounds, int minZoom) {
static LatLngBounds calculateNodeBounds(LatLngBounds visibleBounds, int minZoom) {
final tiles = computeTileList(visibleBounds, minZoom, minZoom);
if (tiles.isEmpty) return visibleBounds;
@@ -182,7 +182,7 @@ class OfflineAreaDownloader {
}
/// Save nodes to disk as JSON
static Future<void> saveNodes(List<OsmCameraNode> nodes, String dir) async {
static Future<void> saveNodes(List<OsmNode> nodes, String dir) async {
final file = File('$dir/nodes.json');
await file.writeAsString(jsonEncode(nodes.map((n) => n.toJson()).toList()));
}

View File

@@ -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<OsmCameraNode> nodes;
List<OsmNode> 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'],

View File

@@ -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<RecentAlert> _recentAlerts = [];
static const Duration _alertCooldown = kProximityAlertCooldown;
// Callback for showing in-app visual alerts
VoidCallback? _onVisualAlert;
/// Initialize the notification plugin and request permissions
Future<void> 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<void> _requestNotificationPermissions() async {
if (_notifications == null) return;
try {
// Request permissions - this will show the permission dialog on Android 13+
final result = await _notifications!
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
debugPrint('[ProximityAlertService] Android notification permission result: $result');
// Also request for iOS (though this was already done in initialization)
await _notifications!
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
?.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<void> checkProximity({
required LatLng userLocation,
required List<OsmNode> nodes,
required List<NodeProfile> 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<void> _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<NodeProfile> 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<bool> areNotificationsEnabled() async {
if (!_isInitialized || _notifications == null) return false;
try {
// Check Android permissions
final androidImpl = _notifications!
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
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<bool> requestNotificationPermissions() async {
await _requestNotificationPermissions();
return await areNotificationsEnabled();
}
}

View File

@@ -0,0 +1,78 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../models/tile_provider.dart';
import '../state/settings_state.dart';
import '../dev_config.dart';
/// Service for fetching missing tile preview images
class TilePreviewService {
static const Duration _timeout = Duration(seconds: 10);
/// Attempt to fetch missing preview tiles for tile types that don't already have preview data
/// Fails silently - no error handling or user notification on failure
static Future<void> fetchMissingPreviews(SettingsState settingsState) async {
try {
bool anyUpdates = false;
for (final provider in settingsState.tileProviders) {
final updatedTileTypes = <TileType>[];
bool providerNeedsUpdate = false;
for (final tileType in provider.tileTypes) {
// Only fetch if preview tile is missing
if (tileType.previewTile == null) {
// Skip if tile type requires API key but provider doesn't have one
if (tileType.requiresApiKey && (provider.apiKey == null || provider.apiKey!.isEmpty)) {
updatedTileTypes.add(tileType);
continue;
}
final previewData = await _fetchPreviewForTileType(tileType, provider.apiKey);
if (previewData != null) {
// Create updated tile type with preview data
final updatedTileType = tileType.copyWith(previewTile: previewData);
updatedTileTypes.add(updatedTileType);
providerNeedsUpdate = true;
} else {
updatedTileTypes.add(tileType);
}
} else {
updatedTileTypes.add(tileType);
}
}
if (providerNeedsUpdate) {
final updatedProvider = provider.copyWith(tileTypes: updatedTileTypes);
await settingsState.addOrUpdateTileProvider(updatedProvider);
anyUpdates = true;
}
}
if (anyUpdates) {
debugPrint('TilePreviewService: Updated providers with new preview tiles');
}
} catch (e) {
// Fail silently as requested
debugPrint('TilePreviewService: Error during preview fetching: $e');
}
}
static Future<Uint8List?> _fetchPreviewForTileType(TileType tileType, String? apiKey) async {
try {
final url = tileType.getTileUrl(kPreviewTileZoom, kPreviewTileX, kPreviewTileY, apiKey: apiKey);
final response = await http.get(Uri.parse(url)).timeout(_timeout);
if (response.statusCode == 200 && response.bodyBytes.isNotEmpty) {
debugPrint('TilePreviewService: Fetched preview for ${tileType.name}');
return response.bodyBytes;
}
} catch (e) {
// Fail silently - just log for debugging
debugPrint('TilePreviewService: Failed to fetch preview for ${tileType.name}: $e');
}
return null;
}
}

View File

@@ -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<NodeProfile> enabledProfiles) {
void startEditSession(OsmNode node, List<NodeProfile> enabledProfiles) {
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
// Try to find a matching profile based on the node's tags

View File

@@ -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<TileProvider> _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<TileProvider> 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<void> 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<void> 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();
}
}
}

View File

@@ -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<String, String>.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<String, String>.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

View File

@@ -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<OsmCameraNode> getCachedNodesForBounds(LatLngBounds bounds) {
List<OsmNode> 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<NodeProfile> profiles) {
bool _matchesAnyProfile(OsmNode node, List<NodeProfile> 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;
}

View File

@@ -62,7 +62,8 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
final maxPossibleZoom = _calculateMaxZoomForTileLimit(bounds, minZoom);
final nTiles = computeTileList(bounds, minZoom, maxZoom).length;
final totalMb = (nTiles * kTileEstimateKb) / 1024.0;
final tileEstimateKb = _getTileEstimateKb();
final totalMb = (nTiles * tileEstimateKb) / 1024.0;
final roundedMb = (totalMb * 10).round() / 10; // Round to nearest tenth
setState(() {
@@ -84,8 +85,22 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
}
return kAbsoluteMaxZoom;
}
/// Get tile size estimate in KB, using preview tile data if available, otherwise fallback to constant
double _getTileEstimateKb() {
final appState = context.read<AppState>();
final selectedTileType = appState.selectedTileType;
if (selectedTileType?.previewTile != null) {
// Use actual preview tile size
final previewSizeBytes = selectedTileType!.previewTile!.length;
final previewSizeKb = previewSizeBytes / 1024.0;
return previewSizeKb;
} else {
// Fall back to configured estimate
return kFallbackTileEstimateKb;
}
}
@override
Widget build(BuildContext context) {

View File

@@ -4,15 +4,22 @@ 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);
final void Function(OsmNode)? onNodeTap;
const CameraMapMarker({
required this.node,
required this.mapController,
this.onNodeTap,
Key? key,
}) : super(key: key);
@override
State<CameraMapMarker> createState() => _CameraMapMarkerState();
@@ -25,11 +32,18 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
void _onTap() {
_tapTimer = Timer(tapTimeout, () {
showModalBottomSheet(
context: context,
builder: (_) => NodeTagSheet(node: widget.node),
showDragHandle: true,
);
// Don't center immediately - let the sheet opening handle the coordinated animation
// Use callback if provided, otherwise fallback to direct modal
if (widget.onNodeTap != null) {
widget.onNodeTap!(widget.node);
} else {
showModalBottomSheet(
context: context,
builder: (_) => NodeTagSheet(node: widget.node),
showDragHandle: true,
);
}
});
}
@@ -76,20 +90,35 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
/// Helper class to build marker layers for cameras and user location
class CameraMarkersBuilder {
static List<Marker> buildCameraMarkers({
required List<OsmCameraNode> cameras,
required List<OsmNode> cameras,
required MapController mapController,
LatLng? userLocation,
int? selectedNodeId,
void Function(OsmNode)? onNodeTap,
}) {
final markers = <Marker>[
// Camera markers
...cameras
.where(_isValidCameraCoordinate)
.map((n) => Marker(
point: n.coord,
width: kCameraIconDiameter,
height: kCameraIconDiameter,
child: CameraMapMarker(node: n, mapController: mapController),
)),
.map((n) {
// Check if this node should be highlighted (selected) or dimmed
final isSelected = selectedNodeId == n.id;
final shouldDim = selectedNodeId != null && !isSelected;
return Marker(
point: n.coord,
width: kCameraIconDiameter,
height: kCameraIconDiameter,
child: Opacity(
opacity: shouldDim ? 0.5 : 1.0,
child: CameraMapMarker(
node: n,
mapController: mapController,
onNodeTap: onNodeTap,
),
),
);
}),
// User location marker
if (userLocation != null)
@@ -104,7 +133,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;

View File

@@ -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<Polygon> buildDirectionCones({
required List<OsmCameraNode> cameras,
required List<OsmNode> 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';
}

View File

@@ -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<OsmNode> nearbyNodes = const [],
List<NodeProfile> 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<OsmNode> Function() getNearbyNodes,
required List<NodeProfile> 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,
);
});
}

View File

@@ -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<AppState>();
final providers = appState.tileProviders;
final locService = LocalizationService.instance;
// Group tile types by provider for display
final providerGroups = <TileProvider, List<TileType>>{};
@@ -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

View File

@@ -69,7 +69,12 @@ class MapOverlays extends StatelessWidget {
),
child: Builder(
builder: (context) {
final zoom = mapController.camera.zoom;
double zoom = 15.0; // fallback
try {
zoom = mapController.camera.zoom;
} catch (_) {
// Map controller not ready yet
}
return Text(
'Zoom: ${zoom.toStringAsFixed(2)}',
style: const TextStyle(
@@ -118,8 +123,12 @@ class MapOverlays extends StatelessWidget {
mini: true,
heroTag: "zoom_in",
onPressed: () {
final zoom = mapController.camera.zoom;
mapController.move(mapController.camera.center, zoom + 1);
try {
final zoom = mapController.camera.zoom;
mapController.move(mapController.camera.center, zoom + 1);
} catch (_) {
// Map controller not ready yet
}
},
child: const Icon(Icons.add),
),
@@ -129,8 +138,12 @@ class MapOverlays extends StatelessWidget {
mini: true,
heroTag: "zoom_out",
onPressed: () {
final zoom = mapController.camera.zoom;
mapController.move(mapController.camera.center, zoom - 1);
try {
final zoom = mapController.camera.zoom;
mapController.move(mapController.camera.center, zoom - 1);
} catch (_) {
// Map controller not ready yet
}
},
child: const Icon(Icons.remove),
),

View File

@@ -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,11 @@ 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';
import 'sheet_aware_map.dart';
class MapView extends StatefulWidget {
final AnimatedMapController controller;
@@ -31,12 +34,16 @@ class MapView extends StatefulWidget {
required this.controller,
required this.followMeMode,
required this.onUserGesture,
this.bottomPadding = 0.0,
this.sheetHeight = 0.0,
this.selectedNodeId,
this.onNodeTap,
});
final FollowMeMode followMeMode;
final VoidCallback onUserGesture;
final double bottomPadding;
final double sheetHeight;
final int? selectedNodeId;
final void Function(OsmNode)? onNodeTap;
@override
State<MapView> createState() => MapViewState();
@@ -55,6 +62,9 @@ class MapViewState extends State<MapView> {
// Track zoom to clear queue on zoom changes
double? _lastZoom;
// State for proximity alert banner
bool _showProximityBanner = false;
@override
void initState() {
@@ -68,6 +78,17 @@ class MapViewState extends State<MapView> {
_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 +114,59 @@ class MapViewState extends State<MapView> {
}
return FollowMeMode.off;
},
getProximityAlertsEnabled: () {
if (mounted) {
try {
return context.read<AppState>().proximityAlertsEnabled;
} catch (e) {
debugPrint('[MapView] Could not read proximity alerts enabled: $e');
return false;
}
}
return false;
},
getProximityAlertDistance: () {
if (mounted) {
try {
return context.read<AppState>().proximityAlertDistance;
} catch (e) {
debugPrint('[MapView] Could not read proximity alert distance: $e');
return 200;
}
}
return 200;
},
getNearbyNodes: () {
if (mounted) {
try {
final cameraProvider = context.read<CameraProviderWithCache>();
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<AppState>().enabledProfiles;
} catch (e) {
debugPrint('[MapView] Could not read enabled profiles: $e');
return [];
}
}
return [];
},
);
// Fetch initial cameras
@@ -116,6 +190,8 @@ class MapViewState extends State<MapView> {
super.dispose();
}
void _onCamerasUpdated() {
if (mounted) setState(() {});
}
@@ -194,13 +270,7 @@ class MapViewState extends State<MapView> {
}
}
double _safeZoom() {
try {
return _controller.mapController.camera.zoom;
} catch (_) {
return 15.0;
}
}
@@ -242,18 +312,9 @@ class MapViewState extends State<MapView> {
} catch (_) {/* controller not ready yet */}
}
// For edit sessions, center the map on the camera being edited initially
if (editSession != null && _controller.mapController.camera.center != editSession.target) {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
try {
_controller.mapController.move(editSession.target, _controller.mapController.camera.zoom);
} catch (_) {/* controller not ready yet */}
},
);
}
final zoom = _safeZoom();
// Edit sessions don't need to center - we're already centered from the node tap
// SheetAwareMap handles the visual positioning
// Fetch cached cameras for current map bounds (using Consumer so overlays redraw instantly)
Widget cameraLayers = Consumer<CameraProviderWithCache>(
builder: (context, cameraProvider, child) {
@@ -265,17 +326,27 @@ class MapViewState extends State<MapView> {
}
final cameras = (mapBounds != null)
? cameraProvider.getCachedNodesForBounds(mapBounds)
: <OsmCameraNode>[];
: <OsmNode>[];
final markers = CameraMarkersBuilder.buildCameraMarkers(
cameras: cameras,
mapController: _controller.mapController,
userLocation: _gpsController.currentLocation,
selectedNodeId: widget.selectedNodeId,
onNodeTap: widget.onNodeTap,
);
// Get current zoom level for direction cones
double currentZoom = 15.0; // fallback
try {
currentZoom = _controller.mapController.camera.zoom;
} catch (_) {
// Controller not ready yet, use fallback
}
final overlays = DirectionConesBuilder.buildDirectionCones(
cameras: cameras,
zoom: zoom,
zoom: currentZoom,
session: session,
editSession: editSession,
);
@@ -315,10 +386,8 @@ class MapViewState extends State<MapView> {
return Stack(
children: [
AnimatedPadding(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
padding: EdgeInsets.only(bottom: widget.bottomPadding),
SheetAwareMap(
sheetHeight: widget.sheetHeight,
child: FlutterMap(
key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_${_tileManager.mapRebuildKey}'),
mapController: _controller.mapController,
@@ -329,6 +398,7 @@ class MapViewState extends State<MapView> {
onPositionChanged: (pos, gesture) {
setState(() {}); // Instant UI update for zoom, etc.
if (gesture) widget.onUserGesture();
if (session != null) {
appState.updateSession(target: pos.center);
}
@@ -388,7 +458,7 @@ class MapViewState extends State<MapView> {
// backgroundColor removed in flutter_map >=8 (wrap in Container if needed)
),
],
),
),
),
// All map overlays (mode indicator, zoom, attribution, add pin)
@@ -402,12 +472,22 @@ class MapViewState extends State<MapView> {
// 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<Polyline> _buildEditLines(List<OsmCameraNode> cameras) {
List<Polyline> _buildEditLines(List<OsmNode> cameras) {
final lines = <Polyline>[];
// Create a lookup map of original node IDs to their coordinates

View File

@@ -1,13 +1,14 @@
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;
final VoidCallback? onEditPressed;
const NodeTagSheet({super.key, required this.node});
const NodeTagSheet({super.key, required this.node, this.onEditPressed});
@override
Widget build(BuildContext context) {
@@ -26,8 +27,13 @@ class NodeTagSheet extends StatelessWidget {
node.tags['_pending_deletion'] != 'true');
void _openEditSheet() {
Navigator.pop(context); // Close this sheet first
appState.startEditSession(node); // HomeScreen will auto-show the edit sheet
if (onEditPressed != null) {
onEditPressed!(); // Use callback if provided
} else {
// Fallback behavior
Navigator.pop(context); // Close this sheet first
appState.startEditSession(node); // HomeScreen will auto-show the edit sheet
}
}
void _deleteNode() async {

View File

@@ -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<ProximityAlertBanner> createState() => _ProximityAlertBannerState();
}
class _ProximityAlertBannerState extends State<ProximityAlertBanner>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _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,
),
],
),
),
),
),
),
),
);
},
);
}
}

View File

@@ -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<RefineTagsSheet> {
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
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<RefineTagsSheet> {
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<RefineTagsSheet> {
child: Column(
children: [
RadioListTile<OperatorProfile?>(
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<OperatorProfile?>(
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<RefineTagsSheet> {
),
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<RefineTagsSheet> {
),
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) =>

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
/// A wrapper that shifts a map's visual positioning to account for bottom sheets.
///
/// When a sheet is open, moves the map upward by the sheet height while extending
/// the map rendering area to fill the screen. This keeps the bottom edge visible
/// while shifting the visual center up so pins appear above the sheet.
class SheetAwareMap extends StatelessWidget {
const SheetAwareMap({
super.key,
required this.child,
this.sheetHeight = 0.0,
this.animationDuration = const Duration(milliseconds: 300),
});
/// The map widget to position
final Widget child;
/// Current height of the bottom sheet
final double sheetHeight;
/// Duration for smooth transitions when sheet height changes
final Duration animationDuration;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// Use the actual available height from constraints, not full screen height
final availableHeight = constraints.maxHeight;
return Stack(
children: [
AnimatedPositioned(
duration: animationDuration,
curve: Curves.easeOut,
// Move the map up by the sheet height
top: -sheetHeight,
left: 0,
right: 0,
// Extend the height to compensate and fill available area
height: availableHeight + sheetHeight,
child: child,
),
],
);
},
);
}
}

View File

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

View File

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