mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-07-25 21:40:53 +02:00
Merge pull request #16 from FoggedLens/approach-notifications
Approach notifications
This commit is contained in:
+425
@@ -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.
|
||||||
@@ -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
|
- **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
|
- **Offline-first design**: Download a region for complete offline operation
|
||||||
- **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, and gesture-friendly interactions
|
- **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
|
### Device Management
|
||||||
- **Comprehensive profiles**: Built-in profiles for major manufacturers (Flock Safety, Motorola/Vigilant, Genetec, Leonardo/ELSAG, Neology) plus custom profile creation
|
- **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
|
- **Direction visualization**: Interactive field-of-view cones showing camera viewing angles
|
||||||
- **Bulk operations**: Tag multiple devices efficiently with profile-based workflow
|
- **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
|
1. **Install** the app on iOS or Android
|
||||||
2. **Enable location** permissions
|
2. **Enable location** permissions
|
||||||
3. **Log into OpenStreetMap**: Choose upload mode and get OAuth2 credentials
|
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.
|
**New to OpenStreetMap?** Visit [deflock.me](https://deflock.me) for complete setup instructions and community guidelines.
|
||||||
|
|
||||||
@@ -60,37 +61,26 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
|||||||
|
|
||||||
## For Developers
|
## For Developers
|
||||||
|
|
||||||
### Architecture Highlights
|
**See [DEVELOPER.md](DEVELOPER.md)** for comprehensive technical documentation including:
|
||||||
- **Unified data provider**: All map tiles and surveillance device data route through `MapDataProvider` with pluggable remote/local sources
|
- Architecture overview and design decisions
|
||||||
- **Modular settings**: Each settings section is a separate widget for maintainability
|
- Development setup and build instructions
|
||||||
- **State management**: Provider pattern with clean separation of concerns
|
- Code organization and contribution guidelines
|
||||||
- **Offline-first**: Network calls are optional; app functions fully offline with downloaded data and queues uploads until online
|
- Debugging tips and troubleshooting
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
|
**Quick setup:**
|
||||||
```shell
|
```shell
|
||||||
# Basic setup
|
|
||||||
flutter pub get
|
flutter pub get
|
||||||
cp lib/keys.dart.example lib/keys.dart
|
cp lib/keys.dart.example lib/keys.dart
|
||||||
# Add your OAuth2 client IDs to keys.dart
|
# Add OAuth2 client IDs, then: flutter run
|
||||||
|
|
||||||
# iOS additional setup
|
|
||||||
cd ios && pod install
|
|
||||||
|
|
||||||
# Run
|
|
||||||
flutter run
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### v1 todo/bug List
|
### Current Development
|
||||||
- Update offline area nodes while browsing?
|
- Update offline area nodes while browsing?
|
||||||
- Camera deletions
|
- Optional custom icons for camera profiles
|
||||||
- Optional custom icons for camera profiles
|
|
||||||
- Upgrade device marker design (considering nullplate's svg)
|
- Upgrade device marker design (considering nullplate's svg)
|
||||||
|
|
||||||
### Future Features & Wishlist
|
### Future Features & Wishlist
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ android {
|
|||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||||
@@ -50,3 +51,7 @@ flutter {
|
|||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
<!-- Location permissions for blue‑dot positioning -->
|
<!-- Location permissions for blue‑dot positioning -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||||
|
|
||||||
|
<!-- Notification permission for proximity alerts -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
+15
-3
@@ -4,7 +4,7 @@ import 'package:latlong2/latlong.dart';
|
|||||||
|
|
||||||
import 'models/node_profile.dart';
|
import 'models/node_profile.dart';
|
||||||
import 'models/operator_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/pending_upload.dart';
|
||||||
import 'models/tile_provider.dart';
|
import 'models/tile_provider.dart';
|
||||||
import 'services/offline_area_service.dart';
|
import 'services/offline_area_service.dart';
|
||||||
@@ -79,6 +79,8 @@ class AppState extends ChangeNotifier {
|
|||||||
int get maxCameras => _settingsState.maxCameras;
|
int get maxCameras => _settingsState.maxCameras;
|
||||||
UploadMode get uploadMode => _settingsState.uploadMode;
|
UploadMode get uploadMode => _settingsState.uploadMode;
|
||||||
FollowMeMode get followMeMode => _settingsState.followMeMode;
|
FollowMeMode get followMeMode => _settingsState.followMeMode;
|
||||||
|
bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled;
|
||||||
|
int get proximityAlertDistance => _settingsState.proximityAlertDistance;
|
||||||
|
|
||||||
// Tile provider state
|
// Tile provider state
|
||||||
List<TileProvider> get tileProviders => _settingsState.tileProviders;
|
List<TileProvider> get tileProviders => _settingsState.tileProviders;
|
||||||
@@ -162,7 +164,7 @@ class AppState extends ChangeNotifier {
|
|||||||
_sessionState.startAddSession(enabledProfiles);
|
_sessionState.startAddSession(enabledProfiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
void startEditSession(OsmCameraNode node) {
|
void startEditSession(OsmNode node) {
|
||||||
_sessionState.startEditSession(node, enabledProfiles);
|
_sessionState.startEditSession(node, enabledProfiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +220,7 @@ class AppState extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteNode(OsmCameraNode node) {
|
void deleteNode(OsmNode node) {
|
||||||
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode);
|
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode);
|
||||||
_startUploader();
|
_startUploader();
|
||||||
}
|
}
|
||||||
@@ -270,6 +272,16 @@ class AppState extends ChangeNotifier {
|
|||||||
await _settingsState.setFollowMeMode(mode);
|
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 ----------
|
// ---------- Queue Methods ----------
|
||||||
void clearQueue() {
|
void clearQueue() {
|
||||||
_uploadQueueState.clearQueue();
|
_uploadQueueState.clearQueue();
|
||||||
|
|||||||
+7
-1
@@ -31,7 +31,7 @@ const double kAddPinYOffset = 0.0;
|
|||||||
|
|
||||||
// Client name and version for OSM uploads ("created_by" tag)
|
// Client name and version for OSM uploads ("created_by" tag)
|
||||||
const String kClientName = 'DeFlock';
|
const String kClientName = 'DeFlock';
|
||||||
const String kClientVersion = '0.9.12';
|
const String kClientVersion = '0.9.13';
|
||||||
|
|
||||||
// Development/testing features - set to false for production builds
|
// 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
|
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
|
||||||
@@ -46,6 +46,12 @@ const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
|
|||||||
const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600);
|
const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600);
|
||||||
const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rotation
|
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
|
// Last map location and settings storage
|
||||||
const String kLastMapLatKey = 'last_map_latitude';
|
const String kLastMapLatKey = 'last_map_latitude';
|
||||||
const String kLastMapLngKey = 'last_map_longitude';
|
const String kLastMapLngKey = 'last_map_longitude';
|
||||||
|
|||||||
@@ -90,7 +90,6 @@
|
|||||||
"simulate": "Simulieren",
|
"simulate": "Simulieren",
|
||||||
"productionDescription": "Hochladen in die Live-OSM-Datenbank (für alle Benutzer sichtbar)",
|
"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).",
|
"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)"
|
"simulateDescription": "Uploads simulieren (kontaktiert OSM-Server nicht)"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
@@ -220,6 +219,7 @@
|
|||||||
"noAreasTitle": "Keine Offline-Bereiche",
|
"noAreasTitle": "Keine Offline-Bereiche",
|
||||||
"noAreasSubtitle": "Laden Sie einen Kartenbereich für die Offline-Nutzung herunter.",
|
"noAreasSubtitle": "Laden Sie einen Kartenbereich für die Offline-Nutzung herunter.",
|
||||||
"provider": "Anbieter",
|
"provider": "Anbieter",
|
||||||
|
"maxZoom": "Max Zoom",
|
||||||
"zoomLevels": "Z{}-{}",
|
"zoomLevels": "Z{}-{}",
|
||||||
"latitude": "Breite",
|
"latitude": "Breite",
|
||||||
"longitude": "Länge",
|
"longitude": "Länge",
|
||||||
@@ -237,5 +237,22 @@
|
|||||||
"megabytes": "MB",
|
"megabytes": "MB",
|
||||||
"kilobytes": "KB",
|
"kilobytes": "KB",
|
||||||
"progress": "{}%"
|
"progress": "{}%"
|
||||||
|
},
|
||||||
|
"refineTagsSheet": {
|
||||||
|
"title": "Tags Verfeinern",
|
||||||
|
"operatorProfile": "Betreiber-Profil",
|
||||||
|
"done": "Fertig",
|
||||||
|
"none": "Keine",
|
||||||
|
"noAdditionalOperatorTags": "Keine zusätzlichen Betreiber-Tags",
|
||||||
|
"additionalTags": "zusätzliche Tags",
|
||||||
|
"additionalTagsTitle": "Zusätzliche Tags",
|
||||||
|
"noTagsDefinedForProfile": "Keine Tags für dieses Betreiber-Profil definiert.",
|
||||||
|
"noOperatorProfiles": "Keine Betreiber-Profile definiert",
|
||||||
|
"noOperatorProfilesMessage": "Erstellen Sie Betreiber-Profile in den Einstellungen, um zusätzliche Tags auf Ihre Knoten-Übertragungen anzuwenden."
|
||||||
|
},
|
||||||
|
"layerSelector": {
|
||||||
|
"cannotChangeTileTypes": "Kachel-Typen können während des Herunterladens von Offline-Bereichen nicht geändert werden",
|
||||||
|
"selectMapLayer": "Kartenschicht Auswählen",
|
||||||
|
"noTileProvidersAvailable": "Keine Kachel-Anbieter verfügbar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,6 @@
|
|||||||
"simulate": "Simulate",
|
"simulate": "Simulate",
|
||||||
"productionDescription": "Upload to the live OSM database (visible to all users)",
|
"productionDescription": "Upload to the live OSM database (visible to all users)",
|
||||||
"sandboxDescription": "Uploads go to the OSM Sandbox (safe for testing, resets regularly).",
|
"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)"
|
"simulateDescription": "Simulate uploads (does not contact OSM servers)"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
@@ -220,6 +219,7 @@
|
|||||||
"noAreasTitle": "No offline areas",
|
"noAreasTitle": "No offline areas",
|
||||||
"noAreasSubtitle": "Download a map area for offline use.",
|
"noAreasSubtitle": "Download a map area for offline use.",
|
||||||
"provider": "Provider",
|
"provider": "Provider",
|
||||||
|
"maxZoom": "Max zoom",
|
||||||
"zoomLevels": "Z{}-{}",
|
"zoomLevels": "Z{}-{}",
|
||||||
"latitude": "Lat",
|
"latitude": "Lat",
|
||||||
"longitude": "Lon",
|
"longitude": "Lon",
|
||||||
@@ -237,5 +237,22 @@
|
|||||||
"megabytes": "MB",
|
"megabytes": "MB",
|
||||||
"kilobytes": "KB",
|
"kilobytes": "KB",
|
||||||
"progress": "{}%"
|
"progress": "{}%"
|
||||||
|
},
|
||||||
|
"refineTagsSheet": {
|
||||||
|
"title": "Refine Tags",
|
||||||
|
"operatorProfile": "Operator Profile",
|
||||||
|
"done": "Done",
|
||||||
|
"none": "None",
|
||||||
|
"noAdditionalOperatorTags": "No additional operator tags",
|
||||||
|
"additionalTags": "additional tags",
|
||||||
|
"additionalTagsTitle": "Additional Tags",
|
||||||
|
"noTagsDefinedForProfile": "No tags defined for this operator profile.",
|
||||||
|
"noOperatorProfiles": "No operator profiles defined",
|
||||||
|
"noOperatorProfilesMessage": "Create operator profiles in Settings to apply additional tags to your node submissions."
|
||||||
|
},
|
||||||
|
"layerSelector": {
|
||||||
|
"cannotChangeTileTypes": "Cannot change tile types while downloading offline areas",
|
||||||
|
"selectMapLayer": "Select Map Layer",
|
||||||
|
"noTileProvidersAvailable": "No tile providers available"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,6 @@
|
|||||||
"simulate": "Simular",
|
"simulate": "Simular",
|
||||||
"productionDescription": "Subir a la base de datos OSM en vivo (visible para todos los usuarios)",
|
"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).",
|
"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)"
|
"simulateDescription": "Simular subidas (no contacta servidores OSM)"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
@@ -220,6 +219,7 @@
|
|||||||
"noAreasTitle": "Sin áreas sin conexión",
|
"noAreasTitle": "Sin áreas sin conexión",
|
||||||
"noAreasSubtitle": "Descarga un área del mapa para uso sin conexión.",
|
"noAreasSubtitle": "Descarga un área del mapa para uso sin conexión.",
|
||||||
"provider": "Proveedor",
|
"provider": "Proveedor",
|
||||||
|
"maxZoom": "Zoom máx",
|
||||||
"zoomLevels": "Z{}-{}",
|
"zoomLevels": "Z{}-{}",
|
||||||
"latitude": "Lat",
|
"latitude": "Lat",
|
||||||
"longitude": "Lon",
|
"longitude": "Lon",
|
||||||
@@ -237,5 +237,22 @@
|
|||||||
"megabytes": "MB",
|
"megabytes": "MB",
|
||||||
"kilobytes": "KB",
|
"kilobytes": "KB",
|
||||||
"progress": "{}%"
|
"progress": "{}%"
|
||||||
|
},
|
||||||
|
"refineTagsSheet": {
|
||||||
|
"title": "Refinar Etiquetas",
|
||||||
|
"operatorProfile": "Perfil de Operador",
|
||||||
|
"done": "Listo",
|
||||||
|
"none": "Ninguno",
|
||||||
|
"noAdditionalOperatorTags": "Sin etiquetas adicionales de operador",
|
||||||
|
"additionalTags": "etiquetas adicionales",
|
||||||
|
"additionalTagsTitle": "Etiquetas Adicionales",
|
||||||
|
"noTagsDefinedForProfile": "No hay etiquetas definidas para este perfil de operador.",
|
||||||
|
"noOperatorProfiles": "No hay perfiles de operador definidos",
|
||||||
|
"noOperatorProfilesMessage": "Cree perfiles de operador en Configuración para aplicar etiquetas adicionales a sus envíos de nodos."
|
||||||
|
},
|
||||||
|
"layerSelector": {
|
||||||
|
"cannotChangeTileTypes": "No se pueden cambiar los tipos de teselas mientras se descargan áreas sin conexión",
|
||||||
|
"selectMapLayer": "Seleccionar Capa del Mapa",
|
||||||
|
"noTileProvidersAvailable": "No hay proveedores de teselas disponibles"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,6 @@
|
|||||||
"simulate": "Simuler",
|
"simulate": "Simuler",
|
||||||
"productionDescription": "Télécharger vers la base de données OSM en direct (visible pour tous les utilisateurs)",
|
"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).",
|
"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)"
|
"simulateDescription": "Simuler les téléchargements (ne contacte pas les serveurs OSM)"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
@@ -220,6 +219,7 @@
|
|||||||
"noAreasTitle": "Aucune zone hors ligne",
|
"noAreasTitle": "Aucune zone hors ligne",
|
||||||
"noAreasSubtitle": "Téléchargez une zone de carte pour utilisation hors ligne.",
|
"noAreasSubtitle": "Téléchargez une zone de carte pour utilisation hors ligne.",
|
||||||
"provider": "Fournisseur",
|
"provider": "Fournisseur",
|
||||||
|
"maxZoom": "Zoom max",
|
||||||
"zoomLevels": "Z{}-{}",
|
"zoomLevels": "Z{}-{}",
|
||||||
"latitude": "Lat",
|
"latitude": "Lat",
|
||||||
"longitude": "Lon",
|
"longitude": "Lon",
|
||||||
@@ -237,5 +237,22 @@
|
|||||||
"megabytes": "Mo",
|
"megabytes": "Mo",
|
||||||
"kilobytes": "Ko",
|
"kilobytes": "Ko",
|
||||||
"progress": "{}%"
|
"progress": "{}%"
|
||||||
|
},
|
||||||
|
"refineTagsSheet": {
|
||||||
|
"title": "Affiner les Étiquettes",
|
||||||
|
"operatorProfile": "Profil d'Opérateur",
|
||||||
|
"done": "Terminé",
|
||||||
|
"none": "Aucun",
|
||||||
|
"noAdditionalOperatorTags": "Aucune étiquette d'opérateur supplémentaire",
|
||||||
|
"additionalTags": "étiquettes supplémentaires",
|
||||||
|
"additionalTagsTitle": "Étiquettes Supplémentaires",
|
||||||
|
"noTagsDefinedForProfile": "Aucune étiquette définie pour ce profil d'opérateur.",
|
||||||
|
"noOperatorProfiles": "Aucun profil d'opérateur défini",
|
||||||
|
"noOperatorProfilesMessage": "Créez des profils d'opérateur dans les Paramètres pour appliquer des étiquettes supplémentaires à vos soumissions de nœuds."
|
||||||
|
},
|
||||||
|
"layerSelector": {
|
||||||
|
"cannotChangeTileTypes": "Impossible de changer les types de tuiles pendant le téléchargement des zones hors ligne",
|
||||||
|
"selectMapLayer": "Sélectionner la Couche de Carte",
|
||||||
|
"noTileProvidersAvailable": "Aucun fournisseur de tuiles disponible"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
{
|
||||||
|
"language": {
|
||||||
|
"name": "Italiano"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"title": "DeFlock"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"tagNode": "Nuovo Nodo",
|
||||||
|
"download": "Scarica",
|
||||||
|
"settings": "Impostazioni",
|
||||||
|
"edit": "Modifica",
|
||||||
|
"delete": "Elimina",
|
||||||
|
"cancel": "Annulla",
|
||||||
|
"ok": "OK",
|
||||||
|
"close": "Chiudi",
|
||||||
|
"submit": "Invia",
|
||||||
|
"saveEdit": "Salva Modifica",
|
||||||
|
"clear": "Pulisci"
|
||||||
|
},
|
||||||
|
"followMe": {
|
||||||
|
"off": "Attiva seguimi (nord in alto)",
|
||||||
|
"northUp": "Attiva seguimi (rotazione)",
|
||||||
|
"rotating": "Disattiva seguimi"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Impostazioni",
|
||||||
|
"language": "Lingua",
|
||||||
|
"systemDefault": "Predefinito del Sistema",
|
||||||
|
"aboutInfo": "Informazioni",
|
||||||
|
"aboutThisApp": "Informazioni su questa App",
|
||||||
|
"maxNodes": "Max nodi recuperati/disegnati",
|
||||||
|
"maxNodesSubtitle": "Imposta un limite superiore per il numero di nodi sulla mappa (predefinito: 250).",
|
||||||
|
"maxNodesWarning": "Probabilmente non vuoi farlo a meno che non sei assolutamente sicuro di avere una buona ragione per farlo.",
|
||||||
|
"offlineMode": "Modalità Offline",
|
||||||
|
"offlineModeSubtitle": "Disabilita tutte le richieste di rete tranne per aree locali/offline.",
|
||||||
|
"offlineModeWarningTitle": "Download Attivi",
|
||||||
|
"offlineModeWarningMessage": "L'attivazione della modalità offline cancellerà qualsiasi download di area attivo. Vuoi continuare?",
|
||||||
|
"enableOfflineMode": "Attiva Modalità Offline"
|
||||||
|
},
|
||||||
|
"node": {
|
||||||
|
"title": "Nodo #{}",
|
||||||
|
"tagSheetTitle": "Tag Dispositivo di Sorveglianza",
|
||||||
|
"queuedForUpload": "Nodo in coda per il caricamento",
|
||||||
|
"editQueuedForUpload": "Modifica nodo in coda per il caricamento",
|
||||||
|
"deleteQueuedForUpload": "Eliminazione nodo in coda per il caricamento",
|
||||||
|
"confirmDeleteTitle": "Elimina Nodo",
|
||||||
|
"confirmDeleteMessage": "Sei sicuro di voler eliminare il nodo #{}? Questa azione non può essere annullata."
|
||||||
|
},
|
||||||
|
"addNode": {
|
||||||
|
"profile": "Profilo",
|
||||||
|
"direction": "Direzione {}°",
|
||||||
|
"profileNoDirectionInfo": "Questo profilo non richiede una direzione.",
|
||||||
|
"mustBeLoggedIn": "Devi essere loggato per inviare nuovi nodi. Per favore accedi tramite Impostazioni.",
|
||||||
|
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per inviare nuovi nodi.",
|
||||||
|
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per inviare nuovi nodi.",
|
||||||
|
"refineTags": "Affina Tag",
|
||||||
|
"refineTagsWithProfile": "Affina Tag ({})"
|
||||||
|
},
|
||||||
|
"editNode": {
|
||||||
|
"title": "Modifica Nodo #{}",
|
||||||
|
"profile": "Profilo",
|
||||||
|
"direction": "Direzione {}°",
|
||||||
|
"profileNoDirectionInfo": "Questo profilo non richiede una direzione.",
|
||||||
|
"mustBeLoggedIn": "Devi essere loggato per modificare i nodi. Per favore accedi tramite Impostazioni.",
|
||||||
|
"sandboxModeWarning": "Impossibile inviare modifiche di nodi di produzione alla sandbox. Passa alla modalità Produzione nelle Impostazioni per modificare i nodi.",
|
||||||
|
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per modificare i nodi.",
|
||||||
|
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per modificare i nodi.",
|
||||||
|
"refineTags": "Affina Tag",
|
||||||
|
"refineTagsWithProfile": "Affina Tag ({})"
|
||||||
|
},
|
||||||
|
"download": {
|
||||||
|
"title": "Scarica Area Mappa",
|
||||||
|
"maxZoomLevel": "Livello zoom max",
|
||||||
|
"storageEstimate": "Stima archiviazione:",
|
||||||
|
"tilesAndSize": "{} tile, {} MB",
|
||||||
|
"minZoom": "Zoom min:",
|
||||||
|
"maxRecommendedZoom": "Zoom max raccomandato: Z{}",
|
||||||
|
"withinTileLimit": "Entro il limite di {} tile",
|
||||||
|
"exceedsTileLimit": "La selezione corrente supera il limite di {} tile",
|
||||||
|
"offlineModeWarning": "Download disabilitati in modalità offline. Disabilita la modalità offline per scaricare nuove aree.",
|
||||||
|
"downloadStarted": "Download avviato! Recupero tile e telecamere...",
|
||||||
|
"downloadFailed": "Impossibile avviare il download: {}"
|
||||||
|
},
|
||||||
|
"uploadMode": {
|
||||||
|
"title": "Destinazione Upload",
|
||||||
|
"subtitle": "Scegli dove vengono caricate le telecamere",
|
||||||
|
"production": "Produzione",
|
||||||
|
"sandbox": "Sandbox",
|
||||||
|
"simulate": "Simula",
|
||||||
|
"productionDescription": "Carica nel database OSM dal vivo (visibile a tutti gli utenti)",
|
||||||
|
"sandboxDescription": "Gli upload vanno alla Sandbox OSM (sicuro per i test, si resetta regolarmente).",
|
||||||
|
"simulateDescription": "Simula upload (non contatta i server OSM)"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"loggedInAs": "Loggato come {}",
|
||||||
|
"loginToOSM": "Accedi a OpenStreetMap",
|
||||||
|
"tapToLogout": "Tocca per disconnetterti",
|
||||||
|
"requiredToSubmit": "Richiesto per inviare dati delle telecamere",
|
||||||
|
"loggedOut": "Disconnesso",
|
||||||
|
"testConnection": "Testa Connessione",
|
||||||
|
"testConnectionSubtitle": "Verifica che le credenziali OSM funzionino",
|
||||||
|
"connectionOK": "Connessione OK - le credenziali sono valide",
|
||||||
|
"connectionFailed": "Connessione fallita - per favore accedi di nuovo"
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"pendingUploads": "Upload in sospeso: {}",
|
||||||
|
"simulateModeEnabled": "Modalità simulazione abilitata – upload simulati",
|
||||||
|
"sandboxMode": "Modalità sandbox – upload vanno alla Sandbox OSM",
|
||||||
|
"tapToViewQueue": "Tocca per vedere la coda",
|
||||||
|
"clearUploadQueue": "Pulisci Coda Upload",
|
||||||
|
"removeAllPending": "Rimuovi tutti i {} upload in sospeso",
|
||||||
|
"clearQueueTitle": "Pulisci Coda",
|
||||||
|
"clearQueueConfirm": "Rimuovere tutti i {} upload in sospeso?",
|
||||||
|
"queueCleared": "Coda pulita",
|
||||||
|
"uploadQueueTitle": "Coda Upload ({} elementi)",
|
||||||
|
"queueIsEmpty": "La coda è vuota",
|
||||||
|
"cameraWithIndex": "Telecamera {}",
|
||||||
|
"error": " (Errore)",
|
||||||
|
"completing": " (Completamento...)",
|
||||||
|
"destination": "Dest: {}",
|
||||||
|
"latitude": "Lat: {}",
|
||||||
|
"longitude": "Lon: {}",
|
||||||
|
"direction": "Direzione: {}°",
|
||||||
|
"attempts": "Tentativi: {}",
|
||||||
|
"uploadFailedRetry": "Upload fallito. Tocca riprova per tentare di nuovo.",
|
||||||
|
"retryUpload": "Riprova upload",
|
||||||
|
"clearAll": "Pulisci Tutto"
|
||||||
|
},
|
||||||
|
"tileProviders": {
|
||||||
|
"title": "Fornitori di Tile",
|
||||||
|
"noProvidersConfigured": "Nessun fornitore di tile configurato",
|
||||||
|
"tileTypesCount": "{} tipi di tile",
|
||||||
|
"apiKeyConfigured": "Chiave API configurata",
|
||||||
|
"needsApiKey": "Richiede chiave API",
|
||||||
|
"editProvider": "Modifica Fornitore",
|
||||||
|
"addProvider": "Aggiungi Fornitore",
|
||||||
|
"deleteProvider": "Elimina Fornitore",
|
||||||
|
"deleteProviderConfirm": "Sei sicuro di voler eliminare \"{}\"?",
|
||||||
|
"providerName": "Nome Fornitore",
|
||||||
|
"providerNameHint": "es., Mappe Personalizzate Inc.",
|
||||||
|
"providerNameRequired": "Il nome del fornitore è obbligatorio",
|
||||||
|
"apiKey": "Chiave API (Opzionale)",
|
||||||
|
"apiKeyHint": "Inserisci la chiave API se richiesta dai tipi di tile",
|
||||||
|
"tileTypes": "Tipi di Tile",
|
||||||
|
"addType": "Aggiungi Tipo",
|
||||||
|
"noTileTypesConfigured": "Nessun tipo di tile configurato",
|
||||||
|
"atLeastOneTileTypeRequired": "È richiesto almeno un tipo di tile",
|
||||||
|
"manageTileProviders": "Gestisci Fornitori"
|
||||||
|
},
|
||||||
|
"tileTypeEditor": {
|
||||||
|
"editTileType": "Modifica Tipo Tile",
|
||||||
|
"addTileType": "Aggiungi Tipo Tile",
|
||||||
|
"name": "Nome",
|
||||||
|
"nameHint": "es., Satellite",
|
||||||
|
"nameRequired": "Il nome è obbligatorio",
|
||||||
|
"urlTemplate": "Template URL",
|
||||||
|
"urlTemplateHint": "https://esempio.com/{z}/{x}/{y}.png",
|
||||||
|
"urlTemplateRequired": "Il template URL è obbligatorio",
|
||||||
|
"urlTemplatePlaceholders": "L'URL deve contenere i segnaposto {z}, {x} e {y}",
|
||||||
|
"attribution": "Attribuzione",
|
||||||
|
"attributionHint": "© Fornitore Mappe",
|
||||||
|
"attributionRequired": "L'attribuzione è obbligatoria",
|
||||||
|
"fetchPreview": "Ottieni Anteprima",
|
||||||
|
"previewTileLoaded": "Tile di anteprima caricato con successo",
|
||||||
|
"previewTileFailed": "Impossibile ottenere l'anteprima: {}",
|
||||||
|
"save": "Salva"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"nodeProfiles": "Profili Nodo",
|
||||||
|
"newProfile": "Nuovo Profilo",
|
||||||
|
"builtIn": "Integrato",
|
||||||
|
"custom": "Personalizzato",
|
||||||
|
"view": "Visualizza",
|
||||||
|
"deleteProfile": "Elimina Profilo",
|
||||||
|
"deleteProfileConfirm": "Sei sicuro di voler eliminare \"{}\"?",
|
||||||
|
"profileDeleted": "Profilo eliminato"
|
||||||
|
},
|
||||||
|
"mapTiles": {
|
||||||
|
"title": "Tile Mappa",
|
||||||
|
"manageProviders": "Gestisci Fornitori"
|
||||||
|
},
|
||||||
|
"profileEditor": {
|
||||||
|
"viewProfile": "Visualizza Profilo",
|
||||||
|
"newProfile": "Nuovo Profilo",
|
||||||
|
"editProfile": "Modifica Profilo",
|
||||||
|
"profileName": "Nome profilo",
|
||||||
|
"profileNameHint": "es., Telecamera ALPR Personalizzata",
|
||||||
|
"profileNameRequired": "Il nome del profilo è obbligatorio",
|
||||||
|
"requiresDirection": "Richiede Direzione",
|
||||||
|
"requiresDirectionSubtitle": "Se le telecamere di questo tipo necessitano di un tag direzione",
|
||||||
|
"submittable": "Inviabile",
|
||||||
|
"submittableSubtitle": "Se questo profilo può essere usato per invii di telecamere",
|
||||||
|
"osmTags": "Tag OSM",
|
||||||
|
"addTag": "Aggiungi Tag",
|
||||||
|
"saveProfile": "Salva Profilo",
|
||||||
|
"keyHint": "chiave",
|
||||||
|
"valueHint": "valore",
|
||||||
|
"atLeastOneTagRequired": "È richiesto almeno un tag",
|
||||||
|
"profileSaved": "Profilo \"{}\" salvato"
|
||||||
|
},
|
||||||
|
"operatorProfileEditor": {
|
||||||
|
"newOperatorProfile": "Nuovo Profilo Operatore",
|
||||||
|
"editOperatorProfile": "Modifica Profilo Operatore",
|
||||||
|
"operatorName": "Nome operatore",
|
||||||
|
"operatorNameHint": "es., Dipartimento di Polizia di Austin",
|
||||||
|
"operatorNameRequired": "Il nome dell'operatore è obbligatorio",
|
||||||
|
"operatorProfileSaved": "Profilo operatore \"{}\" salvato"
|
||||||
|
},
|
||||||
|
"operatorProfiles": {
|
||||||
|
"title": "Profili Operatore",
|
||||||
|
"noProfilesMessage": "Nessun profilo operatore definito. Creane uno per applicare tag operatore agli invii di nodi.",
|
||||||
|
"tagsCount": "{} tag",
|
||||||
|
"deleteOperatorProfile": "Elimina Profilo Operatore",
|
||||||
|
"deleteOperatorProfileConfirm": "Sei sicuro di voler eliminare \"{}\"?",
|
||||||
|
"operatorProfileDeleted": "Profilo operatore eliminato"
|
||||||
|
},
|
||||||
|
"offlineAreas": {
|
||||||
|
"noAreasTitle": "Nessuna area offline",
|
||||||
|
"noAreasSubtitle": "Scarica un'area mappa per l'uso offline.",
|
||||||
|
"provider": "Fornitore",
|
||||||
|
"maxZoom": "Zoom max",
|
||||||
|
"zoomLevels": "Z{}-{}",
|
||||||
|
"latitude": "Lat",
|
||||||
|
"longitude": "Lon",
|
||||||
|
"tiles": "Tile",
|
||||||
|
"size": "Dimensione",
|
||||||
|
"cameras": "Telecamere",
|
||||||
|
"areaIdFallback": "Area {}...",
|
||||||
|
"renameArea": "Rinomina area",
|
||||||
|
"refreshWorldTiles": "Aggiorna/ri-scarica tile mondiali",
|
||||||
|
"deleteOfflineArea": "Elimina area offline",
|
||||||
|
"cancelDownload": "Annulla download",
|
||||||
|
"renameAreaDialogTitle": "Rinomina Area Offline",
|
||||||
|
"areaNameLabel": "Nome Area",
|
||||||
|
"renameButton": "Rinomina",
|
||||||
|
"megabytes": "MB",
|
||||||
|
"kilobytes": "KB",
|
||||||
|
"progress": "{}%"
|
||||||
|
},
|
||||||
|
"refineTagsSheet": {
|
||||||
|
"title": "Affina Tag",
|
||||||
|
"operatorProfile": "Profilo Operatore",
|
||||||
|
"done": "Fatto",
|
||||||
|
"none": "Nessuno",
|
||||||
|
"noAdditionalOperatorTags": "Nessun tag operatore aggiuntivo",
|
||||||
|
"additionalTags": "tag aggiuntivi",
|
||||||
|
"additionalTagsTitle": "Tag Aggiuntivi",
|
||||||
|
"noTagsDefinedForProfile": "Nessun tag definito per questo profilo operatore.",
|
||||||
|
"noOperatorProfiles": "Nessun profilo operatore definito",
|
||||||
|
"noOperatorProfilesMessage": "Crea profili operatore nelle Impostazioni per applicare tag aggiuntivi ai tuoi invii di nodi."
|
||||||
|
},
|
||||||
|
"layerSelector": {
|
||||||
|
"cannotChangeTileTypes": "Impossibile cambiare tipi di tile durante il download di aree offline",
|
||||||
|
"selectMapLayer": "Seleziona Livello Mappa",
|
||||||
|
"noTileProvidersAvailable": "Nessun fornitore di tile disponibile"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
{
|
||||||
|
"language": {
|
||||||
|
"name": "Português"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"title": "DeFlock"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"tagNode": "Novo Nó",
|
||||||
|
"download": "Baixar",
|
||||||
|
"settings": "Configurações",
|
||||||
|
"edit": "Editar",
|
||||||
|
"delete": "Excluir",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"ok": "OK",
|
||||||
|
"close": "Fechar",
|
||||||
|
"submit": "Enviar",
|
||||||
|
"saveEdit": "Salvar Edição",
|
||||||
|
"clear": "Limpar"
|
||||||
|
},
|
||||||
|
"followMe": {
|
||||||
|
"off": "Ativar seguir-me (norte para cima)",
|
||||||
|
"northUp": "Ativar seguir-me (rotação)",
|
||||||
|
"rotating": "Desativar seguir-me"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Configurações",
|
||||||
|
"language": "Idioma",
|
||||||
|
"systemDefault": "Padrão do Sistema",
|
||||||
|
"aboutInfo": "Sobre / Informações",
|
||||||
|
"aboutThisApp": "Sobre este App",
|
||||||
|
"maxNodes": "Máx. de nós obtidos/desenhados",
|
||||||
|
"maxNodesSubtitle": "Definir um limite superior para o número de nós no mapa (padrão: 250).",
|
||||||
|
"maxNodesWarning": "Você provavelmente não quer fazer isso a menos que tenha certeza absoluta de que tem uma boa razão para isso.",
|
||||||
|
"offlineMode": "Modo Offline",
|
||||||
|
"offlineModeSubtitle": "Desabilitar todas as requisições de rede exceto para áreas locais/offline.",
|
||||||
|
"offlineModeWarningTitle": "Downloads Ativos",
|
||||||
|
"offlineModeWarningMessage": "Ativar o modo offline cancelará qualquer download de área ativo. Deseja continuar?",
|
||||||
|
"enableOfflineMode": "Ativar Modo Offline"
|
||||||
|
},
|
||||||
|
"node": {
|
||||||
|
"title": "Nó #{}",
|
||||||
|
"tagSheetTitle": "Tags do Dispositivo de Vigilância",
|
||||||
|
"queuedForUpload": "Nó na fila para envio",
|
||||||
|
"editQueuedForUpload": "Edição de nó na fila para envio",
|
||||||
|
"deleteQueuedForUpload": "Exclusão de nó na fila para envio",
|
||||||
|
"confirmDeleteTitle": "Excluir Nó",
|
||||||
|
"confirmDeleteMessage": "Tem certeza de que deseja excluir o nó #{}? Esta ação não pode ser desfeita."
|
||||||
|
},
|
||||||
|
"addNode": {
|
||||||
|
"profile": "Perfil",
|
||||||
|
"direction": "Direção {}°",
|
||||||
|
"profileNoDirectionInfo": "Este perfil não requer uma direção.",
|
||||||
|
"mustBeLoggedIn": "Você deve estar logado para enviar novos nós. Por favor, faça login via Configurações.",
|
||||||
|
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para enviar novos nós.",
|
||||||
|
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para enviar novos nós.",
|
||||||
|
"refineTags": "Refinar Tags",
|
||||||
|
"refineTagsWithProfile": "Refinar Tags ({})"
|
||||||
|
},
|
||||||
|
"editNode": {
|
||||||
|
"title": "Editar Nó #{}",
|
||||||
|
"profile": "Perfil",
|
||||||
|
"direction": "Direção {}°",
|
||||||
|
"profileNoDirectionInfo": "Este perfil não requer uma direção.",
|
||||||
|
"mustBeLoggedIn": "Você deve estar logado para editar nós. Por favor, faça login via Configurações.",
|
||||||
|
"sandboxModeWarning": "Não é possível enviar edições de nós de produção para o sandbox. Mude para o modo Produção nas Configurações para editar nós.",
|
||||||
|
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para editar nós.",
|
||||||
|
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para editar nós.",
|
||||||
|
"refineTags": "Refinar Tags",
|
||||||
|
"refineTagsWithProfile": "Refinar Tags ({})"
|
||||||
|
},
|
||||||
|
"download": {
|
||||||
|
"title": "Baixar Área do Mapa",
|
||||||
|
"maxZoomLevel": "Nível máx. de zoom",
|
||||||
|
"storageEstimate": "Estimativa de armazenamento:",
|
||||||
|
"tilesAndSize": "{} tiles, {} MB",
|
||||||
|
"minZoom": "Zoom mín.:",
|
||||||
|
"maxRecommendedZoom": "Zoom máx. recomendado: Z{}",
|
||||||
|
"withinTileLimit": "Dentro do limite de {} tiles",
|
||||||
|
"exceedsTileLimit": "A seleção atual excede o limite de {} tiles",
|
||||||
|
"offlineModeWarning": "Downloads desabilitados no modo offline. Desative o modo offline para baixar novas áreas.",
|
||||||
|
"downloadStarted": "Download iniciado! Buscando tiles e câmeras...",
|
||||||
|
"downloadFailed": "Falha ao iniciar o download: {}"
|
||||||
|
},
|
||||||
|
"uploadMode": {
|
||||||
|
"title": "Destino do Upload",
|
||||||
|
"subtitle": "Escolha onde as câmeras são enviadas",
|
||||||
|
"production": "Produção",
|
||||||
|
"sandbox": "Sandbox",
|
||||||
|
"simulate": "Simular",
|
||||||
|
"productionDescription": "Enviar para o banco de dados OSM ao vivo (visível para todos os usuários)",
|
||||||
|
"sandboxDescription": "Uploads vão para o Sandbox OSM (seguro para testes, redefine regularmente).",
|
||||||
|
"simulateDescription": "Simular uploads (não contacta servidores OSM)"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"loggedInAs": "Logado como {}",
|
||||||
|
"loginToOSM": "Fazer login no OpenStreetMap",
|
||||||
|
"tapToLogout": "Toque para sair",
|
||||||
|
"requiredToSubmit": "Necessário para enviar dados de câmeras",
|
||||||
|
"loggedOut": "Deslogado",
|
||||||
|
"testConnection": "Testar Conexão",
|
||||||
|
"testConnectionSubtitle": "Verificar se as credenciais OSM estão funcionando",
|
||||||
|
"connectionOK": "Conexão OK - credenciais são válidas",
|
||||||
|
"connectionFailed": "Conexão falhou - por favor, faça login novamente"
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"pendingUploads": "Uploads pendentes: {}",
|
||||||
|
"simulateModeEnabled": "Modo simulação ativado – uploads simulados",
|
||||||
|
"sandboxMode": "Modo sandbox – uploads vão para o Sandbox OSM",
|
||||||
|
"tapToViewQueue": "Toque para ver a fila",
|
||||||
|
"clearUploadQueue": "Limpar Fila de Upload",
|
||||||
|
"removeAllPending": "Remover todos os {} uploads pendentes",
|
||||||
|
"clearQueueTitle": "Limpar Fila",
|
||||||
|
"clearQueueConfirm": "Remover todos os {} uploads pendentes?",
|
||||||
|
"queueCleared": "Fila limpa",
|
||||||
|
"uploadQueueTitle": "Fila de Upload ({} itens)",
|
||||||
|
"queueIsEmpty": "A fila está vazia",
|
||||||
|
"cameraWithIndex": "Câmera {}",
|
||||||
|
"error": " (Erro)",
|
||||||
|
"completing": " (Completando...)",
|
||||||
|
"destination": "Dest: {}",
|
||||||
|
"latitude": "Lat: {}",
|
||||||
|
"longitude": "Lon: {}",
|
||||||
|
"direction": "Direção: {}°",
|
||||||
|
"attempts": "Tentativas: {}",
|
||||||
|
"uploadFailedRetry": "Upload falhou. Toque em tentar novamente para tentar novamente.",
|
||||||
|
"retryUpload": "Tentar upload novamente",
|
||||||
|
"clearAll": "Limpar Tudo"
|
||||||
|
},
|
||||||
|
"tileProviders": {
|
||||||
|
"title": "Provedores de Tiles",
|
||||||
|
"noProvidersConfigured": "Nenhum provedor de tiles configurado",
|
||||||
|
"tileTypesCount": "{} tipos de tiles",
|
||||||
|
"apiKeyConfigured": "Chave API configurada",
|
||||||
|
"needsApiKey": "Precisa de chave API",
|
||||||
|
"editProvider": "Editar Provedor",
|
||||||
|
"addProvider": "Adicionar Provedor",
|
||||||
|
"deleteProvider": "Excluir Provedor",
|
||||||
|
"deleteProviderConfirm": "Tem certeza de que deseja excluir \"{}\"?",
|
||||||
|
"providerName": "Nome do Provedor",
|
||||||
|
"providerNameHint": "ex., Mapas Personalizados Inc.",
|
||||||
|
"providerNameRequired": "Nome do provedor é obrigatório",
|
||||||
|
"apiKey": "Chave API (Opcional)",
|
||||||
|
"apiKeyHint": "Insira a chave API se necessária pelos tipos de tiles",
|
||||||
|
"tileTypes": "Tipos de Tiles",
|
||||||
|
"addType": "Adicionar Tipo",
|
||||||
|
"noTileTypesConfigured": "Nenhum tipo de tile configurado",
|
||||||
|
"atLeastOneTileTypeRequired": "Pelo menos um tipo de tile é obrigatório",
|
||||||
|
"manageTileProviders": "Gerenciar Provedores"
|
||||||
|
},
|
||||||
|
"tileTypeEditor": {
|
||||||
|
"editTileType": "Editar Tipo de Tile",
|
||||||
|
"addTileType": "Adicionar Tipo de Tile",
|
||||||
|
"name": "Nome",
|
||||||
|
"nameHint": "ex., Satélite",
|
||||||
|
"nameRequired": "Nome é obrigatório",
|
||||||
|
"urlTemplate": "Modelo de URL",
|
||||||
|
"urlTemplateHint": "https://exemplo.com/{z}/{x}/{y}.png",
|
||||||
|
"urlTemplateRequired": "Modelo de URL é obrigatório",
|
||||||
|
"urlTemplatePlaceholders": "URL deve conter os marcadores {z}, {x} e {y}",
|
||||||
|
"attribution": "Atribuição",
|
||||||
|
"attributionHint": "© Provedor de Mapas",
|
||||||
|
"attributionRequired": "Atribuição é obrigatória",
|
||||||
|
"fetchPreview": "Buscar Preview",
|
||||||
|
"previewTileLoaded": "Tile de preview carregado com sucesso",
|
||||||
|
"previewTileFailed": "Falha ao buscar preview: {}",
|
||||||
|
"save": "Salvar"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"nodeProfiles": "Perfis de Nó",
|
||||||
|
"newProfile": "Novo Perfil",
|
||||||
|
"builtIn": "Integrado",
|
||||||
|
"custom": "Personalizado",
|
||||||
|
"view": "Ver",
|
||||||
|
"deleteProfile": "Excluir Perfil",
|
||||||
|
"deleteProfileConfirm": "Tem certeza de que deseja excluir \"{}\"?",
|
||||||
|
"profileDeleted": "Perfil excluído"
|
||||||
|
},
|
||||||
|
"mapTiles": {
|
||||||
|
"title": "Tiles do Mapa",
|
||||||
|
"manageProviders": "Gerenciar Provedores"
|
||||||
|
},
|
||||||
|
"profileEditor": {
|
||||||
|
"viewProfile": "Ver Perfil",
|
||||||
|
"newProfile": "Novo Perfil",
|
||||||
|
"editProfile": "Editar Perfil",
|
||||||
|
"profileName": "Nome do perfil",
|
||||||
|
"profileNameHint": "ex., Câmera ALPR Personalizada",
|
||||||
|
"profileNameRequired": "Nome do perfil é obrigatório",
|
||||||
|
"requiresDirection": "Requer Direção",
|
||||||
|
"requiresDirectionSubtitle": "Se câmeras deste tipo precisam de uma tag de direção",
|
||||||
|
"submittable": "Enviável",
|
||||||
|
"submittableSubtitle": "Se este perfil pode ser usado para envios de câmeras",
|
||||||
|
"osmTags": "Tags OSM",
|
||||||
|
"addTag": "Adicionar Tag",
|
||||||
|
"saveProfile": "Salvar Perfil",
|
||||||
|
"keyHint": "chave",
|
||||||
|
"valueHint": "valor",
|
||||||
|
"atLeastOneTagRequired": "Pelo menos uma tag é obrigatória",
|
||||||
|
"profileSaved": "Perfil \"{}\" salvo"
|
||||||
|
},
|
||||||
|
"operatorProfileEditor": {
|
||||||
|
"newOperatorProfile": "Novo Perfil de Operador",
|
||||||
|
"editOperatorProfile": "Editar Perfil de Operador",
|
||||||
|
"operatorName": "Nome do operador",
|
||||||
|
"operatorNameHint": "ex., Departamento de Polícia de Austin",
|
||||||
|
"operatorNameRequired": "Nome do operador é obrigatório",
|
||||||
|
"operatorProfileSaved": "Perfil de operador \"{}\" salvo"
|
||||||
|
},
|
||||||
|
"operatorProfiles": {
|
||||||
|
"title": "Perfis de Operador",
|
||||||
|
"noProfilesMessage": "Nenhum perfil de operador definido. Crie um para aplicar tags de operador aos envios de nós.",
|
||||||
|
"tagsCount": "{} tags",
|
||||||
|
"deleteOperatorProfile": "Excluir Perfil de Operador",
|
||||||
|
"deleteOperatorProfileConfirm": "Tem certeza de que deseja excluir \"{}\"?",
|
||||||
|
"operatorProfileDeleted": "Perfil de operador excluído"
|
||||||
|
},
|
||||||
|
"offlineAreas": {
|
||||||
|
"noAreasTitle": "Nenhuma área offline",
|
||||||
|
"noAreasSubtitle": "Baixe uma área do mapa para uso offline.",
|
||||||
|
"provider": "Provedor",
|
||||||
|
"maxZoom": "Zoom máx",
|
||||||
|
"zoomLevels": "Z{}-{}",
|
||||||
|
"latitude": "Lat",
|
||||||
|
"longitude": "Lon",
|
||||||
|
"tiles": "Tiles",
|
||||||
|
"size": "Tamanho",
|
||||||
|
"cameras": "Câmeras",
|
||||||
|
"areaIdFallback": "Área {}...",
|
||||||
|
"renameArea": "Renomear área",
|
||||||
|
"refreshWorldTiles": "Atualizar/rebaixar tiles mundiais",
|
||||||
|
"deleteOfflineArea": "Excluir área offline",
|
||||||
|
"cancelDownload": "Cancelar download",
|
||||||
|
"renameAreaDialogTitle": "Renomear Área Offline",
|
||||||
|
"areaNameLabel": "Nome da Área",
|
||||||
|
"renameButton": "Renomear",
|
||||||
|
"megabytes": "MB",
|
||||||
|
"kilobytes": "KB",
|
||||||
|
"progress": "{}%"
|
||||||
|
},
|
||||||
|
"refineTagsSheet": {
|
||||||
|
"title": "Refinar Tags",
|
||||||
|
"operatorProfile": "Perfil de Operador",
|
||||||
|
"done": "Concluído",
|
||||||
|
"none": "Nenhum",
|
||||||
|
"noAdditionalOperatorTags": "Nenhuma tag adicional de operador",
|
||||||
|
"additionalTags": "tags adicionais",
|
||||||
|
"additionalTagsTitle": "Tags Adicionais",
|
||||||
|
"noTagsDefinedForProfile": "Nenhuma tag definida para este perfil de operador.",
|
||||||
|
"noOperatorProfiles": "Nenhum perfil de operador definido",
|
||||||
|
"noOperatorProfilesMessage": "Crie perfis de operador nas Configurações para aplicar tags adicionais aos seus envios de nós."
|
||||||
|
},
|
||||||
|
"layerSelector": {
|
||||||
|
"cannotChangeTileTypes": "Não é possível alterar tipos de tiles durante o download de áreas offline",
|
||||||
|
"selectMapLayer": "Selecionar Camada do Mapa",
|
||||||
|
"noTileProvidersAvailable": "Nenhum provedor de tiles disponível"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
{
|
||||||
|
"language": {
|
||||||
|
"name": "中文"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"title": "DeFlock"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"tagNode": "新建节点",
|
||||||
|
"download": "下载",
|
||||||
|
"settings": "设置",
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除",
|
||||||
|
"cancel": "取消",
|
||||||
|
"ok": "确定",
|
||||||
|
"close": "关闭",
|
||||||
|
"submit": "提交",
|
||||||
|
"saveEdit": "保存编辑",
|
||||||
|
"clear": "清空"
|
||||||
|
},
|
||||||
|
"followMe": {
|
||||||
|
"off": "启用跟随模式(北向上)",
|
||||||
|
"northUp": "启用跟随模式(旋转)",
|
||||||
|
"rotating": "禁用跟随模式"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "设置",
|
||||||
|
"language": "语言",
|
||||||
|
"systemDefault": "系统默认",
|
||||||
|
"aboutInfo": "关于 / 信息",
|
||||||
|
"aboutThisApp": "关于此应用",
|
||||||
|
"maxNodes": "最大节点获取/绘制数",
|
||||||
|
"maxNodesSubtitle": "设置地图上节点数量的上限(默认:250)。",
|
||||||
|
"maxNodesWarning": "除非您确定有充分的理由,否则您可能不想这样做。",
|
||||||
|
"offlineMode": "离线模式",
|
||||||
|
"offlineModeSubtitle": "禁用除本地/离线区域外的所有网络请求。",
|
||||||
|
"offlineModeWarningTitle": "活动下载",
|
||||||
|
"offlineModeWarningMessage": "启用离线模式将取消任何活动的区域下载。您要继续吗?",
|
||||||
|
"enableOfflineMode": "启用离线模式"
|
||||||
|
},
|
||||||
|
"node": {
|
||||||
|
"title": "节点 #{}",
|
||||||
|
"tagSheetTitle": "监控设备标签",
|
||||||
|
"queuedForUpload": "节点已排队上传",
|
||||||
|
"editQueuedForUpload": "节点编辑已排队上传",
|
||||||
|
"deleteQueuedForUpload": "节点删除已排队上传",
|
||||||
|
"confirmDeleteTitle": "删除节点",
|
||||||
|
"confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。"
|
||||||
|
},
|
||||||
|
"addNode": {
|
||||||
|
"profile": "配置文件",
|
||||||
|
"direction": "方向 {}°",
|
||||||
|
"profileNoDirectionInfo": "此配置文件不需要方向。",
|
||||||
|
"mustBeLoggedIn": "您必须登录才能提交新节点。请通过设置登录。",
|
||||||
|
"enableSubmittableProfile": "在设置中启用可提交的配置文件以提交新节点。",
|
||||||
|
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来提交新节点。",
|
||||||
|
"refineTags": "细化标签",
|
||||||
|
"refineTagsWithProfile": "细化标签({})"
|
||||||
|
},
|
||||||
|
"editNode": {
|
||||||
|
"title": "编辑节点 #{}",
|
||||||
|
"profile": "配置文件",
|
||||||
|
"direction": "方向 {}°",
|
||||||
|
"profileNoDirectionInfo": "此配置文件不需要方向。",
|
||||||
|
"mustBeLoggedIn": "您必须登录才能编辑节点。请通过设置登录。",
|
||||||
|
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
|
||||||
|
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
|
||||||
|
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
|
||||||
|
"refineTags": "细化标签",
|
||||||
|
"refineTagsWithProfile": "细化标签({})"
|
||||||
|
},
|
||||||
|
"download": {
|
||||||
|
"title": "下载地图区域",
|
||||||
|
"maxZoomLevel": "最大缩放级别",
|
||||||
|
"storageEstimate": "存储估算:",
|
||||||
|
"tilesAndSize": "{} 瓦片,{} MB",
|
||||||
|
"minZoom": "最小缩放:",
|
||||||
|
"maxRecommendedZoom": "最大推荐缩放:Z{}",
|
||||||
|
"withinTileLimit": "在 {} 瓦片限制内",
|
||||||
|
"exceedsTileLimit": "当前选择超出 {} 瓦片限制",
|
||||||
|
"offlineModeWarning": "离线模式下禁用下载。禁用离线模式以下载新区域。",
|
||||||
|
"downloadStarted": "下载已开始!正在获取瓦片和摄像头...",
|
||||||
|
"downloadFailed": "启动下载失败:{}"
|
||||||
|
},
|
||||||
|
"uploadMode": {
|
||||||
|
"title": "上传目标",
|
||||||
|
"subtitle": "选择摄像头上传位置",
|
||||||
|
"production": "生产环境",
|
||||||
|
"sandbox": "沙盒",
|
||||||
|
"simulate": "模拟",
|
||||||
|
"productionDescription": "上传到实时 OSM 数据库(对所有用户可见)",
|
||||||
|
"sandboxDescription": "上传到 OSM 沙盒(测试安全,定期重置)。",
|
||||||
|
"simulateDescription": "模拟上传(不联系 OSM 服务器)"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"loggedInAs": "已登录为 {}",
|
||||||
|
"loginToOSM": "登录 OpenStreetMap",
|
||||||
|
"tapToLogout": "点击登出",
|
||||||
|
"requiredToSubmit": "提交摄像头数据所需",
|
||||||
|
"loggedOut": "已登出",
|
||||||
|
"testConnection": "测试连接",
|
||||||
|
"testConnectionSubtitle": "验证 OSM 凭据是否有效",
|
||||||
|
"connectionOK": "连接正常 - 凭据有效",
|
||||||
|
"connectionFailed": "连接失败 - 请重新登录"
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"pendingUploads": "待上传:{}",
|
||||||
|
"simulateModeEnabled": "模拟模式已启用 – 上传已模拟",
|
||||||
|
"sandboxMode": "沙盒模式 – 上传到 OSM 沙盒",
|
||||||
|
"tapToViewQueue": "点击查看队列",
|
||||||
|
"clearUploadQueue": "清空上传队列",
|
||||||
|
"removeAllPending": "移除所有 {} 个待上传项",
|
||||||
|
"clearQueueTitle": "清空队列",
|
||||||
|
"clearQueueConfirm": "移除所有 {} 个待上传项?",
|
||||||
|
"queueCleared": "队列已清空",
|
||||||
|
"uploadQueueTitle": "上传队列({} 项)",
|
||||||
|
"queueIsEmpty": "队列为空",
|
||||||
|
"cameraWithIndex": "摄像头 {}",
|
||||||
|
"error": "(错误)",
|
||||||
|
"completing": "(完成中...)",
|
||||||
|
"destination": "目标:{}",
|
||||||
|
"latitude": "纬度:{}",
|
||||||
|
"longitude": "经度:{}",
|
||||||
|
"direction": "方向:{}°",
|
||||||
|
"attempts": "尝试次数:{}",
|
||||||
|
"uploadFailedRetry": "上传失败。点击重试再次尝试。",
|
||||||
|
"retryUpload": "重试上传",
|
||||||
|
"clearAll": "全部清空"
|
||||||
|
},
|
||||||
|
"tileProviders": {
|
||||||
|
"title": "瓦片提供商",
|
||||||
|
"noProvidersConfigured": "未配置瓦片提供商",
|
||||||
|
"tileTypesCount": "{} 种瓦片类型",
|
||||||
|
"apiKeyConfigured": "API 密钥已配置",
|
||||||
|
"needsApiKey": "需要 API 密钥",
|
||||||
|
"editProvider": "编辑提供商",
|
||||||
|
"addProvider": "添加提供商",
|
||||||
|
"deleteProvider": "删除提供商",
|
||||||
|
"deleteProviderConfirm": "您确定要删除 \"{}\" 吗?",
|
||||||
|
"providerName": "提供商名称",
|
||||||
|
"providerNameHint": "例如,自定义地图公司",
|
||||||
|
"providerNameRequired": "提供商名称为必填项",
|
||||||
|
"apiKey": "API 密钥(可选)",
|
||||||
|
"apiKeyHint": "如果瓦片类型需要,请输入 API 密钥",
|
||||||
|
"tileTypes": "瓦片类型",
|
||||||
|
"addType": "添加类型",
|
||||||
|
"noTileTypesConfigured": "未配置瓦片类型",
|
||||||
|
"atLeastOneTileTypeRequired": "至少需要一种瓦片类型",
|
||||||
|
"manageTileProviders": "管理提供商"
|
||||||
|
},
|
||||||
|
"tileTypeEditor": {
|
||||||
|
"editTileType": "编辑瓦片类型",
|
||||||
|
"addTileType": "添加瓦片类型",
|
||||||
|
"name": "名称",
|
||||||
|
"nameHint": "例如,卫星",
|
||||||
|
"nameRequired": "名称为必填项",
|
||||||
|
"urlTemplate": "URL 模板",
|
||||||
|
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
|
||||||
|
"urlTemplateRequired": "URL 模板为必填项",
|
||||||
|
"urlTemplatePlaceholders": "URL 必须包含 {z}、{x} 和 {y} 占位符",
|
||||||
|
"attribution": "归属",
|
||||||
|
"attributionHint": "© 地图提供商",
|
||||||
|
"attributionRequired": "归属为必填项",
|
||||||
|
"fetchPreview": "获取预览",
|
||||||
|
"previewTileLoaded": "预览瓦片加载成功",
|
||||||
|
"previewTileFailed": "获取预览失败:{}",
|
||||||
|
"save": "保存"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"nodeProfiles": "节点配置文件",
|
||||||
|
"newProfile": "新建配置文件",
|
||||||
|
"builtIn": "内置",
|
||||||
|
"custom": "自定义",
|
||||||
|
"view": "查看",
|
||||||
|
"deleteProfile": "删除配置文件",
|
||||||
|
"deleteProfileConfirm": "您确定要删除 \"{}\" 吗?",
|
||||||
|
"profileDeleted": "配置文件已删除"
|
||||||
|
},
|
||||||
|
"mapTiles": {
|
||||||
|
"title": "地图瓦片",
|
||||||
|
"manageProviders": "管理提供商"
|
||||||
|
},
|
||||||
|
"profileEditor": {
|
||||||
|
"viewProfile": "查看配置文件",
|
||||||
|
"newProfile": "新建配置文件",
|
||||||
|
"editProfile": "编辑配置文件",
|
||||||
|
"profileName": "配置文件名称",
|
||||||
|
"profileNameHint": "例如,自定义 ALPR 摄像头",
|
||||||
|
"profileNameRequired": "配置文件名称为必填项",
|
||||||
|
"requiresDirection": "需要方向",
|
||||||
|
"requiresDirectionSubtitle": "此类型的摄像头是否需要方向标签",
|
||||||
|
"submittable": "可提交",
|
||||||
|
"submittableSubtitle": "此配置文件是否可用于摄像头提交",
|
||||||
|
"osmTags": "OSM 标签",
|
||||||
|
"addTag": "添加标签",
|
||||||
|
"saveProfile": "保存配置文件",
|
||||||
|
"keyHint": "键",
|
||||||
|
"valueHint": "值",
|
||||||
|
"atLeastOneTagRequired": "至少需要一个标签",
|
||||||
|
"profileSaved": "配置文件 \"{}\" 已保存"
|
||||||
|
},
|
||||||
|
"operatorProfileEditor": {
|
||||||
|
"newOperatorProfile": "新建运营商配置文件",
|
||||||
|
"editOperatorProfile": "编辑运营商配置文件",
|
||||||
|
"operatorName": "运营商名称",
|
||||||
|
"operatorNameHint": "例如,奥斯汀警察局",
|
||||||
|
"operatorNameRequired": "运营商名称为必填项",
|
||||||
|
"operatorProfileSaved": "运营商配置文件 \"{}\" 已保存"
|
||||||
|
},
|
||||||
|
"operatorProfiles": {
|
||||||
|
"title": "运营商配置文件",
|
||||||
|
"noProfilesMessage": "未定义运营商配置文件。创建一个以将运营商标签应用于节点提交。",
|
||||||
|
"tagsCount": "{} 个标签",
|
||||||
|
"deleteOperatorProfile": "删除运营商配置文件",
|
||||||
|
"deleteOperatorProfileConfirm": "您确定要删除 \"{}\" 吗?",
|
||||||
|
"operatorProfileDeleted": "运营商配置文件已删除"
|
||||||
|
},
|
||||||
|
"offlineAreas": {
|
||||||
|
"noAreasTitle": "无离线区域",
|
||||||
|
"noAreasSubtitle": "下载地图区域以供离线使用。",
|
||||||
|
"provider": "提供商",
|
||||||
|
"maxZoom": "最大缩放",
|
||||||
|
"zoomLevels": "Z{}-{}",
|
||||||
|
"latitude": "纬度",
|
||||||
|
"longitude": "经度",
|
||||||
|
"tiles": "瓦片",
|
||||||
|
"size": "大小",
|
||||||
|
"cameras": "摄像头",
|
||||||
|
"areaIdFallback": "区域 {}...",
|
||||||
|
"renameArea": "重命名区域",
|
||||||
|
"refreshWorldTiles": "刷新/重新下载世界瓦片",
|
||||||
|
"deleteOfflineArea": "删除离线区域",
|
||||||
|
"cancelDownload": "取消下载",
|
||||||
|
"renameAreaDialogTitle": "重命名离线区域",
|
||||||
|
"areaNameLabel": "区域名称",
|
||||||
|
"renameButton": "重命名",
|
||||||
|
"megabytes": "MB",
|
||||||
|
"kilobytes": "KB",
|
||||||
|
"progress": "{}%"
|
||||||
|
},
|
||||||
|
"refineTagsSheet": {
|
||||||
|
"title": "细化标签",
|
||||||
|
"operatorProfile": "运营商配置文件",
|
||||||
|
"done": "完成",
|
||||||
|
"none": "无",
|
||||||
|
"noAdditionalOperatorTags": "无额外运营商标签",
|
||||||
|
"additionalTags": "额外标签",
|
||||||
|
"additionalTagsTitle": "额外标签",
|
||||||
|
"noTagsDefinedForProfile": "此运营商配置文件未定义标签。",
|
||||||
|
"noOperatorProfiles": "未定义运营商配置文件",
|
||||||
|
"noOperatorProfilesMessage": "在设置中创建运营商配置文件,以将额外标签应用于您的节点提交。"
|
||||||
|
},
|
||||||
|
"layerSelector": {
|
||||||
|
"cannotChangeTileTypes": "在下载离线区域时无法更改瓦片类型",
|
||||||
|
"selectMapLayer": "选择地图图层",
|
||||||
|
"noTileProvidersAvailable": "无可用瓦片提供商"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
class OsmCameraNode {
|
class OsmNode {
|
||||||
final int id;
|
final int id;
|
||||||
final LatLng coord;
|
final LatLng coord;
|
||||||
final Map<String, String> tags;
|
final Map<String, String> tags;
|
||||||
|
|
||||||
OsmCameraNode({
|
OsmNode({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.coord,
|
required this.coord,
|
||||||
required this.tags,
|
required this.tags,
|
||||||
@@ -18,14 +18,14 @@ class OsmCameraNode {
|
|||||||
'tags': tags,
|
'tags': tags,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory OsmCameraNode.fromJson(Map<String, dynamic> json) {
|
factory OsmNode.fromJson(Map<String, dynamic> json) {
|
||||||
final tags = <String, String>{};
|
final tags = <String, String>{};
|
||||||
if (json['tags'] != null) {
|
if (json['tags'] != null) {
|
||||||
(json['tags'] as Map<String, dynamic>).forEach((k, v) {
|
(json['tags'] as Map<String, dynamic>).forEach((k, v) {
|
||||||
tags[k.toString()] = v.toString();
|
tags[k.toString()] = v.toString();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return OsmCameraNode(
|
return OsmNode(
|
||||||
id: json['id'] is int ? json['id'] as int : int.tryParse(json['id'].toString()) ?? 0,
|
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()),
|
coord: LatLng((json['lat'] as num).toDouble(), (json['lon'] as num).toDouble()),
|
||||||
tags: tags,
|
tags: tags,
|
||||||
@@ -51,5 +51,4 @@ class OsmCameraNode {
|
|||||||
final normalized = ((val % 360) + 360) % 360;
|
final normalized = ((val % 360) + 360) % 360;
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import 'settings_screen_sections/offline_areas_section.dart';
|
|||||||
import 'settings_screen_sections/offline_mode_section.dart';
|
import 'settings_screen_sections/offline_mode_section.dart';
|
||||||
import 'settings_screen_sections/about_section.dart';
|
import 'settings_screen_sections/about_section.dart';
|
||||||
import 'settings_screen_sections/max_nodes_section.dart';
|
import 'settings_screen_sections/max_nodes_section.dart';
|
||||||
|
import 'settings_screen_sections/proximity_alerts_section.dart';
|
||||||
import 'settings_screen_sections/tile_provider_section.dart';
|
import 'settings_screen_sections/tile_provider_section.dart';
|
||||||
import 'settings_screen_sections/language_section.dart';
|
import 'settings_screen_sections/language_section.dart';
|
||||||
import '../services/localization_service.dart';
|
import '../services/localization_service.dart';
|
||||||
@@ -40,6 +41,8 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
const Divider(),
|
const Divider(),
|
||||||
const MaxNodesSection(),
|
const MaxNodesSection(),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
const ProximityAlertsSection(),
|
||||||
|
const Divider(),
|
||||||
const TileProviderSection(),
|
const TileProviderSection(),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
const OfflineModeSection(),
|
const OfflineModeSection(),
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
|
|||||||
: '--';
|
: '--';
|
||||||
|
|
||||||
String subtitle = '${locService.t('offlineAreas.provider')}: ${area.tileProviderDisplay}\n' +
|
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.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)}';
|
'${locService.t('offlineAreas.latitude')}: ${area.bounds.northEast.latitude.toStringAsFixed(3)}, ${area.bounds.northEast.longitude.toStringAsFixed(3)}';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../app_state.dart';
|
||||||
|
import '../../services/localization_service.dart';
|
||||||
|
import '../../services/proximity_alert_service.dart';
|
||||||
|
import '../../dev_config.dart';
|
||||||
|
|
||||||
|
/// Settings section for proximity alerts configuration
|
||||||
|
/// Follows brutalist principles: simple, explicit UI that matches existing patterns
|
||||||
|
class ProximityAlertsSection extends StatefulWidget {
|
||||||
|
const ProximityAlertsSection({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<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) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Proximity Alerts',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Enable/disable toggle
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Enable proximity alerts'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Get notified when approaching surveillance devices\n'
|
||||||
|
'Uses extra battery for continuous location monitoring\n'
|
||||||
|
'${_notificationsEnabled ? "✓ Notifications enabled" : "⚠ Notifications disabled"}',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
value: appState.proximityAlertsEnabled,
|
||||||
|
onChanged: (enabled) {
|
||||||
|
appState.setProximityAlertsEnabled(enabled);
|
||||||
|
if (enabled && !_notificationsEnabled) {
|
||||||
|
// Automatically try to request permissions when enabling
|
||||||
|
_requestNotificationPermissions();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Notification permissions section (only show when proximity alerts are enabled)
|
||||||
|
if (appState.proximityAlertsEnabled && !_notificationsEnabled && !_checkingPermissions) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.notifications_off, color: Colors.orange, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'Notification permission required',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Push notifications are disabled. You\'ll only see in-app alerts and won\'t be notified when the app is in background.',
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _requestNotificationPermissions,
|
||||||
|
icon: const Icon(Icons.settings, size: 16),
|
||||||
|
label: const Text('Enable Notifications'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
minimumSize: const Size(0, 32),
|
||||||
|
textStyle: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Loading indicator
|
||||||
|
if (_checkingPermissions) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Checking permissions...', style: TextStyle(fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Distance setting (only show when enabled)
|
||||||
|
if (appState.proximityAlertsEnabled) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Text('Alert distance: '),
|
||||||
|
SizedBox(
|
||||||
|
width: 80,
|
||||||
|
child: TextField(
|
||||||
|
controller: _distanceController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
],
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _updateDistance(appState),
|
||||||
|
onEditingComplete: () => _updateDistance(appState),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text('meters'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Range: $kProximityAlertMinDistance-$kProximityAlertMaxDistance meters (default: $kProximityAlertDefaultDistance)',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,19 +53,9 @@ class UploadModeSection extends StatelessWidget {
|
|||||||
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7))
|
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7))
|
||||||
);
|
);
|
||||||
case UploadMode.sandbox:
|
case UploadMode.sandbox:
|
||||||
return Column(
|
return Text(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
locService.t('uploadMode.sandboxDescription'),
|
||||||
children: [
|
style: const TextStyle(fontSize: 12, color: Colors.orange),
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
case UploadMode.simulate:
|
case UploadMode.simulate:
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -24,9 +24,44 @@ class LocalizationService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _discoverAvailableLanguages() async {
|
Future<void> _discoverAvailableLanguages() async {
|
||||||
// For now, we'll hardcode the languages we support
|
_availableLanguages = [];
|
||||||
// In the future, this could scan the assets directory
|
|
||||||
_availableLanguages = ['en', 'es', 'fr', 'de'];
|
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 {
|
Future<void> _loadSavedLanguage() async {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'package:flutter_map/flutter_map.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../models/node_profile.dart';
|
import '../models/node_profile.dart';
|
||||||
import '../models/osm_camera_node.dart';
|
import '../models/osm_node.dart';
|
||||||
import '../app_state.dart';
|
import '../app_state.dart';
|
||||||
import 'map_data_submodules/nodes_from_overpass.dart';
|
import 'map_data_submodules/nodes_from_overpass.dart';
|
||||||
import 'map_data_submodules/nodes_from_osm_api.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.
|
/// Fetch surveillance nodes from OSM/Overpass or local storage.
|
||||||
/// Remote is default. If source is MapSource.auto, remote is tried first unless offline.
|
/// 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 LatLngBounds bounds,
|
||||||
required List<NodeProfile> profiles,
|
required List<NodeProfile> profiles,
|
||||||
UploadMode uploadMode = UploadMode.production,
|
UploadMode uploadMode = UploadMode.production,
|
||||||
@@ -70,7 +70,7 @@ class MapDataProvider {
|
|||||||
if (uploadMode == UploadMode.sandbox) {
|
if (uploadMode == UploadMode.sandbox) {
|
||||||
// Offline + Sandbox = no nodes (local cache is production data)
|
// Offline + Sandbox = no nodes (local cache is production data)
|
||||||
debugPrint('[MapDataProvider] Offline + Sandbox mode: returning 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 {
|
} else {
|
||||||
// Offline + Production = use local cache
|
// Offline + Production = use local cache
|
||||||
return fetchLocalNodes(
|
return fetchLocalNodes(
|
||||||
@@ -90,7 +90,7 @@ class MapDataProvider {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Production mode: fetch both remote and local, then merge with deduplication
|
// 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)
|
// Always try to get local nodes (fast, cached)
|
||||||
futures.add(fetchLocalNodes(
|
futures.add(fetchLocalNodes(
|
||||||
@@ -107,7 +107,7 @@ class MapDataProvider {
|
|||||||
maxResults: AppState.instance.maxCameras,
|
maxResults: AppState.instance.maxCameras,
|
||||||
).catchError((e) {
|
).catchError((e) {
|
||||||
debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Continuing with local only.');
|
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
|
// Wait for both, then merge with deduplication by node ID
|
||||||
@@ -116,7 +116,7 @@ class MapDataProvider {
|
|||||||
final remoteNodes = results[1];
|
final remoteNodes = results[1];
|
||||||
|
|
||||||
// Merge with deduplication - prefer remote data over local for same node ID
|
// 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
|
// Add local nodes first
|
||||||
for (final node in localNodes) {
|
for (final node in localNodes) {
|
||||||
@@ -140,7 +140,7 @@ class MapDataProvider {
|
|||||||
|
|
||||||
/// Bulk/paged node fetch for offline downloads (handling paging, dedup, and Overpass retries)
|
/// 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.
|
/// 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 LatLngBounds bounds,
|
||||||
required List<NodeProfile> profiles,
|
required List<NodeProfile> profiles,
|
||||||
UploadMode uploadMode = UploadMode.production,
|
UploadMode uploadMode = UploadMode.production,
|
||||||
@@ -214,7 +214,7 @@ class MapDataProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch remote nodes with Overpass first, OSM API fallback
|
/// Fetch remote nodes with Overpass first, OSM API fallback
|
||||||
Future<List<OsmCameraNode>> _fetchRemoteNodes({
|
Future<List<OsmNode>> _fetchRemoteNodes({
|
||||||
required LatLngBounds bounds,
|
required LatLngBounds bounds,
|
||||||
required List<NodeProfile> profiles,
|
required List<NodeProfile> profiles,
|
||||||
UploadMode uploadMode = UploadMode.production,
|
UploadMode uploadMode = UploadMode.production,
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
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 '../../models/node_profile.dart';
|
||||||
import '../offline_area_service.dart';
|
import '../offline_area_service.dart';
|
||||||
import '../offline_areas/offline_area_models.dart';
|
import '../offline_areas/offline_area_models.dart';
|
||||||
|
|
||||||
/// Fetch surveillance nodes from all offline areas intersecting the bounds/profile list.
|
/// Fetch surveillance nodes from all offline areas intersecting the bounds/profile list.
|
||||||
Future<List<OsmCameraNode>> fetchLocalNodes({
|
Future<List<OsmNode>> fetchLocalNodes({
|
||||||
required LatLngBounds bounds,
|
required LatLngBounds bounds,
|
||||||
required List<NodeProfile> profiles,
|
required List<NodeProfile> profiles,
|
||||||
int? maxNodes,
|
int? maxNodes,
|
||||||
}) async {
|
}) async {
|
||||||
final areas = OfflineAreaService().offlineAreas;
|
final areas = OfflineAreaService().offlineAreas;
|
||||||
final Map<int, OsmCameraNode> deduped = {};
|
final Map<int, OsmNode> deduped = {};
|
||||||
|
|
||||||
for (final area in areas) {
|
for (final area in areas) {
|
||||||
if (area.status != OfflineAreaStatus.complete) continue;
|
if (area.status != OfflineAreaStatus.complete) continue;
|
||||||
@@ -38,7 +38,7 @@ Future<List<OsmCameraNode>> fetchLocalNodes({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try in-memory first, else load from disk
|
// 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) {
|
if (area.nodes.isNotEmpty) {
|
||||||
return area.nodes;
|
return area.nodes;
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@ Future<List<OsmCameraNode>> _loadAreaNodes(OfflineArea area) async {
|
|||||||
try {
|
try {
|
||||||
final str = await fileToLoad.readAsString();
|
final str = await fileToLoad.readAsString();
|
||||||
final jsonList = jsonDecode(str) as List;
|
final jsonList = jsonDecode(str) as List;
|
||||||
return jsonList.map((e) => OsmCameraNode.fromJson(e)).toList();
|
return jsonList.map((e) => OsmNode.fromJson(e)).toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[_loadAreaNodes] Error loading nodes from ${fileToLoad.path}: $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;
|
pt.longitude <= bounds.northEast.longitude;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _matchesAnyProfile(OsmCameraNode node, List<NodeProfile> profiles) {
|
bool _matchesAnyProfile(OsmNode node, List<NodeProfile> profiles) {
|
||||||
for (final prof in profiles) {
|
for (final prof in profiles) {
|
||||||
if (_nodeMatchesProfile(node, prof)) return true;
|
if (_nodeMatchesProfile(node, prof)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _nodeMatchesProfile(OsmCameraNode node, NodeProfile profile) {
|
bool _nodeMatchesProfile(OsmNode node, NodeProfile profile) {
|
||||||
for (final e in profile.tags.entries) {
|
for (final e in profile.tags.entries) {
|
||||||
if (node.tags[e.key] != e.value) return false; // All profile tags must match
|
if (node.tags[e.key] != e.value) return false; // All profile tags must match
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import 'package:flutter_map/flutter_map.dart';
|
|||||||
import 'package:xml/xml.dart';
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
import '../../models/node_profile.dart';
|
import '../../models/node_profile.dart';
|
||||||
import '../../models/osm_camera_node.dart';
|
import '../../models/osm_node.dart';
|
||||||
import '../../app_state.dart';
|
import '../../app_state.dart';
|
||||||
import '../network_status.dart';
|
import '../network_status.dart';
|
||||||
|
|
||||||
/// Fetches surveillance nodes from the direct OSM API using bbox query.
|
/// 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).
|
/// 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 LatLngBounds bounds,
|
||||||
required List<NodeProfile> profiles,
|
required List<NodeProfile> profiles,
|
||||||
UploadMode uploadMode = UploadMode.production,
|
UploadMode uploadMode = UploadMode.production,
|
||||||
@@ -47,7 +47,7 @@ Future<List<OsmCameraNode>> fetchOsmApiNodes({
|
|||||||
|
|
||||||
// Parse XML response
|
// Parse XML response
|
||||||
final document = XmlDocument.parse(response.body);
|
final document = XmlDocument.parse(response.body);
|
||||||
final nodes = <OsmCameraNode>[];
|
final nodes = <OsmNode>[];
|
||||||
|
|
||||||
// Find all node elements
|
// Find all node elements
|
||||||
for (final nodeElement in document.findAllElements('node')) {
|
for (final nodeElement in document.findAllElements('node')) {
|
||||||
@@ -73,7 +73,7 @@ Future<List<OsmCameraNode>> fetchOsmApiNodes({
|
|||||||
|
|
||||||
// Check if this node matches any of our profiles
|
// Check if this node matches any of our profiles
|
||||||
if (_nodeMatchesProfiles(tags, profiles)) {
|
if (_nodeMatchesProfiles(tags, profiles)) {
|
||||||
nodes.add(OsmCameraNode(
|
nodes.add(OsmNode(
|
||||||
id: id,
|
id: id,
|
||||||
coord: LatLng(lat, lon),
|
coord: LatLng(lat, lon),
|
||||||
tags: tags,
|
tags: tags,
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import 'package:latlong2/latlong.dart';
|
|||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
|
||||||
import '../../models/node_profile.dart';
|
import '../../models/node_profile.dart';
|
||||||
import '../../models/osm_camera_node.dart';
|
import '../../models/osm_node.dart';
|
||||||
import '../../models/pending_upload.dart';
|
import '../../models/pending_upload.dart';
|
||||||
import '../../app_state.dart';
|
import '../../app_state.dart';
|
||||||
import '../network_status.dart';
|
import '../network_status.dart';
|
||||||
|
|
||||||
/// Fetches surveillance nodes from the Overpass OSM API for the given bounds and profiles.
|
/// 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 LatLngBounds bounds,
|
||||||
required List<NodeProfile> profiles,
|
required List<NodeProfile> profiles,
|
||||||
UploadMode uploadMode = UploadMode.production,
|
UploadMode uploadMode = UploadMode.production,
|
||||||
@@ -49,7 +49,7 @@ Future<List<OsmCameraNode>> fetchOverpassNodes({
|
|||||||
NetworkStatus.instance.reportOverpassSuccess();
|
NetworkStatus.instance.reportOverpassSuccess();
|
||||||
|
|
||||||
final nodes = elements.whereType<Map<String, dynamic>>().map((element) {
|
final nodes = elements.whereType<Map<String, dynamic>>().map((element) {
|
||||||
return OsmCameraNode(
|
return OsmNode(
|
||||||
id: element['id'],
|
id: element['id'],
|
||||||
coord: LatLng(element['lat'], element['lon']),
|
coord: LatLng(element['lat'], element['lon']),
|
||||||
tags: Map<String, String>.from(element['tags'] ?? {}),
|
tags: Map<String, String>.from(element['tags'] ?? {}),
|
||||||
@@ -101,7 +101,7 @@ $outputClause
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Clean up pending uploads that now appear in Overpass results
|
/// Clean up pending uploads that now appear in Overpass results
|
||||||
void _cleanupCompletedUploads(List<OsmCameraNode> overpassNodes) {
|
void _cleanupCompletedUploads(List<OsmNode> overpassNodes) {
|
||||||
try {
|
try {
|
||||||
final appState = AppState.instance;
|
final appState = AppState.instance;
|
||||||
final pendingUploads = appState.pendingUploads;
|
final pendingUploads = appState.pendingUploads;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:latlong2/latlong.dart';
|
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;
|
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||||
|
|
||||||
class NodeCache {
|
class NodeCache {
|
||||||
@@ -8,10 +8,10 @@ class NodeCache {
|
|||||||
factory NodeCache() => instance;
|
factory NodeCache() => instance;
|
||||||
NodeCache._internal();
|
NodeCache._internal();
|
||||||
|
|
||||||
final Map<int, OsmCameraNode> _nodes = {};
|
final Map<int, OsmNode> _nodes = {};
|
||||||
|
|
||||||
/// Add or update a batch of nodes in the cache.
|
/// 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) {
|
for (var node in nodes) {
|
||||||
final existing = _nodes[node.id];
|
final existing = _nodes[node.id];
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
@@ -22,7 +22,7 @@ class NodeCache {
|
|||||||
mergedTags[entry.key] = entry.value;
|
mergedTags[entry.key] = entry.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_nodes[node.id] = OsmCameraNode(
|
_nodes[node.id] = OsmNode(
|
||||||
id: node.id,
|
id: node.id,
|
||||||
coord: node.coord,
|
coord: node.coord,
|
||||||
tags: mergedTags,
|
tags: mergedTags,
|
||||||
@@ -34,14 +34,14 @@ class NodeCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Query for all cached nodes currently within the given LatLngBounds.
|
/// Query for all cached nodes currently within the given LatLngBounds.
|
||||||
List<OsmCameraNode> queryByBounds(LatLngBounds bounds) {
|
List<OsmNode> queryByBounds(LatLngBounds bounds) {
|
||||||
return _nodes.values
|
return _nodes.values
|
||||||
.where((node) => _inBounds(node.coord, bounds))
|
.where((node) => _inBounds(node.coord, bounds))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve all cached nodes.
|
/// Retrieve all cached nodes.
|
||||||
List<OsmCameraNode> getAll() => _nodes.values.toList();
|
List<OsmNode> getAll() => _nodes.values.toList();
|
||||||
|
|
||||||
/// Optionally clear the cache (rarely needed)
|
/// Optionally clear the cache (rarely needed)
|
||||||
void clear() => _nodes.clear();
|
void clear() => _nodes.clear();
|
||||||
@@ -53,7 +53,7 @@ class NodeCache {
|
|||||||
final cleanTags = Map<String, String>.from(node.tags);
|
final cleanTags = Map<String, String>.from(node.tags);
|
||||||
cleanTags.remove('_pending_edit');
|
cleanTags.remove('_pending_edit');
|
||||||
|
|
||||||
_nodes[nodeId] = OsmCameraNode(
|
_nodes[nodeId] = OsmNode(
|
||||||
id: node.id,
|
id: node.id,
|
||||||
coord: node.coord,
|
coord: node.coord,
|
||||||
tags: cleanTags,
|
tags: cleanTags,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import 'offline_areas/offline_area_models.dart';
|
|||||||
import 'offline_areas/offline_tile_utils.dart';
|
import 'offline_areas/offline_tile_utils.dart';
|
||||||
import 'offline_areas/offline_area_downloader.dart';
|
import 'offline_areas/offline_area_downloader.dart';
|
||||||
|
|
||||||
import '../models/osm_camera_node.dart';
|
import '../models/osm_node.dart';
|
||||||
import '../app_state.dart';
|
import '../app_state.dart';
|
||||||
import 'map_data_provider.dart';
|
import 'map_data_provider.dart';
|
||||||
import 'package:deflockapp/dev_config.dart';
|
import 'package:deflockapp/dev_config.dart';
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import 'package:latlong2/latlong.dart';
|
|||||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||||
|
|
||||||
import '../../app_state.dart';
|
import '../../app_state.dart';
|
||||||
import '../../models/osm_camera_node.dart';
|
import '../../models/osm_node.dart';
|
||||||
import '../map_data_provider.dart';
|
import '../map_data_provider.dart';
|
||||||
import 'offline_area_models.dart';
|
import 'offline_area_models.dart';
|
||||||
import 'offline_tile_utils.dart';
|
import 'offline_tile_utils.dart';
|
||||||
@@ -182,7 +182,7 @@ class OfflineAreaDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Save nodes to disk as JSON
|
/// 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');
|
final file = File('$dir/nodes.json');
|
||||||
await file.writeAsString(jsonEncode(nodes.map((n) => n.toJson()).toList()));
|
await file.writeAsString(jsonEncode(nodes.map((n) => n.toJson()).toList()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
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
|
/// Status of an offline area
|
||||||
enum OfflineAreaStatus { downloading, complete, error, cancelled }
|
enum OfflineAreaStatus { downloading, complete, error, cancelled }
|
||||||
@@ -17,7 +17,7 @@ class OfflineArea {
|
|||||||
double progress; // 0.0 - 1.0
|
double progress; // 0.0 - 1.0
|
||||||
int tilesDownloaded;
|
int tilesDownloaded;
|
||||||
int tilesTotal;
|
int tilesTotal;
|
||||||
List<OsmCameraNode> nodes;
|
List<OsmNode> nodes;
|
||||||
int sizeBytes; // Disk size in bytes
|
int sizeBytes; // Disk size in bytes
|
||||||
final bool isPermanent; // Not user-deletable if true
|
final bool isPermanent; // Not user-deletable if true
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ class OfflineArea {
|
|||||||
tilesDownloaded: json['tilesDownloaded'] ?? 0,
|
tilesDownloaded: json['tilesDownloaded'] ?? 0,
|
||||||
tilesTotal: json['tilesTotal'] ?? 0,
|
tilesTotal: json['tilesTotal'] ?? 0,
|
||||||
nodes: (json['nodes'] as List? ?? json['cameras'] as List? ?? [])
|
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,
|
sizeBytes: json['sizeBytes'] ?? 0,
|
||||||
isPermanent: json['isPermanent'] ?? false,
|
isPermanent: json['isPermanent'] ?? false,
|
||||||
tileProviderId: json['tileProviderId'],
|
tileProviderId: json['tileProviderId'],
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import 'package:latlong2/latlong.dart';
|
|||||||
|
|
||||||
import '../models/node_profile.dart';
|
import '../models/node_profile.dart';
|
||||||
import '../models/operator_profile.dart';
|
import '../models/operator_profile.dart';
|
||||||
import '../models/osm_camera_node.dart';
|
import '../models/osm_node.dart';
|
||||||
|
|
||||||
// ------------------ AddNodeSession ------------------
|
// ------------------ AddNodeSession ------------------
|
||||||
class AddNodeSession {
|
class AddNodeSession {
|
||||||
@@ -23,7 +23,7 @@ class EditNodeSession {
|
|||||||
required this.target,
|
required this.target,
|
||||||
});
|
});
|
||||||
|
|
||||||
final OsmCameraNode originalNode; // The original node being edited
|
final OsmNode originalNode; // The original node being edited
|
||||||
NodeProfile profile;
|
NodeProfile profile;
|
||||||
OperatorProfile? operatorProfile;
|
OperatorProfile? operatorProfile;
|
||||||
double directionDegrees;
|
double directionDegrees;
|
||||||
@@ -48,7 +48,7 @@ class SessionState extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void startEditSession(OsmCameraNode node, List<NodeProfile> enabledProfiles) {
|
void startEditSession(OsmNode node, List<NodeProfile> enabledProfiles) {
|
||||||
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
|
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||||
|
|
||||||
// Try to find a matching profile based on the node's tags
|
// Try to find a matching profile based on the node's tags
|
||||||
|
|||||||
@@ -24,11 +24,15 @@ class SettingsState extends ChangeNotifier {
|
|||||||
static const String _selectedTileTypePrefsKey = 'selected_tile_type';
|
static const String _selectedTileTypePrefsKey = 'selected_tile_type';
|
||||||
static const String _legacyTestModePrefsKey = 'test_mode';
|
static const String _legacyTestModePrefsKey = 'test_mode';
|
||||||
static const String _followMeModePrefsKey = 'follow_me_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;
|
bool _offlineMode = false;
|
||||||
int _maxCameras = 250;
|
int _maxCameras = 250;
|
||||||
UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production;
|
UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production;
|
||||||
FollowMeMode _followMeMode = FollowMeMode.northUp;
|
FollowMeMode _followMeMode = FollowMeMode.northUp;
|
||||||
|
bool _proximityAlertsEnabled = false;
|
||||||
|
int _proximityAlertDistance = kProximityAlertDefaultDistance;
|
||||||
List<TileProvider> _tileProviders = [];
|
List<TileProvider> _tileProviders = [];
|
||||||
String _selectedTileTypeId = '';
|
String _selectedTileTypeId = '';
|
||||||
|
|
||||||
@@ -37,6 +41,8 @@ class SettingsState extends ChangeNotifier {
|
|||||||
int get maxCameras => _maxCameras;
|
int get maxCameras => _maxCameras;
|
||||||
UploadMode get uploadMode => _uploadMode;
|
UploadMode get uploadMode => _uploadMode;
|
||||||
FollowMeMode get followMeMode => _followMeMode;
|
FollowMeMode get followMeMode => _followMeMode;
|
||||||
|
bool get proximityAlertsEnabled => _proximityAlertsEnabled;
|
||||||
|
int get proximityAlertDistance => _proximityAlertDistance;
|
||||||
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
|
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
|
||||||
String get selectedTileTypeId => _selectedTileTypeId;
|
String get selectedTileTypeId => _selectedTileTypeId;
|
||||||
|
|
||||||
@@ -85,6 +91,10 @@ class SettingsState extends ChangeNotifier {
|
|||||||
_maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250;
|
_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)
|
// Load upload mode (including migration from old test_mode bool)
|
||||||
if (prefs.containsKey(_uploadModePrefsKey)) {
|
if (prefs.containsKey(_uploadModePrefsKey)) {
|
||||||
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../models/pending_upload.dart';
|
import '../models/pending_upload.dart';
|
||||||
import '../models/osm_camera_node.dart';
|
import '../models/osm_node.dart';
|
||||||
import '../models/node_profile.dart';
|
import '../models/node_profile.dart';
|
||||||
import '../services/node_cache.dart';
|
import '../services/node_cache.dart';
|
||||||
import '../services/uploader.dart';
|
import '../services/uploader.dart';
|
||||||
@@ -46,7 +46,7 @@ class UploadQueueState extends ChangeNotifier {
|
|||||||
final tags = upload.getCombinedTags();
|
final tags = upload.getCombinedTags();
|
||||||
tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction
|
tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction
|
||||||
|
|
||||||
final tempNode = OsmCameraNode(
|
final tempNode = OsmNode(
|
||||||
id: tempId,
|
id: tempId,
|
||||||
coord: upload.coord,
|
coord: upload.coord,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
@@ -80,7 +80,7 @@ class UploadQueueState extends ChangeNotifier {
|
|||||||
final originalTags = Map<String, String>.from(session.originalNode.tags);
|
final originalTags = Map<String, String>.from(session.originalNode.tags);
|
||||||
originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit
|
originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit
|
||||||
|
|
||||||
final originalNode = OsmCameraNode(
|
final originalNode = OsmNode(
|
||||||
id: session.originalNode.id,
|
id: session.originalNode.id,
|
||||||
coord: session.originalNode.coord, // Keep at original location
|
coord: session.originalNode.coord, // Keep at original location
|
||||||
tags: originalTags,
|
tags: originalTags,
|
||||||
@@ -92,7 +92,7 @@ class UploadQueueState extends ChangeNotifier {
|
|||||||
editedTags['_pending_upload'] = 'true'; // Mark as pending upload
|
editedTags['_pending_upload'] = 'true'; // Mark as pending upload
|
||||||
editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing
|
editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing
|
||||||
|
|
||||||
final editedNode = OsmCameraNode(
|
final editedNode = OsmNode(
|
||||||
id: tempId,
|
id: tempId,
|
||||||
coord: upload.coord, // At new location
|
coord: upload.coord, // At new location
|
||||||
tags: editedTags,
|
tags: editedTags,
|
||||||
@@ -106,7 +106,7 @@ class UploadQueueState extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add a node deletion to the upload queue
|
// 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(
|
final upload = PendingUpload(
|
||||||
coord: node.coord,
|
coord: node.coord,
|
||||||
direction: node.directionDeg ?? 0, // Use existing direction or default to 0
|
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);
|
final deletionTags = Map<String, String>.from(node.tags);
|
||||||
deletionTags['_pending_deletion'] = 'true';
|
deletionTags['_pending_deletion'] = 'true';
|
||||||
|
|
||||||
final nodeWithDeletionTag = OsmCameraNode(
|
final nodeWithDeletionTag = OsmNode(
|
||||||
id: node.id,
|
id: node.id,
|
||||||
coord: node.coord,
|
coord: node.coord,
|
||||||
tags: deletionTags,
|
tags: deletionTags,
|
||||||
@@ -259,7 +259,7 @@ class UploadQueueState extends ChangeNotifier {
|
|||||||
// Create the node with real ID and clean tags (remove temp markers)
|
// Create the node with real ID and clean tags (remove temp markers)
|
||||||
final tags = item.getCombinedTags();
|
final tags = item.getCombinedTags();
|
||||||
|
|
||||||
final realNode = OsmCameraNode(
|
final realNode = OsmNode(
|
||||||
id: realNodeId,
|
id: realNodeId,
|
||||||
coord: item.coord,
|
coord: item.coord,
|
||||||
tags: tags, // Clean tags without _pending_upload markers
|
tags: tags, // Clean tags without _pending_upload markers
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import '../services/map_data_provider.dart';
|
|||||||
import '../services/node_cache.dart';
|
import '../services/node_cache.dart';
|
||||||
import '../services/network_status.dart';
|
import '../services/network_status.dart';
|
||||||
import '../models/node_profile.dart';
|
import '../models/node_profile.dart';
|
||||||
import '../models/osm_camera_node.dart';
|
import '../models/osm_node.dart';
|
||||||
import '../app_state.dart';
|
import '../app_state.dart';
|
||||||
|
|
||||||
/// Provides surveillance nodes for a map view, using an in-memory cache and optionally
|
/// 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.
|
/// Call this to get (quickly) all cached overlays for the given view.
|
||||||
/// Filters by currently enabled profiles.
|
/// Filters by currently enabled profiles.
|
||||||
List<OsmCameraNode> getCachedNodesForBounds(LatLngBounds bounds) {
|
List<OsmNode> getCachedNodesForBounds(LatLngBounds bounds) {
|
||||||
final allNodes = NodeCache.instance.queryByBounds(bounds);
|
final allNodes = NodeCache.instance.queryByBounds(bounds);
|
||||||
final enabledProfiles = AppState.instance.enabledProfiles;
|
final enabledProfiles = AppState.instance.enabledProfiles;
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ class CameraProviderWithCache extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a node matches any of the provided profiles
|
/// 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) {
|
for (final profile in profiles) {
|
||||||
if (_nodeMatchesProfile(node, profile)) return true;
|
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)
|
/// 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) {
|
for (final entry in profile.tags.entries) {
|
||||||
if (node.tags[entry.key] != entry.value) return false;
|
if (node.tags[entry.key] != entry.value) return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import 'package:flutter_map/flutter_map.dart';
|
|||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
import '../../dev_config.dart';
|
import '../../dev_config.dart';
|
||||||
import '../../models/osm_camera_node.dart';
|
import '../../models/osm_node.dart';
|
||||||
import '../node_tag_sheet.dart';
|
import '../node_tag_sheet.dart';
|
||||||
import '../camera_icon.dart';
|
import '../camera_icon.dart';
|
||||||
|
|
||||||
/// Smart marker widget for camera with single/double tap distinction
|
/// Smart marker widget for camera with single/double tap distinction
|
||||||
class CameraMapMarker extends StatefulWidget {
|
class CameraMapMarker extends StatefulWidget {
|
||||||
final OsmCameraNode node;
|
final OsmNode node;
|
||||||
final MapController mapController;
|
final MapController mapController;
|
||||||
const CameraMapMarker({required this.node, required this.mapController, Key? key}) : super(key: key);
|
const CameraMapMarker({required this.node, required this.mapController, Key? key}) : super(key: key);
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
|
|||||||
/// Helper class to build marker layers for cameras and user location
|
/// Helper class to build marker layers for cameras and user location
|
||||||
class CameraMarkersBuilder {
|
class CameraMarkersBuilder {
|
||||||
static List<Marker> buildCameraMarkers({
|
static List<Marker> buildCameraMarkers({
|
||||||
required List<OsmCameraNode> cameras,
|
required List<OsmNode> cameras,
|
||||||
required MapController mapController,
|
required MapController mapController,
|
||||||
LatLng? userLocation,
|
LatLng? userLocation,
|
||||||
}) {
|
}) {
|
||||||
@@ -104,7 +104,7 @@ class CameraMarkersBuilder {
|
|||||||
return markers;
|
return markers;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool _isValidCameraCoordinate(OsmCameraNode node) {
|
static bool _isValidCameraCoordinate(OsmNode node) {
|
||||||
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||||
node.coord.latitude.abs() <= 90 &&
|
node.coord.latitude.abs() <= 90 &&
|
||||||
node.coord.longitude.abs() <= 180;
|
node.coord.longitude.abs() <= 180;
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import 'package:latlong2/latlong.dart';
|
|||||||
|
|
||||||
import '../../app_state.dart';
|
import '../../app_state.dart';
|
||||||
import '../../dev_config.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
|
/// Helper class to build direction cone polygons for cameras
|
||||||
class DirectionConesBuilder {
|
class DirectionConesBuilder {
|
||||||
static List<Polygon> buildDirectionCones({
|
static List<Polygon> buildDirectionCones({
|
||||||
required List<OsmCameraNode> cameras,
|
required List<OsmNode> cameras,
|
||||||
required double zoom,
|
required double zoom,
|
||||||
AddNodeSession? session,
|
AddNodeSession? session,
|
||||||
EditNodeSession? editSession,
|
EditNodeSession? editSession,
|
||||||
@@ -52,7 +52,7 @@ class DirectionConesBuilder {
|
|||||||
return overlays;
|
return overlays;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool _isValidCameraWithDirection(OsmCameraNode node) {
|
static bool _isValidCameraWithDirection(OsmNode node) {
|
||||||
return node.hasDirection &&
|
return node.hasDirection &&
|
||||||
node.directionDeg != null &&
|
node.directionDeg != null &&
|
||||||
(node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
(node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||||
@@ -60,7 +60,7 @@ class DirectionConesBuilder {
|
|||||||
node.coord.longitude.abs() <= 180;
|
node.coord.longitude.abs() <= 180;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool _isPendingUpload(OsmCameraNode node) {
|
static bool _isPendingUpload(OsmNode node) {
|
||||||
return node.tags.containsKey('_pending_upload') &&
|
return node.tags.containsKey('_pending_upload') &&
|
||||||
node.tags['_pending_upload'] == 'true';
|
node.tags['_pending_upload'] == 'true';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import 'package:latlong2/latlong.dart';
|
|||||||
|
|
||||||
import '../../dev_config.dart';
|
import '../../dev_config.dart';
|
||||||
import '../../app_state.dart' show FollowMeMode;
|
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.
|
/// Manages GPS location tracking, follow-me modes, and location-based map animations.
|
||||||
/// Handles GPS permissions, position streams, and follow-me behavior.
|
/// Handles GPS permissions, position streams, and follow-me behavior.
|
||||||
@@ -81,6 +84,11 @@ class GpsController {
|
|||||||
required FollowMeMode followMeMode,
|
required FollowMeMode followMeMode,
|
||||||
required AnimatedMapController controller,
|
required AnimatedMapController controller,
|
||||||
required VoidCallback onLocationUpdated,
|
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);
|
final latLng = LatLng(position.latitude, position.longitude);
|
||||||
_currentLatLng = latLng;
|
_currentLatLng = latLng;
|
||||||
@@ -88,6 +96,16 @@ class GpsController {
|
|||||||
// Notify that location was updated (for setState, etc.)
|
// Notify that location was updated (for setState, etc.)
|
||||||
onLocationUpdated();
|
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
|
// Handle follow-me animations if enabled - use current mode from app state
|
||||||
if (followMeMode != FollowMeMode.off) {
|
if (followMeMode != FollowMeMode.off) {
|
||||||
debugPrint('[GpsController] GPS position update: ${latLng.latitude}, ${latLng.longitude}, follow-me: $followMeMode');
|
debugPrint('[GpsController] GPS position update: ${latLng.latitude}, ${latLng.longitude}, follow-me: $followMeMode');
|
||||||
@@ -131,6 +149,10 @@ class GpsController {
|
|||||||
required AnimatedMapController controller,
|
required AnimatedMapController controller,
|
||||||
required VoidCallback onLocationUpdated,
|
required VoidCallback onLocationUpdated,
|
||||||
required FollowMeMode Function() getCurrentFollowMeMode,
|
required FollowMeMode Function() getCurrentFollowMeMode,
|
||||||
|
required bool Function() getProximityAlertsEnabled,
|
||||||
|
required int Function() getProximityAlertDistance,
|
||||||
|
required List<OsmNode> Function() getNearbyNodes,
|
||||||
|
required List<NodeProfile> Function() getEnabledProfiles,
|
||||||
}) async {
|
}) async {
|
||||||
final perm = await Geolocator.requestPermission();
|
final perm = await Geolocator.requestPermission();
|
||||||
if (perm == LocationPermission.denied ||
|
if (perm == LocationPermission.denied ||
|
||||||
@@ -142,11 +164,20 @@ class GpsController {
|
|||||||
_positionSub = Geolocator.getPositionStream().listen((Position position) {
|
_positionSub = Geolocator.getPositionStream().listen((Position position) {
|
||||||
// Get the current follow-me mode from the app state each time
|
// Get the current follow-me mode from the app state each time
|
||||||
final currentFollowMeMode = getCurrentFollowMeMode();
|
final currentFollowMeMode = getCurrentFollowMeMode();
|
||||||
|
final proximityAlertsEnabled = getProximityAlertsEnabled();
|
||||||
|
final proximityAlertDistance = getProximityAlertDistance();
|
||||||
|
final nearbyNodes = getNearbyNodes();
|
||||||
|
final enabledProfiles = getEnabledProfiles();
|
||||||
|
|
||||||
processPositionUpdate(
|
processPositionUpdate(
|
||||||
position: position,
|
position: position,
|
||||||
followMeMode: currentFollowMeMode,
|
followMeMode: currentFollowMeMode,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
onLocationUpdated: onLocationUpdated,
|
onLocationUpdated: onLocationUpdated,
|
||||||
|
proximityAlertsEnabled: proximityAlertsEnabled,
|
||||||
|
proximityAlertDistance: proximityAlertDistance,
|
||||||
|
nearbyNodes: nearbyNodes,
|
||||||
|
enabledProfiles: enabledProfiles,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../../app_state.dart';
|
import '../../app_state.dart';
|
||||||
import '../../models/tile_provider.dart';
|
import '../../models/tile_provider.dart';
|
||||||
import '../../services/offline_area_service.dart';
|
import '../../services/offline_area_service.dart';
|
||||||
|
import '../../services/localization_service.dart';
|
||||||
|
|
||||||
class LayerSelectorButton extends StatelessWidget {
|
class LayerSelectorButton extends StatelessWidget {
|
||||||
const LayerSelectorButton({super.key});
|
const LayerSelectorButton({super.key});
|
||||||
@@ -22,9 +23,9 @@ class LayerSelectorButton extends StatelessWidget {
|
|||||||
final offlineService = OfflineAreaService();
|
final offlineService = OfflineAreaService();
|
||||||
if (offlineService.hasActiveDownloads) {
|
if (offlineService.hasActiveDownloads) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Cannot change tile types while downloading offline areas'),
|
content: Text(LocalizationService.instance.t('layerSelector.cannotChangeTileTypes')),
|
||||||
duration: Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -58,6 +59,7 @@ class _LayerSelectorDialogState extends State<_LayerSelectorDialog> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final appState = context.watch<AppState>();
|
final appState = context.watch<AppState>();
|
||||||
final providers = appState.tileProviders;
|
final providers = appState.tileProviders;
|
||||||
|
final locService = LocalizationService.instance;
|
||||||
|
|
||||||
// Group tile types by provider for display
|
// Group tile types by provider for display
|
||||||
final providerGroups = <TileProvider, List<TileType>>{};
|
final providerGroups = <TileProvider, List<TileType>>{};
|
||||||
@@ -86,9 +88,9 @@ class _LayerSelectorDialogState extends State<_LayerSelectorDialog> {
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Icons.layers),
|
const Icon(Icons.layers),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Text(
|
Text(
|
||||||
'Select Map Layer',
|
locService.t('layerSelector.selectMapLayer'),
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -104,10 +106,10 @@ class _LayerSelectorDialogState extends State<_LayerSelectorDialog> {
|
|||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
children: [
|
children: [
|
||||||
if (providerGroups.isEmpty)
|
if (providerGroups.isEmpty)
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text('No tile providers available'),
|
child: Text(locService.t('layerSelector.noTileProvidersAvailable')),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../app_state.dart';
|
import '../app_state.dart';
|
||||||
import '../services/offline_area_service.dart';
|
import '../services/offline_area_service.dart';
|
||||||
import '../services/network_status.dart';
|
import '../services/network_status.dart';
|
||||||
import '../models/osm_camera_node.dart';
|
import '../models/osm_node.dart';
|
||||||
import '../models/node_profile.dart';
|
import '../models/node_profile.dart';
|
||||||
import '../models/tile_provider.dart';
|
import '../models/tile_provider.dart';
|
||||||
import 'debouncer.dart';
|
import 'debouncer.dart';
|
||||||
@@ -21,8 +21,10 @@ import 'map/tile_layer_manager.dart';
|
|||||||
import 'map/camera_refresh_controller.dart';
|
import 'map/camera_refresh_controller.dart';
|
||||||
import 'map/gps_controller.dart';
|
import 'map/gps_controller.dart';
|
||||||
import 'network_status_indicator.dart';
|
import 'network_status_indicator.dart';
|
||||||
|
import 'proximity_alert_banner.dart';
|
||||||
import '../dev_config.dart';
|
import '../dev_config.dart';
|
||||||
import '../app_state.dart' show FollowMeMode;
|
import '../app_state.dart' show FollowMeMode;
|
||||||
|
import '../services/proximity_alert_service.dart';
|
||||||
|
|
||||||
class MapView extends StatefulWidget {
|
class MapView extends StatefulWidget {
|
||||||
final AnimatedMapController controller;
|
final AnimatedMapController controller;
|
||||||
@@ -55,6 +57,9 @@ class MapViewState extends State<MapView> {
|
|||||||
|
|
||||||
// Track zoom to clear queue on zoom changes
|
// Track zoom to clear queue on zoom changes
|
||||||
double? _lastZoom;
|
double? _lastZoom;
|
||||||
|
|
||||||
|
// State for proximity alert banner
|
||||||
|
bool _showProximityBanner = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -68,6 +73,17 @@ class MapViewState extends State<MapView> {
|
|||||||
_cameraController.initialize(onCamerasUpdated: _onCamerasUpdated);
|
_cameraController.initialize(onCamerasUpdated: _onCamerasUpdated);
|
||||||
_gpsController = GpsController();
|
_gpsController = GpsController();
|
||||||
|
|
||||||
|
// Initialize proximity alert service
|
||||||
|
ProximityAlertService().initialize(
|
||||||
|
onVisualAlert: () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_showProximityBanner = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Load last map position before initializing GPS
|
// Load last map position before initializing GPS
|
||||||
_positionManager.loadLastMapPosition().then((_) {
|
_positionManager.loadLastMapPosition().then((_) {
|
||||||
// Move to last known position after loading and widget is built
|
// Move to last known position after loading and widget is built
|
||||||
@@ -93,6 +109,59 @@ class MapViewState extends State<MapView> {
|
|||||||
}
|
}
|
||||||
return FollowMeMode.off;
|
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
|
// Fetch initial cameras
|
||||||
@@ -265,7 +334,7 @@ class MapViewState extends State<MapView> {
|
|||||||
}
|
}
|
||||||
final cameras = (mapBounds != null)
|
final cameras = (mapBounds != null)
|
||||||
? cameraProvider.getCachedNodesForBounds(mapBounds)
|
? cameraProvider.getCachedNodesForBounds(mapBounds)
|
||||||
: <OsmCameraNode>[];
|
: <OsmNode>[];
|
||||||
|
|
||||||
final markers = CameraMarkersBuilder.buildCameraMarkers(
|
final markers = CameraMarkersBuilder.buildCameraMarkers(
|
||||||
cameras: cameras,
|
cameras: cameras,
|
||||||
@@ -402,12 +471,22 @@ class MapViewState extends State<MapView> {
|
|||||||
|
|
||||||
// Network status indicator (top-left)
|
// Network status indicator (top-left)
|
||||||
const NetworkStatusIndicator(),
|
const NetworkStatusIndicator(),
|
||||||
|
|
||||||
|
// Proximity alert banner (top)
|
||||||
|
ProximityAlertBanner(
|
||||||
|
isVisible: _showProximityBanner,
|
||||||
|
onDismiss: () {
|
||||||
|
setState(() {
|
||||||
|
_showProximityBanner = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build polylines connecting original cameras to their edited positions
|
/// Build polylines connecting original cameras to their edited positions
|
||||||
List<Polyline> _buildEditLines(List<OsmCameraNode> cameras) {
|
List<Polyline> _buildEditLines(List<OsmNode> cameras) {
|
||||||
final lines = <Polyline>[];
|
final lines = <Polyline>[];
|
||||||
|
|
||||||
// Create a lookup map of original node IDs to their coordinates
|
// Create a lookup map of original node IDs to their coordinates
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/osm_camera_node.dart';
|
import '../models/osm_node.dart';
|
||||||
import '../app_state.dart';
|
import '../app_state.dart';
|
||||||
import '../services/localization_service.dart';
|
import '../services/localization_service.dart';
|
||||||
|
|
||||||
class NodeTagSheet extends StatelessWidget {
|
class NodeTagSheet extends StatelessWidget {
|
||||||
final OsmCameraNode node;
|
final OsmNode node;
|
||||||
|
|
||||||
const NodeTagSheet({super.key, required this.node});
|
const NodeTagSheet({super.key, required this.node});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
|||||||
|
|
||||||
import '../app_state.dart';
|
import '../app_state.dart';
|
||||||
import '../models/operator_profile.dart';
|
import '../models/operator_profile.dart';
|
||||||
|
import '../services/localization_service.dart';
|
||||||
|
|
||||||
class RefineTagsSheet extends StatefulWidget {
|
class RefineTagsSheet extends StatefulWidget {
|
||||||
const RefineTagsSheet({
|
const RefineTagsSheet({
|
||||||
@@ -29,10 +30,11 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final appState = context.watch<AppState>();
|
final appState = context.watch<AppState>();
|
||||||
final operatorProfiles = appState.operatorProfiles;
|
final operatorProfiles = appState.operatorProfiles;
|
||||||
|
final locService = LocalizationService.instance;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Refine Tags'),
|
title: Text(locService.t('refineTagsSheet.title')),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
onPressed: () => Navigator.pop(context, widget.selectedOperatorProfile),
|
onPressed: () => Navigator.pop(context, widget.selectedOperatorProfile),
|
||||||
@@ -40,34 +42,34 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
|
|||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, _selectedOperatorProfile),
|
onPressed: () => Navigator.pop(context, _selectedOperatorProfile),
|
||||||
child: const Text('Done'),
|
child: Text(locService.t('refineTagsSheet.done')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Operator Profile',
|
locService.t('refineTagsSheet.operatorProfile'),
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (operatorProfiles.isEmpty)
|
if (operatorProfiles.isEmpty)
|
||||||
const Card(
|
Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.info_outline, color: Colors.grey, size: 48),
|
const Icon(Icons.info_outline, color: Colors.grey, size: 48),
|
||||||
SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'No operator profiles defined',
|
locService.t('refineTagsSheet.noOperatorProfiles'),
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Create operator profiles in Settings to apply additional tags to your node submissions.',
|
locService.t('refineTagsSheet.noOperatorProfilesMessage'),
|
||||||
style: TextStyle(color: Colors.grey),
|
style: const TextStyle(color: Colors.grey),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -79,15 +81,15 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
RadioListTile<OperatorProfile?>(
|
RadioListTile<OperatorProfile?>(
|
||||||
title: const Text('None'),
|
title: Text(locService.t('refineTagsSheet.none')),
|
||||||
subtitle: const Text('No additional operator tags'),
|
subtitle: Text(locService.t('refineTagsSheet.noAdditionalOperatorTags')),
|
||||||
value: null,
|
value: null,
|
||||||
groupValue: _selectedOperatorProfile,
|
groupValue: _selectedOperatorProfile,
|
||||||
onChanged: (value) => setState(() => _selectedOperatorProfile = value),
|
onChanged: (value) => setState(() => _selectedOperatorProfile = value),
|
||||||
),
|
),
|
||||||
...operatorProfiles.map((profile) => RadioListTile<OperatorProfile?>(
|
...operatorProfiles.map((profile) => RadioListTile<OperatorProfile?>(
|
||||||
title: Text(profile.name),
|
title: Text(profile.name),
|
||||||
subtitle: Text('${profile.tags.length} additional tags'),
|
subtitle: Text('${profile.tags.length} ${locService.t('refineTagsSheet.additionalTags')}'),
|
||||||
value: profile,
|
value: profile,
|
||||||
groupValue: _selectedOperatorProfile,
|
groupValue: _selectedOperatorProfile,
|
||||||
onChanged: (value) => setState(() => _selectedOperatorProfile = value),
|
onChanged: (value) => setState(() => _selectedOperatorProfile = value),
|
||||||
@@ -97,9 +99,9 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (_selectedOperatorProfile != null) ...[
|
if (_selectedOperatorProfile != null) ...[
|
||||||
const Text(
|
Text(
|
||||||
'Additional Tags',
|
locService.t('refineTagsSheet.additionalTagsTitle'),
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Card(
|
Card(
|
||||||
@@ -114,9 +116,9 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (_selectedOperatorProfile!.tags.isEmpty)
|
if (_selectedOperatorProfile!.tags.isEmpty)
|
||||||
const Text(
|
Text(
|
||||||
'No tags defined for this operator profile.',
|
locService.t('refineTagsSheet.noTagsDefinedForProfile'),
|
||||||
style: TextStyle(color: Colors.grey),
|
style: const TextStyle(color: Colors.grey),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
...(_selectedOperatorProfile!.tags.entries.map((entry) =>
|
...(_selectedOperatorProfile!.tags.entries.map((entry) =>
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
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:
|
desktop_webview_window:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -150,6 +158,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.14.4"
|
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:
|
flutter_map:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -624,6 +656,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.2"
|
version: "1.2.2"
|
||||||
|
timezone:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.4"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ dependencies:
|
|||||||
http: ^1.2.1
|
http: ^1.2.1
|
||||||
flutter_svg: ^2.0.10
|
flutter_svg: ^2.0.10
|
||||||
xml: ^6.4.2
|
xml: ^6.4.2
|
||||||
|
flutter_local_notifications: ^17.2.2
|
||||||
|
|
||||||
# Auth, storage, prefs
|
# Auth, storage, prefs
|
||||||
oauth2_client: ^4.2.0
|
oauth2_client: ^4.2.0
|
||||||
|
|||||||
Reference in New Issue
Block a user