Compare commits
49 Commits
v0.9.8-bet
...
v1.0.0-rel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec63aed459 | ||
|
|
b440629ad6 | ||
|
|
322b9fae62 | ||
|
|
792f94065d | ||
|
|
1aeae18ebc | ||
|
|
96b82ef416 | ||
|
|
adec4b175f | ||
|
|
583499ccd1 | ||
|
|
7ff9273f47 | ||
|
|
03654f354e | ||
|
|
9e97b69b85 | ||
|
|
fe554230b6 | ||
|
|
41ee9cab10 | ||
|
|
ab567b1da4 | ||
|
|
e79790c30d | ||
|
|
d397610121 | ||
|
|
3a985d2f8f | ||
|
|
b3a87fc56a | ||
|
|
82501a3131 | ||
|
|
e71adab87e | ||
|
|
2d29c93145 | ||
|
|
caa20140b4 | ||
|
|
2b26bf9188 | ||
|
|
1140e6300a | ||
|
|
7a1b1befb4 | ||
|
|
71fa212d71 | ||
|
|
6b5f05d036 | ||
|
|
87256e2c74 | ||
|
|
4a7a99502c | ||
|
|
5c80fdc169 | ||
|
|
5c525900f1 | ||
|
|
28828fbac0 | ||
|
|
9bf46721f0 | ||
|
|
363439f712 | ||
|
|
38f15a1f8b | ||
|
|
a05abd8bd8 | ||
|
|
c8a8d4c81f | ||
|
|
63e8934490 | ||
|
|
4053c9b39b | ||
|
|
4ad33d17e0 | ||
|
|
c9f1ecf7d0 | ||
|
|
7c49b38230 | ||
|
|
25f0e358a3 | ||
|
|
0cbcec7017 | ||
|
|
68289135bd | ||
|
|
23b7586e25 | ||
|
|
a2b842fb67 | ||
|
|
175bc8831a | ||
|
|
99ce659064 |
425
DEVELOPER.md
Normal file
@@ -0,0 +1,425 @@
|
||||
# Developer Documentation
|
||||
|
||||
This document provides detailed technical information about the DeFlock app architecture, key design decisions, and development guidelines.
|
||||
|
||||
---
|
||||
|
||||
## Philosophy: Brutalist Code
|
||||
|
||||
Our development approach prioritizes **simplicity over cleverness**:
|
||||
|
||||
- **Explicit over implicit**: Clear, readable code that states its intent
|
||||
- **Few edge cases by design**: Avoid complex branching and special cases
|
||||
- **Maintainable over efficient**: Choose the approach that's easier to understand and modify
|
||||
- **Delete before adding**: Remove complexity when possible rather than adding features
|
||||
|
||||
**Hierarchy of preferred code:**
|
||||
1. **Code we don't write** (through thoughtful design and removing edge cases)
|
||||
2. **Code we can remove** (by seeing problems from a new angle)
|
||||
3. **Code that sadly must exist** (simple, explicit, maintainable)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### State Management
|
||||
|
||||
The app uses **Provider pattern** with modular state classes:
|
||||
|
||||
```
|
||||
AppState (main coordinator)
|
||||
├── AuthState (OAuth2 login/logout)
|
||||
├── OperatorProfileState (operator tag sets)
|
||||
├── ProfileState (node profiles & toggles)
|
||||
├── SessionState (add/edit sessions)
|
||||
├── SettingsState (preferences & tile providers)
|
||||
└── UploadQueueState (pending operations)
|
||||
```
|
||||
|
||||
**Why this approach:**
|
||||
- **Separation of concerns**: Each state handles one domain
|
||||
- **Testability**: Individual state classes can be unit tested
|
||||
- **Brutalist**: No complex state orchestration, just simple delegation
|
||||
|
||||
### Data Flow Architecture
|
||||
|
||||
```
|
||||
UI Layer (Widgets)
|
||||
↕️
|
||||
AppState (Coordinator)
|
||||
↕️
|
||||
State Modules (AuthState, ProfileState, etc.)
|
||||
↕️
|
||||
Services (MapDataProvider, NodeCache, Uploader)
|
||||
↕️
|
||||
External APIs (OSM, Overpass, Tile providers)
|
||||
```
|
||||
|
||||
**Key principles:**
|
||||
- **Unidirectional data flow**: UI → AppState → Services → APIs
|
||||
- **No direct service access from UI**: Everything goes through AppState
|
||||
- **Clean boundaries**: Each layer has a clear responsibility
|
||||
|
||||
---
|
||||
|
||||
## Core Components
|
||||
|
||||
### 1. MapDataProvider
|
||||
|
||||
**Purpose**: Unified interface for fetching map tiles and surveillance nodes
|
||||
|
||||
**Design decisions:**
|
||||
- **Pluggable sources**: Local (cached) vs Remote (live API)
|
||||
- **Offline-first**: Always try local first, graceful degradation
|
||||
- **Mode-aware**: Different behavior for production vs sandbox
|
||||
- **Failure handling**: Never crash the UI, always provide fallbacks
|
||||
|
||||
**Key methods:**
|
||||
- `getNodes()`: Smart fetching with local/remote merging
|
||||
- `getTile()`: Tile fetching with caching
|
||||
- `_fetchRemoteNodes()`: Handles Overpass → OSM API fallback
|
||||
|
||||
**Why unified interface:**
|
||||
The app needs to seamlessly switch between multiple data sources (local cache, Overpass API, OSM API, offline areas) based on network status, upload mode, and zoom level. A single interface prevents the UI from needing to know about these complexities.
|
||||
|
||||
### 2. Node Operations (Create/Edit/Delete)
|
||||
|
||||
**Upload Operations Enum:**
|
||||
```dart
|
||||
enum UploadOperation { create, modify, delete }
|
||||
```
|
||||
|
||||
**Why explicit enum vs boolean flags:**
|
||||
- **Brutalist**: Three explicit states instead of nullable booleans
|
||||
- **Extensible**: Easy to add new operations (like bulk operations)
|
||||
- **Clear intent**: `operation == UploadOperation.delete` is unambiguous
|
||||
|
||||
**Session Pattern:**
|
||||
- `AddNodeSession`: For creating new nodes
|
||||
- `EditNodeSession`: For modifying existing nodes
|
||||
- No "DeleteSession": Deletions are immediate (simpler)
|
||||
|
||||
**Why no delete session:**
|
||||
Deletions don't need position dragging or tag editing - they just need confirmation and queuing. A session would add complexity without benefit.
|
||||
|
||||
### 3. Upload Queue System
|
||||
|
||||
**Design principles:**
|
||||
- **Operation-agnostic**: Same queue handles create/modify/delete
|
||||
- **Offline-capable**: Queue persists between app sessions
|
||||
- **Visual feedback**: Each operation type has distinct UI state
|
||||
- **Error recovery**: Retry mechanism with exponential backoff
|
||||
|
||||
**Queue workflow:**
|
||||
1. User action (add/edit/delete) → `PendingUpload` created
|
||||
2. Immediate visual feedback (cache updated with temp markers)
|
||||
3. Background uploader processes queue when online
|
||||
4. Success → cache updated with real data, temp markers removed
|
||||
5. Failure → error state, retry available
|
||||
|
||||
**Why immediate visual feedback:**
|
||||
Users expect instant response to their actions. By immediately updating the cache with temporary markers (e.g., `_pending_deletion`), the UI stays responsive while the actual API calls happen in background.
|
||||
|
||||
### 4. Cache & Visual States
|
||||
|
||||
**Node visual states:**
|
||||
- **Blue ring**: Real nodes from OSM
|
||||
- **Purple ring**: Pending uploads (new nodes)
|
||||
- **Grey ring**: Original nodes with pending edits
|
||||
- **Orange ring**: Node currently being edited
|
||||
- **Red ring**: Nodes pending deletion
|
||||
|
||||
**Cache tags for state tracking:**
|
||||
```dart
|
||||
'_pending_upload' // New node waiting to upload
|
||||
'_pending_edit' // Original node has pending edits
|
||||
'_pending_deletion' // Node queued for deletion
|
||||
'_original_node_id' // For drawing connection lines
|
||||
```
|
||||
|
||||
**Why underscore prefix:**
|
||||
These are internal app tags, not OSM tags. The underscore prefix makes this explicit and prevents accidental upload to OSM.
|
||||
|
||||
### 5. Multi-API Data Sources
|
||||
|
||||
**Production mode:** Overpass API → OSM API fallback
|
||||
**Sandbox mode:** OSM API only (Overpass doesn't have sandbox data)
|
||||
|
||||
**Zoom level restrictions:**
|
||||
- **Production (Overpass)**: Zoom ≥ 10 (established limit)
|
||||
- **Sandbox (OSM API)**: Zoom ≥ 13 (stricter due to bbox limits)
|
||||
|
||||
**Why different zoom limits:**
|
||||
The OSM API returns ALL data types (nodes, ways, relations) in a bounding box and has stricter size limits. Overpass is more efficient for large areas. The zoom restrictions prevent API errors and excessive data transfer.
|
||||
|
||||
### 6. Offline vs Online Mode Behavior
|
||||
|
||||
**Mode combinations:**
|
||||
```
|
||||
Production + Online → Local cache + Overpass API
|
||||
Production + Offline → Local cache only
|
||||
Sandbox + Online → OSM API only (no cache mixing)
|
||||
Sandbox + Offline → No nodes (cache is production data)
|
||||
```
|
||||
|
||||
**Why sandbox + offline = no nodes:**
|
||||
Local cache contains production data. Showing production nodes in sandbox mode would be confusing and could lead to users trying to edit production nodes with sandbox credentials.
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions & Rationales
|
||||
|
||||
### 1. Why Provider Pattern?
|
||||
|
||||
**Alternatives considered:**
|
||||
- BLoC: Too verbose for our needs
|
||||
- Riverpod: Added complexity without clear benefit
|
||||
- setState: Doesn't scale beyond single widgets
|
||||
|
||||
**Why Provider won:**
|
||||
- **Familiar**: Most Flutter developers know Provider
|
||||
- **Simple**: Minimal boilerplate
|
||||
- **Flexible**: Easy to compose multiple providers
|
||||
- **Battle-tested**: Mature, stable library
|
||||
|
||||
### 2. Why Separate State Classes?
|
||||
|
||||
**Alternative**: Single monolithic AppState
|
||||
|
||||
**Why modular state:**
|
||||
- **Single responsibility**: Each state class has one concern
|
||||
- **Testability**: Easier to unit test individual features
|
||||
- **Maintainability**: Changes to auth don't affect profile logic
|
||||
- **Team development**: Different developers can work on different states
|
||||
|
||||
### 3. Why Upload Queue vs Direct API Calls?
|
||||
|
||||
**Alternative**: Direct API calls from UI actions
|
||||
|
||||
**Why queue approach:**
|
||||
- **Offline capability**: Actions work without internet
|
||||
- **User experience**: Instant feedback, no waiting for API calls
|
||||
- **Error recovery**: Failed uploads can be retried
|
||||
- **Batch processing**: Could optimize multiple operations
|
||||
- **Visual feedback**: Users can see pending operations
|
||||
|
||||
### 4. Why Overpass + OSM API vs Just One?
|
||||
|
||||
**Why not just Overpass:**
|
||||
- Overpass doesn't have sandbox data
|
||||
- Overpass can be unreliable/slow
|
||||
- OSM API is canonical source
|
||||
|
||||
**Why not just OSM API:**
|
||||
- OSM API has strict bbox size limits
|
||||
- OSM API returns all data types (inefficient)
|
||||
- Overpass is optimized for surveillance device queries
|
||||
|
||||
**Result**: Use the best tool for each situation
|
||||
|
||||
### 5. Why Zoom Level Restrictions?
|
||||
|
||||
**Alternative**: Always fetch, handle errors gracefully
|
||||
|
||||
**Why restrictions:**
|
||||
- **Prevents API abuse**: Large bbox queries can overload servers
|
||||
- **User experience**: Fetching 10,000 nodes causes UI lag
|
||||
- **Battery life**: Excessive network requests drain battery
|
||||
- **Clear feedback**: Users understand why nodes aren't showing
|
||||
|
||||
---
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### 1. Adding New Features
|
||||
|
||||
**Before writing code:**
|
||||
1. Can we solve this by removing existing code?
|
||||
2. Can we simplify the problem to avoid edge cases?
|
||||
3. Does this fit the existing patterns?
|
||||
|
||||
**When adding new upload operations:**
|
||||
1. Add to `UploadOperation` enum
|
||||
2. Update `PendingUpload` serialization
|
||||
3. Add visual state (color, icon)
|
||||
4. Update uploader logic
|
||||
5. Add cache cleanup handling
|
||||
|
||||
### 2. Testing Philosophy
|
||||
|
||||
**Priority order:**
|
||||
1. **Integration tests**: Test complete user workflows
|
||||
2. **Widget tests**: Test UI components with mock data
|
||||
3. **Unit tests**: Test individual state classes
|
||||
|
||||
**Why integration tests first:**
|
||||
The most important thing is that user workflows work end-to-end. Unit tests can pass while the app is broken from a user perspective.
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
**Principles:**
|
||||
- **Never crash the UI**: Always provide fallbacks
|
||||
- **Fail gracefully**: Empty list is better than exception
|
||||
- **User feedback**: Show meaningful error messages
|
||||
- **Logging**: Use debugPrint for troubleshooting
|
||||
|
||||
**Example pattern:**
|
||||
```dart
|
||||
try {
|
||||
final result = await riskyOperation();
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint('Operation failed: $e');
|
||||
// Show user-friendly message
|
||||
showSnackBar('Unable to load data. Please try again.');
|
||||
return <EmptyResult>[];
|
||||
}
|
||||
```
|
||||
|
||||
### 4. State Updates
|
||||
|
||||
**Always notify listeners:**
|
||||
```dart
|
||||
void updateSomething() {
|
||||
_something = newValue;
|
||||
notifyListeners(); // Don't forget this!
|
||||
}
|
||||
```
|
||||
|
||||
**Batch related updates:**
|
||||
```dart
|
||||
void updateMultipleThings() {
|
||||
_thing1 = value1;
|
||||
_thing2 = value2;
|
||||
_thing3 = value3;
|
||||
notifyListeners(); // Single notification for all changes
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build & Development Setup
|
||||
|
||||
### Prerequisites
|
||||
- **Flutter SDK**: Latest stable version
|
||||
- **Xcode**: For iOS builds (macOS only)
|
||||
- **Android Studio**: For Android builds
|
||||
- **Git**: For version control
|
||||
|
||||
### OAuth2 Setup
|
||||
|
||||
**Required registrations:**
|
||||
1. **Production OSM**: https://www.openstreetmap.org/oauth2/applications
|
||||
2. **Sandbox OSM**: https://master.apis.dev.openstreetmap.org/oauth2/applications
|
||||
|
||||
**Configuration:**
|
||||
```bash
|
||||
cp lib/keys.dart.example lib/keys.dart
|
||||
# Edit keys.dart with your OAuth2 client IDs
|
||||
```
|
||||
|
||||
### iOS Setup
|
||||
```bash
|
||||
cd ios && pod install
|
||||
```
|
||||
|
||||
### Running
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter run
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run all tests
|
||||
flutter test
|
||||
|
||||
# Run with coverage
|
||||
flutter test --coverage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Organization
|
||||
|
||||
```
|
||||
lib/
|
||||
├── models/ # Data classes
|
||||
│ ├── osm_camera_node.dart
|
||||
│ ├── pending_upload.dart
|
||||
│ └── node_profile.dart
|
||||
├── services/ # Business logic
|
||||
│ ├── map_data_provider.dart
|
||||
│ ├── uploader.dart
|
||||
│ └── node_cache.dart
|
||||
├── state/ # State management
|
||||
│ ├── app_state.dart
|
||||
│ ├── auth_state.dart
|
||||
│ └── upload_queue_state.dart
|
||||
├── widgets/ # UI components
|
||||
│ ├── map_view.dart
|
||||
│ ├── edit_node_sheet.dart
|
||||
│ └── map/ # Map-specific widgets
|
||||
├── screens/ # Full screens
|
||||
│ ├── home_screen.dart
|
||||
│ └── settings_screen.dart
|
||||
└── localizations/ # i18n strings
|
||||
├── en.json
|
||||
├── de.json
|
||||
├── es.json
|
||||
└── fr.json
|
||||
```
|
||||
|
||||
**Principles:**
|
||||
- **Models**: Pure data, no business logic
|
||||
- **Services**: Stateless business logic
|
||||
- **State**: Stateful coordination
|
||||
- **Widgets**: UI only, delegate to AppState
|
||||
- **Screens**: Compose widgets, handle navigation
|
||||
|
||||
---
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Nodes not appearing:**
|
||||
- Check zoom level (≥10 production, ≥13 sandbox)
|
||||
- Check upload mode vs expected data source
|
||||
- Check network connectivity
|
||||
- Look for console errors
|
||||
|
||||
**Upload failures:**
|
||||
- Verify OAuth2 credentials
|
||||
- Check upload mode matches login (production vs sandbox)
|
||||
- Ensure node has required tags
|
||||
- Check network connectivity
|
||||
|
||||
**Cache issues:**
|
||||
- Clear app data to reset cache
|
||||
- Check if offline mode is affecting behavior
|
||||
- Verify upload mode switches clear cache
|
||||
|
||||
### Debug Logging
|
||||
|
||||
**Enable verbose logging:**
|
||||
```dart
|
||||
debugPrint('[ComponentName] Detailed message: $data');
|
||||
```
|
||||
|
||||
**Key areas to log:**
|
||||
- Network requests and responses
|
||||
- Cache operations
|
||||
- State transitions
|
||||
- User actions
|
||||
|
||||
### Performance
|
||||
|
||||
**Monitor:**
|
||||
- Memory usage during large node fetches
|
||||
- UI responsiveness during background uploads
|
||||
- Battery usage during GPS tracking
|
||||
|
||||
---
|
||||
|
||||
This documentation should be updated as the architecture evolves. When making significant changes, update both the relevant section here and add a brief note explaining the rationale for the change.
|
||||
61
README.md
@@ -25,11 +25,11 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
||||
- **Multi-source tiles**: Switch between OpenStreetMap, Google Satellite, Esri imagery, Mapbox, and any custom providers
|
||||
- **Offline-first design**: Download a region for complete offline operation
|
||||
- **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, and gesture-friendly interactions
|
||||
- **Device visualization**: Color-coded markers showing real devices (blue), pending uploads (purple), new devices (white), edited devices (grey), and devices being edited (orange)
|
||||
- **Device visualization**: Color-coded markers showing real devices (blue), pending uploads (purple), pending edits (grey), devices being edited (orange), and pending deletions (red)
|
||||
|
||||
### Device Management
|
||||
- **Comprehensive profiles**: Built-in profiles for major manufacturers (Flock Safety, Motorola/Vigilant, Genetec, Leonardo/ELSAG, Neology) plus custom profile creation
|
||||
- **Editing capabilities**: Update location, direction, and tags of existing devices
|
||||
- **Full CRUD operations**: Create, edit, and delete surveillance devices
|
||||
- **Direction visualization**: Interactive field-of-view cones showing camera viewing angles
|
||||
- **Bulk operations**: Tag multiple devices efficiently with profile-based workflow
|
||||
|
||||
@@ -52,7 +52,8 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
||||
1. **Install** the app on iOS or Android
|
||||
2. **Enable location** permissions
|
||||
3. **Log into OpenStreetMap**: Choose upload mode and get OAuth2 credentials
|
||||
4. **Add your first device**: Tap the "tag node" button, position the pin, set direction, select a profile, and tap submit
|
||||
4. **Add your first device**: Tap the "New Node" button, position the pin, set direction, select a profile, and tap submit
|
||||
5. **Edit or delete devices**: Tap any device marker to view details, then use Edit or Delete buttons
|
||||
|
||||
**New to OpenStreetMap?** Visit [deflock.me](https://deflock.me) for complete setup instructions and community guidelines.
|
||||
|
||||
@@ -60,50 +61,42 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
||||
|
||||
## For Developers
|
||||
|
||||
### Architecture Highlights
|
||||
- **Unified data provider**: All map tiles and surveillance device data route through `MapDataProvider` with pluggable remote/local sources
|
||||
- **Modular settings**: Each settings section is a separate widget for maintainability
|
||||
- **State management**: Provider pattern with clean separation of concerns
|
||||
- **Offline-first**: Network calls are optional; app functions fully offline with downloaded data and queues uploads until online
|
||||
|
||||
### Build Setup
|
||||
**Prerequisites**: Flutter SDK, Xcode (iOS), Android Studio
|
||||
**OAuth Setup**: Register apps at [openstreetmap.org/oauth2](https://www.openstreetmap.org/oauth2/applications) and [OSM Sandbox](https://master.apis.dev.openstreetmap.org/oauth2/applications) to get a client ID
|
||||
**See [DEVELOPER.md](DEVELOPER.md)** for comprehensive technical documentation including:
|
||||
- Architecture overview and design decisions
|
||||
- Development setup and build instructions
|
||||
- Code organization and contribution guidelines
|
||||
- Debugging tips and troubleshooting
|
||||
|
||||
**Quick setup:**
|
||||
```shell
|
||||
# Basic setup
|
||||
flutter pub get
|
||||
cp lib/keys.dart.example lib/keys.dart
|
||||
# Add your OAuth2 client IDs to keys.dart
|
||||
|
||||
# iOS additional setup
|
||||
cd ios && pod install
|
||||
|
||||
# Run
|
||||
flutter run
|
||||
# Add OAuth2 client IDs, then: flutter run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### v1 todo/bug List
|
||||
- Fix "tiles loaded" indicator accuracy across different providers
|
||||
- Generic tile provider error messages (not always "OSM tiles slow")
|
||||
- Optional custom icons for camera profiles
|
||||
- Camera deletions
|
||||
- Clean up cache when submitted changesets appear in Overpass results
|
||||
- Upgrade device marker design (considering nullplate's svg)
|
||||
### Current Development
|
||||
- Clean cache when nodes have disappeared / been deletd by others
|
||||
- Clean up dev_config
|
||||
- Improve offline area node refresh live display
|
||||
- Add default operator profiles (Lowe’s etc)
|
||||
|
||||
### Future Features & Wishlist
|
||||
- Location-based notifications when approaching surveillance devices
|
||||
- Red/yellow ring for devices missing specific tag details
|
||||
- iOS/Android native themes and dark mode support
|
||||
- "Cache accumulating" offline areas?
|
||||
- "Offline areas" as tile provider?
|
||||
- Update offline area nodes while browsing?
|
||||
- Suspected locations toggle (alprwatch.com/flock/utilities)
|
||||
- Jump to location by coordinates, address, or POI name
|
||||
- Route planning that avoids surveillance devices
|
||||
- Custom device providers and OSM/Overpass alternatives
|
||||
- Route planning that avoids surveillance devices (alprwatch.com/directions)
|
||||
|
||||
### Maybes
|
||||
- Yellow ring for devices missing specific tag details?
|
||||
- "Cache accumulating" offline area?
|
||||
- "Offline areas" as tile provider?
|
||||
- Optional custom icons for camera profiles?
|
||||
- Upgrade device marker design? (considering nullplate's svg)
|
||||
- Custom device providers and OSM/Overpass alternatives?
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ android {
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
@@ -50,3 +51,7 @@ flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
<!-- Location permissions for blue‑dot positioning -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
|
||||
<!-- Notification permission for proximity alerts -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<application
|
||||
android:name="${applicationName}"
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 69 B |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 435 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 805 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 69 B |
@@ -1,7 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/launch_background" />
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 757 KiB After Width: | Height: | Size: 96 KiB |
57
do_builds.sh
@@ -1,16 +1,53 @@
|
||||
#!/bin/bash
|
||||
|
||||
appver=$(cat lib/dev_config.dart | grep "kClientVersion" | cut -d '=' -f 2 | tr -d ';' | tr -d "\'" | tr -d " ")
|
||||
# Default options
|
||||
BUILD_IOS=true
|
||||
BUILD_ANDROID=true
|
||||
|
||||
# Parse arguments
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--ios)
|
||||
BUILD_ANDROID=false
|
||||
;;
|
||||
--android)
|
||||
BUILD_IOS=false
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [--ios | --android]"
|
||||
echo " --ios Build only iOS"
|
||||
echo " --android Build only Android"
|
||||
echo " (default builds both)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
appver=$(grep "kClientVersion" lib/dev_config.dart | cut -d '=' -f 2 | tr -d ';' | tr -d "\'" | tr -d " ")
|
||||
echo
|
||||
echo "Building app version ${appver}..."
|
||||
flutter build ios --no-codesign
|
||||
flutter build apk
|
||||
echo
|
||||
echo "Converting .app to .ipa..."
|
||||
./app2ipa.sh build/ios/iphoneos/Runner.app
|
||||
echo
|
||||
echo "Moving files..."
|
||||
cp build/app/outputs/flutter-apk/app-release.apk ../flockmap_v${appver}.apk
|
||||
mv Runner.ipa ../flockmap_v${appver}.ipa
|
||||
echo
|
||||
|
||||
if [ "$BUILD_IOS" = true ]; then
|
||||
echo "Building iOS..."
|
||||
flutter build ios --no-codesign || exit 1
|
||||
|
||||
echo "Converting .app to .ipa..."
|
||||
./app2ipa.sh build/ios/iphoneos/Runner.app || exit 1
|
||||
|
||||
echo "Moving iOS files..."
|
||||
mv Runner.ipa "../deflock_v${appver}.ipa" || exit 1
|
||||
echo
|
||||
fi
|
||||
|
||||
if [ "$BUILD_ANDROID" = true ]; then
|
||||
echo "Building Android..."
|
||||
flutter build apk || exit 1
|
||||
|
||||
echo "Moving Android files..."
|
||||
cp build/app/outputs/flutter-apk/app-release.apk "../deflock_v${appver}.apk" || exit 1
|
||||
echo
|
||||
fi
|
||||
|
||||
echo "Done."
|
||||
|
||||
|
||||
@@ -512,7 +512,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.flockMapApp.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -659,7 +659,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.flockMapApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
|
||||
|
Before Width: | Height: | Size: 805 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 875 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 69 B |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 435 KiB After Width: | Height: | Size: 57 KiB |
@@ -16,13 +16,19 @@
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="0.125" green="0.125" blue="0.125" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
|
||||
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
|
||||
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
@@ -32,6 +38,7 @@
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
<image name="LaunchImage" width="512" height="512"/>
|
||||
<image name="LaunchBackground" width="1" height="1"/>
|
||||
</resources>
|
||||
</document>
|
||||
|
||||
@@ -4,10 +4,13 @@ import 'package:latlong2/latlong.dart';
|
||||
|
||||
import 'models/node_profile.dart';
|
||||
import 'models/operator_profile.dart';
|
||||
import 'models/osm_camera_node.dart';
|
||||
import 'models/osm_node.dart';
|
||||
import 'models/pending_upload.dart';
|
||||
import 'models/tile_provider.dart';
|
||||
import 'services/offline_area_service.dart';
|
||||
import 'services/node_cache.dart';
|
||||
import 'services/tile_preview_service.dart';
|
||||
import 'widgets/camera_provider_with_cache.dart';
|
||||
import 'state/auth_state.dart';
|
||||
import 'state/operator_profile_state.dart';
|
||||
import 'state/profile_state.dart';
|
||||
@@ -77,6 +80,8 @@ class AppState extends ChangeNotifier {
|
||||
int get maxCameras => _settingsState.maxCameras;
|
||||
UploadMode get uploadMode => _settingsState.uploadMode;
|
||||
FollowMeMode get followMeMode => _settingsState.followMeMode;
|
||||
bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled;
|
||||
int get proximityAlertDistance => _settingsState.proximityAlertDistance;
|
||||
|
||||
// Tile provider state
|
||||
List<TileProvider> get tileProviders => _settingsState.tileProviders;
|
||||
@@ -97,6 +102,10 @@ class AppState extends ChangeNotifier {
|
||||
Future<void> _init() async {
|
||||
// Initialize all state modules
|
||||
await _settingsState.init();
|
||||
|
||||
// Attempt to fetch missing tile type preview tiles (fails silently)
|
||||
_fetchMissingTilePreviews();
|
||||
|
||||
await _operatorProfileState.init();
|
||||
await _profileState.init();
|
||||
await _uploadQueueState.init();
|
||||
@@ -160,7 +169,7 @@ class AppState extends ChangeNotifier {
|
||||
_sessionState.startAddSession(enabledProfiles);
|
||||
}
|
||||
|
||||
void startEditSession(OsmCameraNode node) {
|
||||
void startEditSession(OsmNode node) {
|
||||
_sessionState.startEditSession(node, enabledProfiles);
|
||||
}
|
||||
|
||||
@@ -216,6 +225,11 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void deleteNode(OsmNode node) {
|
||||
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode);
|
||||
_startUploader();
|
||||
}
|
||||
|
||||
// ---------- Settings Methods ----------
|
||||
Future<void> setOfflineMode(bool enabled) async {
|
||||
await _settingsState.setOfflineMode(enabled);
|
||||
@@ -233,6 +247,11 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
// Clear node cache when switching upload modes to prevent mixing production/sandbox data
|
||||
NodeCache.instance.clear();
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
debugPrint('[AppState] Cleared node cache due to upload mode change');
|
||||
|
||||
await _settingsState.setUploadMode(mode);
|
||||
await _authState.onUploadModeChanged(mode);
|
||||
_startUploader(); // Restart uploader with new mode
|
||||
@@ -258,6 +277,16 @@ class AppState extends ChangeNotifier {
|
||||
await _settingsState.setFollowMeMode(mode);
|
||||
}
|
||||
|
||||
/// Set proximity alerts enabled/disabled
|
||||
Future<void> setProximityAlertsEnabled(bool enabled) async {
|
||||
await _settingsState.setProximityAlertsEnabled(enabled);
|
||||
}
|
||||
|
||||
/// Set proximity alert distance
|
||||
Future<void> setProximityAlertDistance(int distance) async {
|
||||
await _settingsState.setProximityAlertDistance(distance);
|
||||
}
|
||||
|
||||
// ---------- Queue Methods ----------
|
||||
void clearQueue() {
|
||||
_uploadQueueState.clearQueue();
|
||||
@@ -273,6 +302,15 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// ---------- Private Methods ----------
|
||||
/// Attempts to fetch missing tile preview images in the background (fire and forget)
|
||||
void _fetchMissingTilePreviews() {
|
||||
// Run asynchronously without awaiting to avoid blocking app startup
|
||||
TilePreviewService.fetchMissingPreviews(_settingsState).catchError((error) {
|
||||
// Silently ignore errors - this is best effort
|
||||
debugPrint('AppState: Tile preview fetching failed silently: $error');
|
||||
});
|
||||
}
|
||||
|
||||
void _startUploader() {
|
||||
_uploadQueueState.startUploader(
|
||||
offlineMode: offlineMode,
|
||||
|
||||
@@ -2,34 +2,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Developer/build-time configuration for global/non-user-tunable constants.
|
||||
const int kWorldMinZoom = 1;
|
||||
const int kWorldMaxZoom = 5;
|
||||
|
||||
// Example: Default tile storage estimate (KB per tile), for size estimates
|
||||
const double kTileEstimateKb = 25.0;
|
||||
// Fallback tile storage estimate (KB per tile), used when no preview tile data is available
|
||||
const double kFallbackTileEstimateKb = 25.0;
|
||||
|
||||
// Preview tile coordinates for tile provider previews and size estimates
|
||||
const int kPreviewTileZoom = 18;
|
||||
const int kPreviewTileY = 101300;
|
||||
const int kPreviewTileX = 41904;
|
||||
|
||||
// Direction cone for map view
|
||||
const double kDirectionConeHalfAngle = 30.0; // degrees
|
||||
const double kDirectionConeBaseLength = 0.001; // multiplier
|
||||
const Color kDirectionConeColor = Color(0xFF000000); // FOV cone color
|
||||
|
||||
// Margin (bottom) for positioning the floating bottom button bar
|
||||
const double kBottomButtonBarMargin = 4.0;
|
||||
// Bottom button bar positioning
|
||||
const double kBottomButtonBarOffset = 4.0; // Distance from screen bottom (above safe area)
|
||||
const double kButtonBarHeight = 60.0; // Button height (48) + padding (12)
|
||||
|
||||
// Map overlay (attribution, scale bar, zoom) vertical offset from bottom edge
|
||||
const double kAttributionBottomOffset = 110.0;
|
||||
const double kZoomIndicatorBottomOffset = 142.0;
|
||||
const double kScaleBarBottomOffset = 170.0;
|
||||
// Map overlay spacing relative to button bar top
|
||||
const double kAttributionSpacingAboveButtonBar = 10.0; // Attribution above button bar top
|
||||
const double kZoomIndicatorSpacingAboveButtonBar = 40.0; // Zoom indicator above button bar top
|
||||
const double kScaleBarSpacingAboveButtonBar = 70.0; // Scale bar above button bar top
|
||||
const double kZoomControlsSpacingAboveButtonBar = 20.0; // Zoom controls above button bar top
|
||||
|
||||
// Helper to calculate bottom position relative to button bar
|
||||
double bottomPositionFromButtonBar(double spacingAboveButtonBar, double safeAreaBottom) {
|
||||
return safeAreaBottom + kBottomButtonBarOffset + kButtonBarHeight + spacingAboveButtonBar;
|
||||
}
|
||||
|
||||
// Add Camera icon vertical offset (no offset needed since circle is centered)
|
||||
const double kAddPinYOffset = 0.0;
|
||||
|
||||
// Client name and version for OSM uploads ("created_by" tag)
|
||||
const String kClientName = 'DeFlock';
|
||||
const String kClientVersion = '0.9.8';
|
||||
const String kClientVersion = '1.0.0';
|
||||
|
||||
// Development/testing features - set to false for production builds
|
||||
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
|
||||
|
||||
// Marker/node interaction
|
||||
const int kCameraMinZoomLevel = 10; // Minimum zoom to show nodes or warning
|
||||
const int kCameraMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass)
|
||||
const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode)
|
||||
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
|
||||
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
|
||||
|
||||
@@ -37,6 +51,12 @@ const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
|
||||
const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600);
|
||||
const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rotation
|
||||
|
||||
// Proximity alerts configuration
|
||||
const int kProximityAlertDefaultDistance = 200; // meters
|
||||
const int kProximityAlertMinDistance = 50; // meters
|
||||
const int kProximityAlertMaxDistance = 1000; // meters
|
||||
const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node
|
||||
|
||||
// Last map location and settings storage
|
||||
const String kLastMapLatKey = 'last_map_latitude';
|
||||
const String kLastMapLngKey = 'last_map_longitude';
|
||||
@@ -56,6 +76,7 @@ const int kMaxUserDownloadZoomSpan = 7;
|
||||
|
||||
// Download area limits and constants
|
||||
const int kMaxReasonableTileCount = 20000;
|
||||
const int kAbsoluteMaxTileCount = 50000;
|
||||
const int kAbsoluteMaxZoom = 19;
|
||||
|
||||
// Camera icon configuration
|
||||
@@ -67,3 +88,4 @@ const Color kCameraRingColorMock = Color(0xC4FFFFFF); // Add node mock point - w
|
||||
const Color kCameraRingColorPending = Color(0xC49C27B0); // Submitted/pending nodes - purple
|
||||
const Color kCameraRingColorEditing = Color(0xC4FF9800); // Node being edited - orange
|
||||
const Color kCameraRingColorPendingEdit = Color(0xC4757575); // Original node with pending edit - grey
|
||||
const Color kCameraRingColorPendingDeletion = Color(0xA4F44336); // Node pending deletion - red, slightly transparent
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Knoten Markieren",
|
||||
"tagNode": "Neuer Knoten",
|
||||
"download": "Herunterladen",
|
||||
"settings": "Einstellungen",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"cancel": "Abbrechen",
|
||||
"ok": "OK",
|
||||
"close": "Schließen",
|
||||
@@ -28,20 +29,45 @@
|
||||
"systemDefault": "Systemstandard",
|
||||
"aboutInfo": "Über / Informationen",
|
||||
"aboutThisApp": "Über Diese App",
|
||||
"maxNodes": "Max. geladene/angezeigte Knoten",
|
||||
"aboutSubtitle": "App-Informationen und Credits",
|
||||
"languageSubtitle": "Wählen Sie Ihre bevorzugte Sprache",
|
||||
"maxNodes": "Max. angezeigte Knoten",
|
||||
"maxNodesSubtitle": "Obergrenze für die Anzahl der Knoten auf der Karte festlegen (Standard: 250).",
|
||||
"maxNodesWarning": "Sie möchten das wahrscheinlich nicht tun, es sei denn, Sie sind absolut sicher, dass Sie einen guten Grund dafür haben.",
|
||||
"offlineMode": "Offline-Modus",
|
||||
"offlineModeSubtitle": "Alle Netzwerkanfragen außer für lokale/Offline-Bereiche deaktivieren.",
|
||||
"offlineModeWarningTitle": "Aktive Downloads",
|
||||
"offlineModeWarningMessage": "Die Aktivierung des Offline-Modus bricht alle aktiven Bereichsdownloads ab. Möchten Sie fortfahren?",
|
||||
"enableOfflineMode": "Offline-Modus Aktivieren"
|
||||
"enableOfflineMode": "Offline-Modus Aktivieren",
|
||||
"profiles": "Profile",
|
||||
"profilesSubtitle": "Knoten- und Betreiberprofile verwalten",
|
||||
"offlineSettings": "Offline-Einstellungen",
|
||||
"offlineSettingsSubtitle": "Offline-Modus und heruntergeladene Bereiche verwalten",
|
||||
"advancedSettings": "Erweiterte Einstellungen",
|
||||
"advancedSettingsSubtitle": "Leistungs-, Warnungs- und Kachelanbieter-Einstellungen",
|
||||
"proximityAlerts": "Näherungswarnungen"
|
||||
},
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Benachrichtigung erhalten beim Annähern an Überwachungsgeräte",
|
||||
"batteryUsage": "Verbraucht zusätzlich Batterie für kontinuierliche Standortüberwachung",
|
||||
"notificationsEnabled": "✓ Benachrichtigungen aktiviert",
|
||||
"notificationsDisabled": "⚠ Benachrichtigungen deaktiviert",
|
||||
"permissionRequired": "Benachrichtigungsberechtigung erforderlich",
|
||||
"permissionExplanation": "Push-Benachrichtigungen sind deaktiviert. Sie sehen nur In-App-Warnungen und werden nicht benachrichtigt, wenn die App im Hintergrund läuft.",
|
||||
"enableNotifications": "Benachrichtigungen Aktivieren",
|
||||
"checkingPermissions": "Berechtigungen prüfen...",
|
||||
"alertDistance": "Warnentfernung: ",
|
||||
"meters": "Meter",
|
||||
"rangeInfo": "Bereich: {}-{} Meter (Standard: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Knoten #{}",
|
||||
"tagSheetTitle": "Gerät-Tags",
|
||||
"queuedForUpload": "Knoten zum Upload eingereiht",
|
||||
"editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht"
|
||||
"editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht",
|
||||
"deleteQueuedForUpload": "Knoten-Löschung zum Upload eingereiht",
|
||||
"confirmDeleteTitle": "Knoten löschen",
|
||||
"confirmDeleteMessage": "Sind Sie sicher, dass Sie Knoten #{} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
@@ -75,7 +101,7 @@
|
||||
"withinTileLimit": "Innerhalb {} Kachel-Limit",
|
||||
"exceedsTileLimit": "Aktuelle Auswahl überschreitet {} Kachel-Limit",
|
||||
"offlineModeWarning": "Downloads im Offline-Modus deaktiviert. Deaktivieren Sie den Offline-Modus, um neue Bereiche herunterzuladen.",
|
||||
"downloadStarted": "Download gestartet! Lade Kacheln und Kameras...",
|
||||
"downloadStarted": "Download gestartet! Lade Kacheln und Knoten...",
|
||||
"downloadFailed": "Download konnte nicht gestartet werden: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
@@ -86,7 +112,6 @@
|
||||
"simulate": "Simulieren",
|
||||
"productionDescription": "Hochladen in die Live-OSM-Datenbank (für alle Benutzer sichtbar)",
|
||||
"sandboxDescription": "Uploads gehen an die OSM Sandbox (sicher zum Testen, wird regelmäßig zurückgesetzt).",
|
||||
"sandboxNote": "HINWEIS: Aufgrund von OpenStreetMap-Limitierungen werden Kameras, die an die Sandbox übermittelt werden, NICHT in der Karte dieser App angezeigt.",
|
||||
"simulateDescription": "Uploads simulieren (kontaktiert OSM-Server nicht)"
|
||||
},
|
||||
"auth": {
|
||||
@@ -211,5 +236,56 @@
|
||||
"deleteOperatorProfile": "Betreiber-Profil Löschen",
|
||||
"deleteOperatorProfileConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
|
||||
"operatorProfileDeleted": "Betreiber-Profil gelöscht"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Offline-Bereiche",
|
||||
"noAreasTitle": "Keine Offline-Bereiche",
|
||||
"noAreasSubtitle": "Laden Sie einen Kartenbereich für die Offline-Nutzung herunter.",
|
||||
"provider": "Anbieter",
|
||||
"maxZoom": "Max Zoom",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Breite",
|
||||
"longitude": "Länge",
|
||||
"tiles": "Kacheln",
|
||||
"size": "Größe",
|
||||
"nodes": "Knoten",
|
||||
"areaIdFallback": "Bereich {}...",
|
||||
"renameArea": "Bereich umbenennen",
|
||||
"refreshWorldTiles": "Welt-Kacheln aktualisieren/neu herunterladen",
|
||||
"deleteOfflineArea": "Offline-Bereich löschen",
|
||||
"cancelDownload": "Download abbrechen",
|
||||
"renameAreaDialogTitle": "Offline-Bereich Umbenennen",
|
||||
"areaNameLabel": "Bereichsname",
|
||||
"renameButton": "Umbenennen",
|
||||
"megabytes": "MB",
|
||||
"kilobytes": "KB",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Bereich aktualisieren",
|
||||
"refreshAreaDialogTitle": "Offline-Bereich aktualisieren",
|
||||
"refreshAreaDialogSubtitle": "Wählen Sie aus, was für diesen Bereich aktualisiert werden soll:",
|
||||
"refreshTiles": "Karten-Kacheln aktualisieren",
|
||||
"refreshTilesSubtitle": "Alle Kacheln für aktualisierte Bilder erneut herunterladen",
|
||||
"refreshNodes": "Knoten aktualisieren",
|
||||
"refreshNodesSubtitle": "Knotendaten für diesen Bereich erneut abrufen",
|
||||
"startRefresh": "Aktualisierung starten",
|
||||
"refreshStarted": "Aktualisierung gestartet!",
|
||||
"refreshFailed": "Aktualisierung fehlgeschlagen: {}"
|
||||
},
|
||||
"refineTagsSheet": {
|
||||
"title": "Tags Verfeinern",
|
||||
"operatorProfile": "Betreiber-Profil",
|
||||
"done": "Fertig",
|
||||
"none": "Keine",
|
||||
"noAdditionalOperatorTags": "Keine zusätzlichen Betreiber-Tags",
|
||||
"additionalTags": "zusätzliche Tags",
|
||||
"additionalTagsTitle": "Zusätzliche Tags",
|
||||
"noTagsDefinedForProfile": "Keine Tags für dieses Betreiber-Profil definiert.",
|
||||
"noOperatorProfiles": "Keine Betreiber-Profile definiert",
|
||||
"noOperatorProfilesMessage": "Erstellen Sie Betreiber-Profile in den Einstellungen, um zusätzliche Tags auf Ihre Knoten-Übertragungen anzuwenden."
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Kachel-Typen können während des Herunterladens von Offline-Bereichen nicht geändert werden",
|
||||
"selectMapLayer": "Kartenschicht Auswählen",
|
||||
"noTileProvidersAvailable": "Keine Kachel-Anbieter verfügbar"
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,11 @@
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Tag Node",
|
||||
"tagNode": "New Node",
|
||||
"download": "Download",
|
||||
"settings": "Settings",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"ok": "OK",
|
||||
"close": "Close",
|
||||
@@ -28,20 +29,45 @@
|
||||
"systemDefault": "System Default",
|
||||
"aboutInfo": "About / Info",
|
||||
"aboutThisApp": "About This App",
|
||||
"maxNodes": "Max nodes fetched/drawn",
|
||||
"aboutSubtitle": "App information and credits",
|
||||
"languageSubtitle": "Choose your preferred language",
|
||||
"maxNodes": "Max nodes drawn",
|
||||
"maxNodesSubtitle": "Set an upper limit for the number of nodes on the map (default: 250).",
|
||||
"maxNodesWarning": "You probably don't want to do that unless you are absolutely sure you have a good reason for it.",
|
||||
"offlineMode": "Offline Mode",
|
||||
"offlineModeSubtitle": "Disable all network requests except for local/offline areas.",
|
||||
"offlineModeWarningTitle": "Active Downloads",
|
||||
"offlineModeWarningMessage": "Enabling offline mode will cancel any active area downloads. Do you want to continue?",
|
||||
"enableOfflineMode": "Enable Offline Mode"
|
||||
"enableOfflineMode": "Enable Offline Mode",
|
||||
"profiles": "Profiles",
|
||||
"profilesSubtitle": "Manage node and operator profiles",
|
||||
"offlineSettings": "Offline Settings",
|
||||
"offlineSettingsSubtitle": "Manage offline mode and downloaded areas",
|
||||
"advancedSettings": "Advanced Settings",
|
||||
"advancedSettingsSubtitle": "Performance, alerts, and tile provider settings",
|
||||
"proximityAlerts": "Proximity Alerts"
|
||||
},
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Get notified when approaching surveillance devices",
|
||||
"batteryUsage": "Uses extra battery for continuous location monitoring",
|
||||
"notificationsEnabled": "✓ Notifications enabled",
|
||||
"notificationsDisabled": "⚠ Notifications disabled",
|
||||
"permissionRequired": "Notification permission required",
|
||||
"permissionExplanation": "Push notifications are disabled. You'll only see in-app alerts and won't be notified when the app is in background.",
|
||||
"enableNotifications": "Enable Notifications",
|
||||
"checkingPermissions": "Checking permissions...",
|
||||
"alertDistance": "Alert distance: ",
|
||||
"meters": "meters",
|
||||
"rangeInfo": "Range: {}-{} meters (default: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Node #{}",
|
||||
"tagSheetTitle": "Surveillance Device Tags",
|
||||
"queuedForUpload": "Node queued for upload",
|
||||
"editQueuedForUpload": "Node edit queued for upload"
|
||||
"editQueuedForUpload": "Node edit queued for upload",
|
||||
"deleteQueuedForUpload": "Node deletion queued for upload",
|
||||
"confirmDeleteTitle": "Delete Node",
|
||||
"confirmDeleteMessage": "Are you sure you want to delete node #{}? This action cannot be undone."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profile",
|
||||
@@ -75,7 +101,7 @@
|
||||
"withinTileLimit": "Within {} tile limit",
|
||||
"exceedsTileLimit": "Current selection exceeds {} tile limit",
|
||||
"offlineModeWarning": "Downloads disabled while in offline mode. Disable offline mode to download new areas.",
|
||||
"downloadStarted": "Download started! Fetching tiles and cameras...",
|
||||
"downloadStarted": "Download started! Fetching tiles and nodes...",
|
||||
"downloadFailed": "Failed to start download: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
@@ -86,7 +112,6 @@
|
||||
"simulate": "Simulate",
|
||||
"productionDescription": "Upload to the live OSM database (visible to all users)",
|
||||
"sandboxDescription": "Uploads go to the OSM Sandbox (safe for testing, resets regularly).",
|
||||
"sandboxNote": "NOTE: Due to OpenStreetMap limitations, cameras submitted to the sandbox will NOT appear on the map in this app.",
|
||||
"simulateDescription": "Simulate uploads (does not contact OSM servers)"
|
||||
},
|
||||
"auth": {
|
||||
@@ -211,5 +236,56 @@
|
||||
"deleteOperatorProfile": "Delete Operator Profile",
|
||||
"deleteOperatorProfileConfirm": "Are you sure you want to delete \"{}\"?",
|
||||
"operatorProfileDeleted": "Operator profile deleted"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Offline Areas",
|
||||
"noAreasTitle": "No offline areas",
|
||||
"noAreasSubtitle": "Download a map area for offline use.",
|
||||
"provider": "Provider",
|
||||
"maxZoom": "Max zoom",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Lat",
|
||||
"longitude": "Lon",
|
||||
"tiles": "Tiles",
|
||||
"size": "Size",
|
||||
"nodes": "Nodes",
|
||||
"areaIdFallback": "Area {}...",
|
||||
"renameArea": "Rename area",
|
||||
"refreshWorldTiles": "Refresh/re-download world tiles",
|
||||
"deleteOfflineArea": "Delete offline area",
|
||||
"cancelDownload": "Cancel download",
|
||||
"renameAreaDialogTitle": "Rename Offline Area",
|
||||
"areaNameLabel": "Area Name",
|
||||
"renameButton": "Rename",
|
||||
"megabytes": "MB",
|
||||
"kilobytes": "KB",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Refresh area",
|
||||
"refreshAreaDialogTitle": "Refresh Offline Area",
|
||||
"refreshAreaDialogSubtitle": "Choose what to refresh for this area:",
|
||||
"refreshTiles": "Refresh Map Tiles",
|
||||
"refreshTilesSubtitle": "Re-download all tiles for updated imagery",
|
||||
"refreshNodes": "Refresh Nodes",
|
||||
"refreshNodesSubtitle": "Re-fetch node data for this area",
|
||||
"startRefresh": "Start Refresh",
|
||||
"refreshStarted": "Refresh started!",
|
||||
"refreshFailed": "Refresh failed: {}"
|
||||
},
|
||||
"refineTagsSheet": {
|
||||
"title": "Refine Tags",
|
||||
"operatorProfile": "Operator Profile",
|
||||
"done": "Done",
|
||||
"none": "None",
|
||||
"noAdditionalOperatorTags": "No additional operator tags",
|
||||
"additionalTags": "additional tags",
|
||||
"additionalTagsTitle": "Additional Tags",
|
||||
"noTagsDefinedForProfile": "No tags defined for this operator profile.",
|
||||
"noOperatorProfiles": "No operator profiles defined",
|
||||
"noOperatorProfilesMessage": "Create operator profiles in Settings to apply additional tags to your node submissions."
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Cannot change tile types while downloading offline areas",
|
||||
"selectMapLayer": "Select Map Layer",
|
||||
"noTileProvidersAvailable": "No tile providers available"
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,11 @@
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Etiquetar Nodo",
|
||||
"tagNode": "Nuevo Nodo",
|
||||
"download": "Descargar",
|
||||
"settings": "Configuración",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar",
|
||||
"cancel": "Cancelar",
|
||||
"ok": "Aceptar",
|
||||
"close": "Cerrar",
|
||||
@@ -28,20 +29,45 @@
|
||||
"systemDefault": "Sistema por Defecto",
|
||||
"aboutInfo": "Acerca de / Información",
|
||||
"aboutThisApp": "Acerca de Esta App",
|
||||
"maxNodes": "Máx. nodos obtenidos/dibujados",
|
||||
"aboutSubtitle": "Información de la aplicación y créditos",
|
||||
"languageSubtitle": "Elige tu idioma preferido",
|
||||
"maxNodes": "Máx. nodos dibujados",
|
||||
"maxNodesSubtitle": "Establecer un límite superior para el número de nodos en el mapa (predeterminado: 250).",
|
||||
"maxNodesWarning": "Probablemente no quieras hacer eso a menos que estés absolutamente seguro de que tienes una buena razón para ello.",
|
||||
"offlineMode": "Modo Sin Conexión",
|
||||
"offlineModeSubtitle": "Deshabilitar todas las solicitudes de red excepto para áreas locales/sin conexión.",
|
||||
"offlineModeWarningTitle": "Descargas Activas",
|
||||
"offlineModeWarningMessage": "Habilitar el modo sin conexión cancelará cualquier descarga de área activa. ¿Desea continuar?",
|
||||
"enableOfflineMode": "Habilitar Modo Sin Conexión"
|
||||
"enableOfflineMode": "Habilitar Modo Sin Conexión",
|
||||
"profiles": "Perfiles",
|
||||
"profilesSubtitle": "Gestionar perfiles de nodos y operadores",
|
||||
"offlineSettings": "Configuración Sin Conexión",
|
||||
"offlineSettingsSubtitle": "Gestionar modo sin conexión y áreas descargadas",
|
||||
"advancedSettings": "Configuración Avanzada",
|
||||
"advancedSettingsSubtitle": "Configuración de rendimiento, alertas y proveedores de teselas",
|
||||
"proximityAlerts": "Alertas de Proximidad"
|
||||
},
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Recibe notificaciones al acercarte a dispositivos de vigilancia",
|
||||
"batteryUsage": "Usa batería extra para monitoreo continuo de ubicación",
|
||||
"notificationsEnabled": "✓ Notificaciones habilitadas",
|
||||
"notificationsDisabled": "⚠ Notificaciones deshabilitadas",
|
||||
"permissionRequired": "Permiso de notificación requerido",
|
||||
"permissionExplanation": "Las notificaciones push están deshabilitadas. Solo verás alertas dentro de la app y no serás notificado cuando la app esté en segundo plano.",
|
||||
"enableNotifications": "Habilitar Notificaciones",
|
||||
"checkingPermissions": "Verificando permisos...",
|
||||
"alertDistance": "Distancia de alerta: ",
|
||||
"meters": "metros",
|
||||
"rangeInfo": "Rango: {}-{} metros (predeterminado: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nodo #{}",
|
||||
"tagSheetTitle": "Etiquetas del Dispositivo",
|
||||
"queuedForUpload": "Nodo en cola para subir",
|
||||
"editQueuedForUpload": "Edición de nodo en cola para subir"
|
||||
"editQueuedForUpload": "Edición de nodo en cola para subir",
|
||||
"deleteQueuedForUpload": "Eliminación de nodo en cola para subir",
|
||||
"confirmDeleteTitle": "Eliminar Nodo",
|
||||
"confirmDeleteMessage": "¿Estás seguro de que quieres eliminar el nodo #{}? Esta acción no se puede deshacer."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Perfil",
|
||||
@@ -75,7 +101,7 @@
|
||||
"withinTileLimit": "Dentro del límite de {} mosaicos",
|
||||
"exceedsTileLimit": "La selección actual excede el límite de {} mosaicos",
|
||||
"offlineModeWarning": "Descargas deshabilitadas en modo sin conexión. Deshabilite el modo sin conexión para descargar nuevas áreas.",
|
||||
"downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y cámaras...",
|
||||
"downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y nodos...",
|
||||
"downloadFailed": "Error al iniciar la descarga: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
@@ -86,7 +112,6 @@
|
||||
"simulate": "Simular",
|
||||
"productionDescription": "Subir a la base de datos OSM en vivo (visible para todos los usuarios)",
|
||||
"sandboxDescription": "Las subidas van al Sandbox de OSM (seguro para pruebas, se reinicia regularmente).",
|
||||
"sandboxNote": "NOTA: Debido a las limitaciones de OpenStreetMap, las cámaras enviadas al sandbox NO aparecerán en el mapa de esta aplicación.",
|
||||
"simulateDescription": "Simular subidas (no contacta servidores OSM)"
|
||||
},
|
||||
"auth": {
|
||||
@@ -211,5 +236,56 @@
|
||||
"deleteOperatorProfile": "Eliminar Perfil de Operador",
|
||||
"deleteOperatorProfileConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
|
||||
"operatorProfileDeleted": "Perfil de operador eliminado"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Áreas Sin Conexión",
|
||||
"noAreasTitle": "Sin áreas sin conexión",
|
||||
"noAreasSubtitle": "Descarga un área del mapa para uso sin conexión.",
|
||||
"provider": "Proveedor",
|
||||
"maxZoom": "Zoom máx",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Lat",
|
||||
"longitude": "Lon",
|
||||
"tiles": "Teselas",
|
||||
"size": "Tamaño",
|
||||
"nodes": "Nodos",
|
||||
"areaIdFallback": "Área {}...",
|
||||
"renameArea": "Renombrar área",
|
||||
"refreshWorldTiles": "Actualizar/re-descargar teselas mundiales",
|
||||
"deleteOfflineArea": "Eliminar área sin conexión",
|
||||
"cancelDownload": "Cancelar descarga",
|
||||
"renameAreaDialogTitle": "Renombrar Área Sin Conexión",
|
||||
"areaNameLabel": "Nombre del Área",
|
||||
"renameButton": "Renombrar",
|
||||
"megabytes": "MB",
|
||||
"kilobytes": "KB",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Actualizar área",
|
||||
"refreshAreaDialogTitle": "Actualizar Área sin Conexión",
|
||||
"refreshAreaDialogSubtitle": "Elija qué actualizar para esta área:",
|
||||
"refreshTiles": "Actualizar Mosaicos del Mapa",
|
||||
"refreshTilesSubtitle": "Volver a descargar todos los mosaicos para imágenes actualizadas",
|
||||
"refreshNodes": "Actualizar Nodos",
|
||||
"refreshNodesSubtitle": "Volver a obtener datos de nodos para esta área",
|
||||
"startRefresh": "Iniciar Actualización",
|
||||
"refreshStarted": "¡Actualización iniciada!",
|
||||
"refreshFailed": "Actualización falló: {}"
|
||||
},
|
||||
"refineTagsSheet": {
|
||||
"title": "Refinar Etiquetas",
|
||||
"operatorProfile": "Perfil de Operador",
|
||||
"done": "Listo",
|
||||
"none": "Ninguno",
|
||||
"noAdditionalOperatorTags": "Sin etiquetas adicionales de operador",
|
||||
"additionalTags": "etiquetas adicionales",
|
||||
"additionalTagsTitle": "Etiquetas Adicionales",
|
||||
"noTagsDefinedForProfile": "No hay etiquetas definidas para este perfil de operador.",
|
||||
"noOperatorProfiles": "No hay perfiles de operador definidos",
|
||||
"noOperatorProfilesMessage": "Cree perfiles de operador en Configuración para aplicar etiquetas adicionales a sus envíos de nodos."
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "No se pueden cambiar los tipos de teselas mientras se descargan áreas sin conexión",
|
||||
"selectMapLayer": "Seleccionar Capa del Mapa",
|
||||
"noTileProvidersAvailable": "No hay proveedores de teselas disponibles"
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,11 @@
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Marquer Nœud",
|
||||
"tagNode": "Nouveau Nœud",
|
||||
"download": "Télécharger",
|
||||
"settings": "Paramètres",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer",
|
||||
"cancel": "Annuler",
|
||||
"ok": "OK",
|
||||
"close": "Fermer",
|
||||
@@ -28,20 +29,45 @@
|
||||
"systemDefault": "Par Défaut du Système",
|
||||
"aboutInfo": "À Propos / Informations",
|
||||
"aboutThisApp": "À Propos de Cette App",
|
||||
"maxNodes": "Max. nœuds récupérés/dessinés",
|
||||
"aboutSubtitle": "Informations sur l'application et crédits",
|
||||
"languageSubtitle": "Choisissez votre langue préférée",
|
||||
"maxNodes": "Max. nœuds dessinés",
|
||||
"maxNodesSubtitle": "Définir une limite supérieure pour le nombre de nœuds sur la carte (par défaut: 250).",
|
||||
"maxNodesWarning": "Vous ne voulez probablement pas faire cela à moins d'être absolument sûr d'avoir une bonne raison de le faire.",
|
||||
"offlineMode": "Mode Hors Ligne",
|
||||
"offlineModeSubtitle": "Désactiver toutes les requêtes réseau sauf pour les zones locales/hors ligne.",
|
||||
"offlineModeWarningTitle": "Téléchargements Actifs",
|
||||
"offlineModeWarningMessage": "L'activation du mode hors ligne annulera tous les téléchargements de zone actifs. Voulez-vous continuer?",
|
||||
"enableOfflineMode": "Activer le Mode Hors Ligne"
|
||||
"enableOfflineMode": "Activer le Mode Hors Ligne",
|
||||
"profiles": "Profils",
|
||||
"profilesSubtitle": "Gérer les profils de nœuds et d'opérateurs",
|
||||
"offlineSettings": "Paramètres Hors Ligne",
|
||||
"offlineSettingsSubtitle": "Gérer le mode hors ligne et les zones téléchargées",
|
||||
"advancedSettings": "Paramètres Avancés",
|
||||
"advancedSettingsSubtitle": "Paramètres de performance, alertes et fournisseurs de tuiles",
|
||||
"proximityAlerts": "Alertes de Proximité"
|
||||
},
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Recevoir des notifications en s'approchant de dispositifs de surveillance",
|
||||
"batteryUsage": "Utilise de la batterie supplémentaire pour la surveillance continue de la localisation",
|
||||
"notificationsEnabled": "✓ Notifications activées",
|
||||
"notificationsDisabled": "⚠ Notifications désactivées",
|
||||
"permissionRequired": "Autorisation de notification requise",
|
||||
"permissionExplanation": "Les notifications push sont désactivées. Vous ne verrez que des alertes dans l'application et ne serez pas notifié lorsque l'application est en arrière-plan.",
|
||||
"enableNotifications": "Activer les Notifications",
|
||||
"checkingPermissions": "Vérification des autorisations...",
|
||||
"alertDistance": "Distance d'alerte : ",
|
||||
"meters": "mètres",
|
||||
"rangeInfo": "Plage : {}-{} mètres (par défaut : {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nœud #{}",
|
||||
"tagSheetTitle": "Balises du Dispositif",
|
||||
"queuedForUpload": "Nœud mis en file pour envoi",
|
||||
"editQueuedForUpload": "Modification de nœud mise en file pour envoi"
|
||||
"editQueuedForUpload": "Modification de nœud mise en file pour envoi",
|
||||
"deleteQueuedForUpload": "Suppression de nœud mise en file pour envoi",
|
||||
"confirmDeleteTitle": "Supprimer le Nœud",
|
||||
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer le nœud #{} ? Cette action ne peut pas être annulée."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
@@ -75,7 +101,7 @@
|
||||
"withinTileLimit": "Dans la limite de {} tuiles",
|
||||
"exceedsTileLimit": "La sélection actuelle dépasse la limite de {} tuiles",
|
||||
"offlineModeWarning": "Téléchargements désactivés en mode hors ligne. Désactivez le mode hors ligne pour télécharger de nouvelles zones.",
|
||||
"downloadStarted": "Téléchargement démarré! Récupération des tuiles et caméras...",
|
||||
"downloadStarted": "Téléchargement démarré! Récupération des tuiles et nœuds...",
|
||||
"downloadFailed": "Échec du démarrage du téléchargement: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
@@ -86,7 +112,6 @@
|
||||
"simulate": "Simuler",
|
||||
"productionDescription": "Télécharger vers la base de données OSM en direct (visible pour tous les utilisateurs)",
|
||||
"sandboxDescription": "Les téléchargements vont vers le Sandbox OSM (sûr pour les tests, réinitialisé régulièrement).",
|
||||
"sandboxNote": "NOTE: En raison des limitations d'OpenStreetMap, les caméras soumises au sandbox n'apparaîtront PAS sur la carte dans cette application.",
|
||||
"simulateDescription": "Simuler les téléchargements (ne contacte pas les serveurs OSM)"
|
||||
},
|
||||
"auth": {
|
||||
@@ -211,5 +236,56 @@
|
||||
"deleteOperatorProfile": "Supprimer Profil d'Opérateur",
|
||||
"deleteOperatorProfileConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
|
||||
"operatorProfileDeleted": "Profil d'opérateur supprimé"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Zones Hors Ligne",
|
||||
"noAreasTitle": "Aucune zone hors ligne",
|
||||
"noAreasSubtitle": "Téléchargez une zone de carte pour utilisation hors ligne.",
|
||||
"provider": "Fournisseur",
|
||||
"maxZoom": "Zoom max",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Lat",
|
||||
"longitude": "Lon",
|
||||
"tiles": "Tuiles",
|
||||
"size": "Taille",
|
||||
"nodes": "Nœuds",
|
||||
"areaIdFallback": "Zone {}...",
|
||||
"renameArea": "Renommer la zone",
|
||||
"refreshWorldTiles": "Actualiser/re-télécharger les tuiles mondiales",
|
||||
"deleteOfflineArea": "Supprimer la zone hors ligne",
|
||||
"cancelDownload": "Annuler le téléchargement",
|
||||
"renameAreaDialogTitle": "Renommer la Zone Hors Ligne",
|
||||
"areaNameLabel": "Nom de la Zone",
|
||||
"renameButton": "Renommer",
|
||||
"megabytes": "Mo",
|
||||
"kilobytes": "Ko",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Actualiser la zone",
|
||||
"refreshAreaDialogTitle": "Actualiser la Zone Hors Ligne",
|
||||
"refreshAreaDialogSubtitle": "Choisissez quoi actualiser pour cette zone :",
|
||||
"refreshTiles": "Actualiser les Tuiles de Carte",
|
||||
"refreshTilesSubtitle": "Télécharger à nouveau toutes les tuiles pour des images mises à jour",
|
||||
"refreshNodes": "Actualiser les Nœuds",
|
||||
"refreshNodesSubtitle": "Récupérer à nouveau les données de nœuds pour cette zone",
|
||||
"startRefresh": "Démarrer l'Actualisation",
|
||||
"refreshStarted": "Actualisation démarrée !",
|
||||
"refreshFailed": "Actualisation échouée : {}"
|
||||
},
|
||||
"refineTagsSheet": {
|
||||
"title": "Affiner les Étiquettes",
|
||||
"operatorProfile": "Profil d'Opérateur",
|
||||
"done": "Terminé",
|
||||
"none": "Aucun",
|
||||
"noAdditionalOperatorTags": "Aucune étiquette d'opérateur supplémentaire",
|
||||
"additionalTags": "étiquettes supplémentaires",
|
||||
"additionalTagsTitle": "Étiquettes Supplémentaires",
|
||||
"noTagsDefinedForProfile": "Aucune étiquette définie pour ce profil d'opérateur.",
|
||||
"noOperatorProfiles": "Aucun profil d'opérateur défini",
|
||||
"noOperatorProfilesMessage": "Créez des profils d'opérateur dans les Paramètres pour appliquer des étiquettes supplémentaires à vos soumissions de nœuds."
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Impossible de changer les types de tuiles pendant le téléchargement des zones hors ligne",
|
||||
"selectMapLayer": "Sélectionner la Couche de Carte",
|
||||
"noTileProvidersAvailable": "Aucun fournisseur de tuiles disponible"
|
||||
}
|
||||
}
|
||||
291
lib/localizations/it.json
Normal file
@@ -0,0 +1,291 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Italiano"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Nuovo Nodo",
|
||||
"download": "Scarica",
|
||||
"settings": "Impostazioni",
|
||||
"edit": "Modifica",
|
||||
"delete": "Elimina",
|
||||
"cancel": "Annulla",
|
||||
"ok": "OK",
|
||||
"close": "Chiudi",
|
||||
"submit": "Invia",
|
||||
"saveEdit": "Salva Modifica",
|
||||
"clear": "Pulisci"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Attiva seguimi (nord in alto)",
|
||||
"northUp": "Attiva seguimi (rotazione)",
|
||||
"rotating": "Disattiva seguimi"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni",
|
||||
"language": "Lingua",
|
||||
"systemDefault": "Predefinito del Sistema",
|
||||
"aboutInfo": "Informazioni",
|
||||
"aboutThisApp": "Informazioni su questa App",
|
||||
"aboutSubtitle": "Informazioni sull'applicazione e crediti",
|
||||
"languageSubtitle": "Scegli la tua lingua preferita",
|
||||
"maxNodes": "Max nodi disegnati",
|
||||
"maxNodesSubtitle": "Imposta un limite superiore per il numero di nodi sulla mappa (predefinito: 250).",
|
||||
"maxNodesWarning": "Probabilmente non vuoi farlo a meno che non sei assolutamente sicuro di avere una buona ragione per farlo.",
|
||||
"offlineMode": "Modalità Offline",
|
||||
"offlineModeSubtitle": "Disabilita tutte le richieste di rete tranne per aree locali/offline.",
|
||||
"offlineModeWarningTitle": "Download Attivi",
|
||||
"offlineModeWarningMessage": "L'attivazione della modalità offline cancellerà qualsiasi download di area attivo. Vuoi continuare?",
|
||||
"enableOfflineMode": "Attiva Modalità Offline",
|
||||
"profiles": "Profili",
|
||||
"profilesSubtitle": "Gestisci profili di nodi e operatori",
|
||||
"offlineSettings": "Impostazioni Offline",
|
||||
"offlineSettingsSubtitle": "Gestisci modalità offline e aree scaricate",
|
||||
"advancedSettings": "Impostazioni Avanzate",
|
||||
"advancedSettingsSubtitle": "Impostazioni di prestazioni, avvisi e fornitori di tessere",
|
||||
"proximityAlerts": "Avvisi di Prossimità"
|
||||
},
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Ricevi notifiche quando ti avvicini a dispositivi di sorveglianza",
|
||||
"batteryUsage": "Utilizza batteria extra per il monitoraggio continuo della posizione",
|
||||
"notificationsEnabled": "✓ Notifiche abilitate",
|
||||
"notificationsDisabled": "⚠ Notifiche disabilitate",
|
||||
"permissionRequired": "Autorizzazione notifica richiesta",
|
||||
"permissionExplanation": "Le notifiche push sono disabilitate. Vedrai solo avvisi nell'app e non sarai notificato quando l'app è in background.",
|
||||
"enableNotifications": "Abilita Notifiche",
|
||||
"checkingPermissions": "Controllo autorizzazioni...",
|
||||
"alertDistance": "Distanza di avviso: ",
|
||||
"meters": "metri",
|
||||
"rangeInfo": "Intervallo: {}-{} metri (predefinito: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nodo #{}",
|
||||
"tagSheetTitle": "Tag Dispositivo di Sorveglianza",
|
||||
"queuedForUpload": "Nodo in coda per il caricamento",
|
||||
"editQueuedForUpload": "Modifica nodo in coda per il caricamento",
|
||||
"deleteQueuedForUpload": "Eliminazione nodo in coda per il caricamento",
|
||||
"confirmDeleteTitle": "Elimina Nodo",
|
||||
"confirmDeleteMessage": "Sei sicuro di voler eliminare il nodo #{}? Questa azione non può essere annullata."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profilo",
|
||||
"direction": "Direzione {}°",
|
||||
"profileNoDirectionInfo": "Questo profilo non richiede una direzione.",
|
||||
"mustBeLoggedIn": "Devi essere loggato per inviare nuovi nodi. Per favore accedi tramite Impostazioni.",
|
||||
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per inviare nuovi nodi.",
|
||||
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per inviare nuovi nodi.",
|
||||
"refineTags": "Affina Tag",
|
||||
"refineTagsWithProfile": "Affina Tag ({})"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Modifica Nodo #{}",
|
||||
"profile": "Profilo",
|
||||
"direction": "Direzione {}°",
|
||||
"profileNoDirectionInfo": "Questo profilo non richiede una direzione.",
|
||||
"mustBeLoggedIn": "Devi essere loggato per modificare i nodi. Per favore accedi tramite Impostazioni.",
|
||||
"sandboxModeWarning": "Impossibile inviare modifiche di nodi di produzione alla sandbox. Passa alla modalità Produzione nelle Impostazioni per modificare i nodi.",
|
||||
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per modificare i nodi.",
|
||||
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per modificare i nodi.",
|
||||
"refineTags": "Affina Tag",
|
||||
"refineTagsWithProfile": "Affina Tag ({})"
|
||||
},
|
||||
"download": {
|
||||
"title": "Scarica Area Mappa",
|
||||
"maxZoomLevel": "Livello zoom max",
|
||||
"storageEstimate": "Stima archiviazione:",
|
||||
"tilesAndSize": "{} tile, {} MB",
|
||||
"minZoom": "Zoom min:",
|
||||
"maxRecommendedZoom": "Zoom max raccomandato: Z{}",
|
||||
"withinTileLimit": "Entro il limite di {} tile",
|
||||
"exceedsTileLimit": "La selezione corrente supera il limite di {} tile",
|
||||
"offlineModeWarning": "Download disabilitati in modalità offline. Disabilita la modalità offline per scaricare nuove aree.",
|
||||
"downloadStarted": "Download avviato! Recupero tile e nodi...",
|
||||
"downloadFailed": "Impossibile avviare il download: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destinazione Upload",
|
||||
"subtitle": "Scegli dove vengono caricate le telecamere",
|
||||
"production": "Produzione",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simula",
|
||||
"productionDescription": "Carica nel database OSM dal vivo (visibile a tutti gli utenti)",
|
||||
"sandboxDescription": "Gli upload vanno alla Sandbox OSM (sicuro per i test, si resetta regolarmente).",
|
||||
"simulateDescription": "Simula upload (non contatta i server OSM)"
|
||||
},
|
||||
"auth": {
|
||||
"loggedInAs": "Loggato come {}",
|
||||
"loginToOSM": "Accedi a OpenStreetMap",
|
||||
"tapToLogout": "Tocca per disconnetterti",
|
||||
"requiredToSubmit": "Richiesto per inviare dati delle telecamere",
|
||||
"loggedOut": "Disconnesso",
|
||||
"testConnection": "Testa Connessione",
|
||||
"testConnectionSubtitle": "Verifica che le credenziali OSM funzionino",
|
||||
"connectionOK": "Connessione OK - le credenziali sono valide",
|
||||
"connectionFailed": "Connessione fallita - per favore accedi di nuovo"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Upload in sospeso: {}",
|
||||
"simulateModeEnabled": "Modalità simulazione abilitata – upload simulati",
|
||||
"sandboxMode": "Modalità sandbox – upload vanno alla Sandbox OSM",
|
||||
"tapToViewQueue": "Tocca per vedere la coda",
|
||||
"clearUploadQueue": "Pulisci Coda Upload",
|
||||
"removeAllPending": "Rimuovi tutti i {} upload in sospeso",
|
||||
"clearQueueTitle": "Pulisci Coda",
|
||||
"clearQueueConfirm": "Rimuovere tutti i {} upload in sospeso?",
|
||||
"queueCleared": "Coda pulita",
|
||||
"uploadQueueTitle": "Coda Upload ({} elementi)",
|
||||
"queueIsEmpty": "La coda è vuota",
|
||||
"cameraWithIndex": "Telecamera {}",
|
||||
"error": " (Errore)",
|
||||
"completing": " (Completamento...)",
|
||||
"destination": "Dest: {}",
|
||||
"latitude": "Lat: {}",
|
||||
"longitude": "Lon: {}",
|
||||
"direction": "Direzione: {}°",
|
||||
"attempts": "Tentativi: {}",
|
||||
"uploadFailedRetry": "Upload fallito. Tocca riprova per tentare di nuovo.",
|
||||
"retryUpload": "Riprova upload",
|
||||
"clearAll": "Pulisci Tutto"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Fornitori di Tile",
|
||||
"noProvidersConfigured": "Nessun fornitore di tile configurato",
|
||||
"tileTypesCount": "{} tipi di tile",
|
||||
"apiKeyConfigured": "Chiave API configurata",
|
||||
"needsApiKey": "Richiede chiave API",
|
||||
"editProvider": "Modifica Fornitore",
|
||||
"addProvider": "Aggiungi Fornitore",
|
||||
"deleteProvider": "Elimina Fornitore",
|
||||
"deleteProviderConfirm": "Sei sicuro di voler eliminare \"{}\"?",
|
||||
"providerName": "Nome Fornitore",
|
||||
"providerNameHint": "es., Mappe Personalizzate Inc.",
|
||||
"providerNameRequired": "Il nome del fornitore è obbligatorio",
|
||||
"apiKey": "Chiave API (Opzionale)",
|
||||
"apiKeyHint": "Inserisci la chiave API se richiesta dai tipi di tile",
|
||||
"tileTypes": "Tipi di Tile",
|
||||
"addType": "Aggiungi Tipo",
|
||||
"noTileTypesConfigured": "Nessun tipo di tile configurato",
|
||||
"atLeastOneTileTypeRequired": "È richiesto almeno un tipo di tile",
|
||||
"manageTileProviders": "Gestisci Fornitori"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Modifica Tipo Tile",
|
||||
"addTileType": "Aggiungi Tipo Tile",
|
||||
"name": "Nome",
|
||||
"nameHint": "es., Satellite",
|
||||
"nameRequired": "Il nome è obbligatorio",
|
||||
"urlTemplate": "Template URL",
|
||||
"urlTemplateHint": "https://esempio.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "Il template URL è obbligatorio",
|
||||
"urlTemplatePlaceholders": "L'URL deve contenere i segnaposto {z}, {x} e {y}",
|
||||
"attribution": "Attribuzione",
|
||||
"attributionHint": "© Fornitore Mappe",
|
||||
"attributionRequired": "L'attribuzione è obbligatoria",
|
||||
"fetchPreview": "Ottieni Anteprima",
|
||||
"previewTileLoaded": "Tile di anteprima caricato con successo",
|
||||
"previewTileFailed": "Impossibile ottenere l'anteprima: {}",
|
||||
"save": "Salva"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Profili Nodo",
|
||||
"newProfile": "Nuovo Profilo",
|
||||
"builtIn": "Integrato",
|
||||
"custom": "Personalizzato",
|
||||
"view": "Visualizza",
|
||||
"deleteProfile": "Elimina Profilo",
|
||||
"deleteProfileConfirm": "Sei sicuro di voler eliminare \"{}\"?",
|
||||
"profileDeleted": "Profilo eliminato"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tile Mappa",
|
||||
"manageProviders": "Gestisci Fornitori"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Visualizza Profilo",
|
||||
"newProfile": "Nuovo Profilo",
|
||||
"editProfile": "Modifica Profilo",
|
||||
"profileName": "Nome profilo",
|
||||
"profileNameHint": "es., Telecamera ALPR Personalizzata",
|
||||
"profileNameRequired": "Il nome del profilo è obbligatorio",
|
||||
"requiresDirection": "Richiede Direzione",
|
||||
"requiresDirectionSubtitle": "Se le telecamere di questo tipo necessitano di un tag direzione",
|
||||
"submittable": "Inviabile",
|
||||
"submittableSubtitle": "Se questo profilo può essere usato per invii di telecamere",
|
||||
"osmTags": "Tag OSM",
|
||||
"addTag": "Aggiungi Tag",
|
||||
"saveProfile": "Salva Profilo",
|
||||
"keyHint": "chiave",
|
||||
"valueHint": "valore",
|
||||
"atLeastOneTagRequired": "È richiesto almeno un tag",
|
||||
"profileSaved": "Profilo \"{}\" salvato"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nuovo Profilo Operatore",
|
||||
"editOperatorProfile": "Modifica Profilo Operatore",
|
||||
"operatorName": "Nome operatore",
|
||||
"operatorNameHint": "es., Dipartimento di Polizia di Austin",
|
||||
"operatorNameRequired": "Il nome dell'operatore è obbligatorio",
|
||||
"operatorProfileSaved": "Profilo operatore \"{}\" salvato"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Profili Operatore",
|
||||
"noProfilesMessage": "Nessun profilo operatore definito. Creane uno per applicare tag operatore agli invii di nodi.",
|
||||
"tagsCount": "{} tag",
|
||||
"deleteOperatorProfile": "Elimina Profilo Operatore",
|
||||
"deleteOperatorProfileConfirm": "Sei sicuro di voler eliminare \"{}\"?",
|
||||
"operatorProfileDeleted": "Profilo operatore eliminato"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Aree Offline",
|
||||
"noAreasTitle": "Nessuna area offline",
|
||||
"noAreasSubtitle": "Scarica un'area mappa per l'uso offline.",
|
||||
"provider": "Fornitore",
|
||||
"maxZoom": "Zoom max",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Lat",
|
||||
"longitude": "Lon",
|
||||
"tiles": "Tile",
|
||||
"size": "Dimensione",
|
||||
"nodes": "Nodi",
|
||||
"areaIdFallback": "Area {}...",
|
||||
"renameArea": "Rinomina area",
|
||||
"refreshWorldTiles": "Aggiorna/ri-scarica tile mondiali",
|
||||
"deleteOfflineArea": "Elimina area offline",
|
||||
"cancelDownload": "Annulla download",
|
||||
"renameAreaDialogTitle": "Rinomina Area Offline",
|
||||
"areaNameLabel": "Nome Area",
|
||||
"renameButton": "Rinomina",
|
||||
"megabytes": "MB",
|
||||
"kilobytes": "KB",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Aggiorna area",
|
||||
"refreshAreaDialogTitle": "Aggiorna Area Offline",
|
||||
"refreshAreaDialogSubtitle": "Scegli cosa aggiornare per quest'area:",
|
||||
"refreshTiles": "Aggiorna Tile Mappa",
|
||||
"refreshTilesSubtitle": "Riscarica tutte le tile per immagini aggiornate",
|
||||
"refreshNodes": "Aggiorna Nodi",
|
||||
"refreshNodesSubtitle": "Ricarica i dati dei nodi per quest'area",
|
||||
"startRefresh": "Avvia Aggiornamento",
|
||||
"refreshStarted": "Aggiornamento avviato!",
|
||||
"refreshFailed": "Aggiornamento fallito: {}"
|
||||
},
|
||||
"refineTagsSheet": {
|
||||
"title": "Affina Tag",
|
||||
"operatorProfile": "Profilo Operatore",
|
||||
"done": "Fatto",
|
||||
"none": "Nessuno",
|
||||
"noAdditionalOperatorTags": "Nessun tag operatore aggiuntivo",
|
||||
"additionalTags": "tag aggiuntivi",
|
||||
"additionalTagsTitle": "Tag Aggiuntivi",
|
||||
"noTagsDefinedForProfile": "Nessun tag definito per questo profilo operatore.",
|
||||
"noOperatorProfiles": "Nessun profilo operatore definito",
|
||||
"noOperatorProfilesMessage": "Crea profili operatore nelle Impostazioni per applicare tag aggiuntivi ai tuoi invii di nodi."
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Impossibile cambiare tipi di tile durante il download di aree offline",
|
||||
"selectMapLayer": "Seleziona Livello Mappa",
|
||||
"noTileProvidersAvailable": "Nessun fornitore di tile disponibile"
|
||||
}
|
||||
}
|
||||
291
lib/localizations/pt.json
Normal file
@@ -0,0 +1,291 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Português"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Novo Nó",
|
||||
"download": "Baixar",
|
||||
"settings": "Configurações",
|
||||
"edit": "Editar",
|
||||
"delete": "Excluir",
|
||||
"cancel": "Cancelar",
|
||||
"ok": "OK",
|
||||
"close": "Fechar",
|
||||
"submit": "Enviar",
|
||||
"saveEdit": "Salvar Edição",
|
||||
"clear": "Limpar"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Ativar seguir-me (norte para cima)",
|
||||
"northUp": "Ativar seguir-me (rotação)",
|
||||
"rotating": "Desativar seguir-me"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configurações",
|
||||
"language": "Idioma",
|
||||
"systemDefault": "Padrão do Sistema",
|
||||
"aboutInfo": "Sobre / Informações",
|
||||
"aboutThisApp": "Sobre este App",
|
||||
"aboutSubtitle": "Informações do aplicativo e créditos",
|
||||
"languageSubtitle": "Escolha seu idioma preferido",
|
||||
"maxNodes": "Máx. de nós desenhados",
|
||||
"maxNodesSubtitle": "Definir um limite superior para o número de nós no mapa (padrão: 250).",
|
||||
"maxNodesWarning": "Você provavelmente não quer fazer isso a menos que tenha certeza absoluta de que tem uma boa razão para isso.",
|
||||
"offlineMode": "Modo Offline",
|
||||
"offlineModeSubtitle": "Desabilitar todas as requisições de rede exceto para áreas locais/offline.",
|
||||
"offlineModeWarningTitle": "Downloads Ativos",
|
||||
"offlineModeWarningMessage": "Ativar o modo offline cancelará qualquer download de área ativo. Deseja continuar?",
|
||||
"enableOfflineMode": "Ativar Modo Offline",
|
||||
"profiles": "Perfis",
|
||||
"profilesSubtitle": "Gerenciar perfis de nós e operadores",
|
||||
"offlineSettings": "Configurações Offline",
|
||||
"offlineSettingsSubtitle": "Gerenciar modo offline e áreas baixadas",
|
||||
"advancedSettings": "Configurações Avançadas",
|
||||
"advancedSettingsSubtitle": "Configurações de desempenho, alertas e provedores de mapas",
|
||||
"proximityAlerts": "Alertas de Proximidade"
|
||||
},
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Receba notificações ao se aproximar de dispositivos de vigilância",
|
||||
"batteryUsage": "Usa bateria extra para monitoramento contínuo de localização",
|
||||
"notificationsEnabled": "✓ Notificações habilitadas",
|
||||
"notificationsDisabled": "⚠ Notificações desabilitadas",
|
||||
"permissionRequired": "Permissão de notificação necessária",
|
||||
"permissionExplanation": "Notificações push estão desabilitadas. Você só verá alertas dentro do app e não será notificado quando o app estiver em segundo plano.",
|
||||
"enableNotifications": "Habilitar Notificações",
|
||||
"checkingPermissions": "Verificando permissões...",
|
||||
"alertDistance": "Distância de alerta: ",
|
||||
"meters": "metros",
|
||||
"rangeInfo": "Faixa: {}-{} metros (padrão: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nó #{}",
|
||||
"tagSheetTitle": "Tags do Dispositivo de Vigilância",
|
||||
"queuedForUpload": "Nó na fila para envio",
|
||||
"editQueuedForUpload": "Edição de nó na fila para envio",
|
||||
"deleteQueuedForUpload": "Exclusão de nó na fila para envio",
|
||||
"confirmDeleteTitle": "Excluir Nó",
|
||||
"confirmDeleteMessage": "Tem certeza de que deseja excluir o nó #{}? Esta ação não pode ser desfeita."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Perfil",
|
||||
"direction": "Direção {}°",
|
||||
"profileNoDirectionInfo": "Este perfil não requer uma direção.",
|
||||
"mustBeLoggedIn": "Você deve estar logado para enviar novos nós. Por favor, faça login via Configurações.",
|
||||
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para enviar novos nós.",
|
||||
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para enviar novos nós.",
|
||||
"refineTags": "Refinar Tags",
|
||||
"refineTagsWithProfile": "Refinar Tags ({})"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Editar Nó #{}",
|
||||
"profile": "Perfil",
|
||||
"direction": "Direção {}°",
|
||||
"profileNoDirectionInfo": "Este perfil não requer uma direção.",
|
||||
"mustBeLoggedIn": "Você deve estar logado para editar nós. Por favor, faça login via Configurações.",
|
||||
"sandboxModeWarning": "Não é possível enviar edições de nós de produção para o sandbox. Mude para o modo Produção nas Configurações para editar nós.",
|
||||
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para editar nós.",
|
||||
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para editar nós.",
|
||||
"refineTags": "Refinar Tags",
|
||||
"refineTagsWithProfile": "Refinar Tags ({})"
|
||||
},
|
||||
"download": {
|
||||
"title": "Baixar Área do Mapa",
|
||||
"maxZoomLevel": "Nível máx. de zoom",
|
||||
"storageEstimate": "Estimativa de armazenamento:",
|
||||
"tilesAndSize": "{} tiles, {} MB",
|
||||
"minZoom": "Zoom mín.:",
|
||||
"maxRecommendedZoom": "Zoom máx. recomendado: Z{}",
|
||||
"withinTileLimit": "Dentro do limite de {} tiles",
|
||||
"exceedsTileLimit": "A seleção atual excede o limite de {} tiles",
|
||||
"offlineModeWarning": "Downloads desabilitados no modo offline. Desative o modo offline para baixar novas áreas.",
|
||||
"downloadStarted": "Download iniciado! Buscando tiles e nós...",
|
||||
"downloadFailed": "Falha ao iniciar o download: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destino do Upload",
|
||||
"subtitle": "Escolha onde as câmeras são enviadas",
|
||||
"production": "Produção",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simular",
|
||||
"productionDescription": "Enviar para o banco de dados OSM ao vivo (visível para todos os usuários)",
|
||||
"sandboxDescription": "Uploads vão para o Sandbox OSM (seguro para testes, redefine regularmente).",
|
||||
"simulateDescription": "Simular uploads (não contacta servidores OSM)"
|
||||
},
|
||||
"auth": {
|
||||
"loggedInAs": "Logado como {}",
|
||||
"loginToOSM": "Fazer login no OpenStreetMap",
|
||||
"tapToLogout": "Toque para sair",
|
||||
"requiredToSubmit": "Necessário para enviar dados de câmeras",
|
||||
"loggedOut": "Deslogado",
|
||||
"testConnection": "Testar Conexão",
|
||||
"testConnectionSubtitle": "Verificar se as credenciais OSM estão funcionando",
|
||||
"connectionOK": "Conexão OK - credenciais são válidas",
|
||||
"connectionFailed": "Conexão falhou - por favor, faça login novamente"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Uploads pendentes: {}",
|
||||
"simulateModeEnabled": "Modo simulação ativado – uploads simulados",
|
||||
"sandboxMode": "Modo sandbox – uploads vão para o Sandbox OSM",
|
||||
"tapToViewQueue": "Toque para ver a fila",
|
||||
"clearUploadQueue": "Limpar Fila de Upload",
|
||||
"removeAllPending": "Remover todos os {} uploads pendentes",
|
||||
"clearQueueTitle": "Limpar Fila",
|
||||
"clearQueueConfirm": "Remover todos os {} uploads pendentes?",
|
||||
"queueCleared": "Fila limpa",
|
||||
"uploadQueueTitle": "Fila de Upload ({} itens)",
|
||||
"queueIsEmpty": "A fila está vazia",
|
||||
"cameraWithIndex": "Câmera {}",
|
||||
"error": " (Erro)",
|
||||
"completing": " (Completando...)",
|
||||
"destination": "Dest: {}",
|
||||
"latitude": "Lat: {}",
|
||||
"longitude": "Lon: {}",
|
||||
"direction": "Direção: {}°",
|
||||
"attempts": "Tentativas: {}",
|
||||
"uploadFailedRetry": "Upload falhou. Toque em tentar novamente para tentar novamente.",
|
||||
"retryUpload": "Tentar upload novamente",
|
||||
"clearAll": "Limpar Tudo"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Provedores de Tiles",
|
||||
"noProvidersConfigured": "Nenhum provedor de tiles configurado",
|
||||
"tileTypesCount": "{} tipos de tiles",
|
||||
"apiKeyConfigured": "Chave API configurada",
|
||||
"needsApiKey": "Precisa de chave API",
|
||||
"editProvider": "Editar Provedor",
|
||||
"addProvider": "Adicionar Provedor",
|
||||
"deleteProvider": "Excluir Provedor",
|
||||
"deleteProviderConfirm": "Tem certeza de que deseja excluir \"{}\"?",
|
||||
"providerName": "Nome do Provedor",
|
||||
"providerNameHint": "ex., Mapas Personalizados Inc.",
|
||||
"providerNameRequired": "Nome do provedor é obrigatório",
|
||||
"apiKey": "Chave API (Opcional)",
|
||||
"apiKeyHint": "Insira a chave API se necessária pelos tipos de tiles",
|
||||
"tileTypes": "Tipos de Tiles",
|
||||
"addType": "Adicionar Tipo",
|
||||
"noTileTypesConfigured": "Nenhum tipo de tile configurado",
|
||||
"atLeastOneTileTypeRequired": "Pelo menos um tipo de tile é obrigatório",
|
||||
"manageTileProviders": "Gerenciar Provedores"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Editar Tipo de Tile",
|
||||
"addTileType": "Adicionar Tipo de Tile",
|
||||
"name": "Nome",
|
||||
"nameHint": "ex., Satélite",
|
||||
"nameRequired": "Nome é obrigatório",
|
||||
"urlTemplate": "Modelo de URL",
|
||||
"urlTemplateHint": "https://exemplo.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "Modelo de URL é obrigatório",
|
||||
"urlTemplatePlaceholders": "URL deve conter os marcadores {z}, {x} e {y}",
|
||||
"attribution": "Atribuição",
|
||||
"attributionHint": "© Provedor de Mapas",
|
||||
"attributionRequired": "Atribuição é obrigatória",
|
||||
"fetchPreview": "Buscar Preview",
|
||||
"previewTileLoaded": "Tile de preview carregado com sucesso",
|
||||
"previewTileFailed": "Falha ao buscar preview: {}",
|
||||
"save": "Salvar"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Perfis de Nó",
|
||||
"newProfile": "Novo Perfil",
|
||||
"builtIn": "Integrado",
|
||||
"custom": "Personalizado",
|
||||
"view": "Ver",
|
||||
"deleteProfile": "Excluir Perfil",
|
||||
"deleteProfileConfirm": "Tem certeza de que deseja excluir \"{}\"?",
|
||||
"profileDeleted": "Perfil excluído"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tiles do Mapa",
|
||||
"manageProviders": "Gerenciar Provedores"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Ver Perfil",
|
||||
"newProfile": "Novo Perfil",
|
||||
"editProfile": "Editar Perfil",
|
||||
"profileName": "Nome do perfil",
|
||||
"profileNameHint": "ex., Câmera ALPR Personalizada",
|
||||
"profileNameRequired": "Nome do perfil é obrigatório",
|
||||
"requiresDirection": "Requer Direção",
|
||||
"requiresDirectionSubtitle": "Se câmeras deste tipo precisam de uma tag de direção",
|
||||
"submittable": "Enviável",
|
||||
"submittableSubtitle": "Se este perfil pode ser usado para envios de câmeras",
|
||||
"osmTags": "Tags OSM",
|
||||
"addTag": "Adicionar Tag",
|
||||
"saveProfile": "Salvar Perfil",
|
||||
"keyHint": "chave",
|
||||
"valueHint": "valor",
|
||||
"atLeastOneTagRequired": "Pelo menos uma tag é obrigatória",
|
||||
"profileSaved": "Perfil \"{}\" salvo"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Novo Perfil de Operador",
|
||||
"editOperatorProfile": "Editar Perfil de Operador",
|
||||
"operatorName": "Nome do operador",
|
||||
"operatorNameHint": "ex., Departamento de Polícia de Austin",
|
||||
"operatorNameRequired": "Nome do operador é obrigatório",
|
||||
"operatorProfileSaved": "Perfil de operador \"{}\" salvo"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Perfis de Operador",
|
||||
"noProfilesMessage": "Nenhum perfil de operador definido. Crie um para aplicar tags de operador aos envios de nós.",
|
||||
"tagsCount": "{} tags",
|
||||
"deleteOperatorProfile": "Excluir Perfil de Operador",
|
||||
"deleteOperatorProfileConfirm": "Tem certeza de que deseja excluir \"{}\"?",
|
||||
"operatorProfileDeleted": "Perfil de operador excluído"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Áreas Offline",
|
||||
"noAreasTitle": "Nenhuma área offline",
|
||||
"noAreasSubtitle": "Baixe uma área do mapa para uso offline.",
|
||||
"provider": "Provedor",
|
||||
"maxZoom": "Zoom máx",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Lat",
|
||||
"longitude": "Lon",
|
||||
"tiles": "Tiles",
|
||||
"size": "Tamanho",
|
||||
"nodes": "Nós",
|
||||
"areaIdFallback": "Área {}...",
|
||||
"renameArea": "Renomear área",
|
||||
"refreshWorldTiles": "Atualizar/rebaixar tiles mundiais",
|
||||
"deleteOfflineArea": "Excluir área offline",
|
||||
"cancelDownload": "Cancelar download",
|
||||
"renameAreaDialogTitle": "Renomear Área Offline",
|
||||
"areaNameLabel": "Nome da Área",
|
||||
"renameButton": "Renomear",
|
||||
"megabytes": "MB",
|
||||
"kilobytes": "KB",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Atualizar área",
|
||||
"refreshAreaDialogTitle": "Atualizar Área Offline",
|
||||
"refreshAreaDialogSubtitle": "Escolha o que atualizar para esta área:",
|
||||
"refreshTiles": "Atualizar Tiles do Mapa",
|
||||
"refreshTilesSubtitle": "Baixar novamente todos os tiles para imagens atualizadas",
|
||||
"refreshNodes": "Atualizar Nós",
|
||||
"refreshNodesSubtitle": "Buscar novamente os dados dos nós para esta área",
|
||||
"startRefresh": "Iniciar Atualização",
|
||||
"refreshStarted": "Atualização iniciada!",
|
||||
"refreshFailed": "Atualização falhou: {}"
|
||||
},
|
||||
"refineTagsSheet": {
|
||||
"title": "Refinar Tags",
|
||||
"operatorProfile": "Perfil de Operador",
|
||||
"done": "Concluído",
|
||||
"none": "Nenhum",
|
||||
"noAdditionalOperatorTags": "Nenhuma tag adicional de operador",
|
||||
"additionalTags": "tags adicionais",
|
||||
"additionalTagsTitle": "Tags Adicionais",
|
||||
"noTagsDefinedForProfile": "Nenhuma tag definida para este perfil de operador.",
|
||||
"noOperatorProfiles": "Nenhum perfil de operador definido",
|
||||
"noOperatorProfilesMessage": "Crie perfis de operador nas Configurações para aplicar tags adicionais aos seus envios de nós."
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Não é possível alterar tipos de tiles durante o download de áreas offline",
|
||||
"selectMapLayer": "Selecionar Camada do Mapa",
|
||||
"noTileProvidersAvailable": "Nenhum provedor de tiles disponível"
|
||||
}
|
||||
}
|
||||
291
lib/localizations/zh.json
Normal file
@@ -0,0 +1,291 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "中文"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "新建节点",
|
||||
"download": "下载",
|
||||
"settings": "设置",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"cancel": "取消",
|
||||
"ok": "确定",
|
||||
"close": "关闭",
|
||||
"submit": "提交",
|
||||
"saveEdit": "保存编辑",
|
||||
"clear": "清空"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "启用跟随模式(北向上)",
|
||||
"northUp": "启用跟随模式(旋转)",
|
||||
"rotating": "禁用跟随模式"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"language": "语言",
|
||||
"systemDefault": "系统默认",
|
||||
"aboutInfo": "关于 / 信息",
|
||||
"aboutThisApp": "关于此应用",
|
||||
"aboutSubtitle": "应用程序信息和鸣谢",
|
||||
"languageSubtitle": "选择您的首选语言",
|
||||
"maxNodes": "最大节点绘制数",
|
||||
"maxNodesSubtitle": "设置地图上节点数量的上限(默认:250)。",
|
||||
"maxNodesWarning": "除非您确定有充分的理由,否则您可能不想这样做。",
|
||||
"offlineMode": "离线模式",
|
||||
"offlineModeSubtitle": "禁用除本地/离线区域外的所有网络请求。",
|
||||
"offlineModeWarningTitle": "活动下载",
|
||||
"offlineModeWarningMessage": "启用离线模式将取消任何活动的区域下载。您要继续吗?",
|
||||
"enableOfflineMode": "启用离线模式",
|
||||
"profiles": "配置文件",
|
||||
"profilesSubtitle": "管理节点和操作员配置文件",
|
||||
"offlineSettings": "离线设置",
|
||||
"offlineSettingsSubtitle": "管理离线模式和已下载区域",
|
||||
"advancedSettings": "高级设置",
|
||||
"advancedSettingsSubtitle": "性能、警报和地图提供商设置",
|
||||
"proximityAlerts": "邻近警报"
|
||||
},
|
||||
"proximityAlerts": {
|
||||
"getNotified": "接近监控设备时接收通知",
|
||||
"batteryUsage": "使用额外电量进行连续位置监控",
|
||||
"notificationsEnabled": "✓ 通知已启用",
|
||||
"notificationsDisabled": "⚠ 通知已禁用",
|
||||
"permissionRequired": "需要通知权限",
|
||||
"permissionExplanation": "推送通知已禁用。您只会看到应用内警报,当应用在后台时不会收到通知。",
|
||||
"enableNotifications": "启用通知",
|
||||
"checkingPermissions": "检查权限中...",
|
||||
"alertDistance": "警报距离:",
|
||||
"meters": "米",
|
||||
"rangeInfo": "范围:{}-{} 米(默认:{})"
|
||||
},
|
||||
"node": {
|
||||
"title": "节点 #{}",
|
||||
"tagSheetTitle": "监控设备标签",
|
||||
"queuedForUpload": "节点已排队上传",
|
||||
"editQueuedForUpload": "节点编辑已排队上传",
|
||||
"deleteQueuedForUpload": "节点删除已排队上传",
|
||||
"confirmDeleteTitle": "删除节点",
|
||||
"confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。"
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "配置文件",
|
||||
"direction": "方向 {}°",
|
||||
"profileNoDirectionInfo": "此配置文件不需要方向。",
|
||||
"mustBeLoggedIn": "您必须登录才能提交新节点。请通过设置登录。",
|
||||
"enableSubmittableProfile": "在设置中启用可提交的配置文件以提交新节点。",
|
||||
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来提交新节点。",
|
||||
"refineTags": "细化标签",
|
||||
"refineTagsWithProfile": "细化标签({})"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "编辑节点 #{}",
|
||||
"profile": "配置文件",
|
||||
"direction": "方向 {}°",
|
||||
"profileNoDirectionInfo": "此配置文件不需要方向。",
|
||||
"mustBeLoggedIn": "您必须登录才能编辑节点。请通过设置登录。",
|
||||
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
|
||||
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
|
||||
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
|
||||
"refineTags": "细化标签",
|
||||
"refineTagsWithProfile": "细化标签({})"
|
||||
},
|
||||
"download": {
|
||||
"title": "下载地图区域",
|
||||
"maxZoomLevel": "最大缩放级别",
|
||||
"storageEstimate": "存储估算:",
|
||||
"tilesAndSize": "{} 瓦片,{} MB",
|
||||
"minZoom": "最小缩放:",
|
||||
"maxRecommendedZoom": "最大推荐缩放:Z{}",
|
||||
"withinTileLimit": "在 {} 瓦片限制内",
|
||||
"exceedsTileLimit": "当前选择超出 {} 瓦片限制",
|
||||
"offlineModeWarning": "离线模式下禁用下载。禁用离线模式以下载新区域。",
|
||||
"downloadStarted": "下载已开始!正在获取瓦片和节点...",
|
||||
"downloadFailed": "启动下载失败:{}"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "上传目标",
|
||||
"subtitle": "选择摄像头上传位置",
|
||||
"production": "生产环境",
|
||||
"sandbox": "沙盒",
|
||||
"simulate": "模拟",
|
||||
"productionDescription": "上传到实时 OSM 数据库(对所有用户可见)",
|
||||
"sandboxDescription": "上传到 OSM 沙盒(测试安全,定期重置)。",
|
||||
"simulateDescription": "模拟上传(不联系 OSM 服务器)"
|
||||
},
|
||||
"auth": {
|
||||
"loggedInAs": "已登录为 {}",
|
||||
"loginToOSM": "登录 OpenStreetMap",
|
||||
"tapToLogout": "点击登出",
|
||||
"requiredToSubmit": "提交摄像头数据所需",
|
||||
"loggedOut": "已登出",
|
||||
"testConnection": "测试连接",
|
||||
"testConnectionSubtitle": "验证 OSM 凭据是否有效",
|
||||
"connectionOK": "连接正常 - 凭据有效",
|
||||
"connectionFailed": "连接失败 - 请重新登录"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "待上传:{}",
|
||||
"simulateModeEnabled": "模拟模式已启用 – 上传已模拟",
|
||||
"sandboxMode": "沙盒模式 – 上传到 OSM 沙盒",
|
||||
"tapToViewQueue": "点击查看队列",
|
||||
"clearUploadQueue": "清空上传队列",
|
||||
"removeAllPending": "移除所有 {} 个待上传项",
|
||||
"clearQueueTitle": "清空队列",
|
||||
"clearQueueConfirm": "移除所有 {} 个待上传项?",
|
||||
"queueCleared": "队列已清空",
|
||||
"uploadQueueTitle": "上传队列({} 项)",
|
||||
"queueIsEmpty": "队列为空",
|
||||
"cameraWithIndex": "摄像头 {}",
|
||||
"error": "(错误)",
|
||||
"completing": "(完成中...)",
|
||||
"destination": "目标:{}",
|
||||
"latitude": "纬度:{}",
|
||||
"longitude": "经度:{}",
|
||||
"direction": "方向:{}°",
|
||||
"attempts": "尝试次数:{}",
|
||||
"uploadFailedRetry": "上传失败。点击重试再次尝试。",
|
||||
"retryUpload": "重试上传",
|
||||
"clearAll": "全部清空"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "瓦片提供商",
|
||||
"noProvidersConfigured": "未配置瓦片提供商",
|
||||
"tileTypesCount": "{} 种瓦片类型",
|
||||
"apiKeyConfigured": "API 密钥已配置",
|
||||
"needsApiKey": "需要 API 密钥",
|
||||
"editProvider": "编辑提供商",
|
||||
"addProvider": "添加提供商",
|
||||
"deleteProvider": "删除提供商",
|
||||
"deleteProviderConfirm": "您确定要删除 \"{}\" 吗?",
|
||||
"providerName": "提供商名称",
|
||||
"providerNameHint": "例如,自定义地图公司",
|
||||
"providerNameRequired": "提供商名称为必填项",
|
||||
"apiKey": "API 密钥(可选)",
|
||||
"apiKeyHint": "如果瓦片类型需要,请输入 API 密钥",
|
||||
"tileTypes": "瓦片类型",
|
||||
"addType": "添加类型",
|
||||
"noTileTypesConfigured": "未配置瓦片类型",
|
||||
"atLeastOneTileTypeRequired": "至少需要一种瓦片类型",
|
||||
"manageTileProviders": "管理提供商"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "编辑瓦片类型",
|
||||
"addTileType": "添加瓦片类型",
|
||||
"name": "名称",
|
||||
"nameHint": "例如,卫星",
|
||||
"nameRequired": "名称为必填项",
|
||||
"urlTemplate": "URL 模板",
|
||||
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "URL 模板为必填项",
|
||||
"urlTemplatePlaceholders": "URL 必须包含 {z}、{x} 和 {y} 占位符",
|
||||
"attribution": "归属",
|
||||
"attributionHint": "© 地图提供商",
|
||||
"attributionRequired": "归属为必填项",
|
||||
"fetchPreview": "获取预览",
|
||||
"previewTileLoaded": "预览瓦片加载成功",
|
||||
"previewTileFailed": "获取预览失败:{}",
|
||||
"save": "保存"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "节点配置文件",
|
||||
"newProfile": "新建配置文件",
|
||||
"builtIn": "内置",
|
||||
"custom": "自定义",
|
||||
"view": "查看",
|
||||
"deleteProfile": "删除配置文件",
|
||||
"deleteProfileConfirm": "您确定要删除 \"{}\" 吗?",
|
||||
"profileDeleted": "配置文件已删除"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "地图瓦片",
|
||||
"manageProviders": "管理提供商"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "查看配置文件",
|
||||
"newProfile": "新建配置文件",
|
||||
"editProfile": "编辑配置文件",
|
||||
"profileName": "配置文件名称",
|
||||
"profileNameHint": "例如,自定义 ALPR 摄像头",
|
||||
"profileNameRequired": "配置文件名称为必填项",
|
||||
"requiresDirection": "需要方向",
|
||||
"requiresDirectionSubtitle": "此类型的摄像头是否需要方向标签",
|
||||
"submittable": "可提交",
|
||||
"submittableSubtitle": "此配置文件是否可用于摄像头提交",
|
||||
"osmTags": "OSM 标签",
|
||||
"addTag": "添加标签",
|
||||
"saveProfile": "保存配置文件",
|
||||
"keyHint": "键",
|
||||
"valueHint": "值",
|
||||
"atLeastOneTagRequired": "至少需要一个标签",
|
||||
"profileSaved": "配置文件 \"{}\" 已保存"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "新建运营商配置文件",
|
||||
"editOperatorProfile": "编辑运营商配置文件",
|
||||
"operatorName": "运营商名称",
|
||||
"operatorNameHint": "例如,奥斯汀警察局",
|
||||
"operatorNameRequired": "运营商名称为必填项",
|
||||
"operatorProfileSaved": "运营商配置文件 \"{}\" 已保存"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "运营商配置文件",
|
||||
"noProfilesMessage": "未定义运营商配置文件。创建一个以将运营商标签应用于节点提交。",
|
||||
"tagsCount": "{} 个标签",
|
||||
"deleteOperatorProfile": "删除运营商配置文件",
|
||||
"deleteOperatorProfileConfirm": "您确定要删除 \"{}\" 吗?",
|
||||
"operatorProfileDeleted": "运营商配置文件已删除"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "离线区域",
|
||||
"noAreasTitle": "无离线区域",
|
||||
"noAreasSubtitle": "下载地图区域以供离线使用。",
|
||||
"provider": "提供商",
|
||||
"maxZoom": "最大缩放",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "纬度",
|
||||
"longitude": "经度",
|
||||
"tiles": "瓦片",
|
||||
"size": "大小",
|
||||
"nodes": "节点",
|
||||
"areaIdFallback": "区域 {}...",
|
||||
"renameArea": "重命名区域",
|
||||
"refreshWorldTiles": "刷新/重新下载世界瓦片",
|
||||
"deleteOfflineArea": "删除离线区域",
|
||||
"cancelDownload": "取消下载",
|
||||
"renameAreaDialogTitle": "重命名离线区域",
|
||||
"areaNameLabel": "区域名称",
|
||||
"renameButton": "重命名",
|
||||
"megabytes": "MB",
|
||||
"kilobytes": "KB",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "刷新区域",
|
||||
"refreshAreaDialogTitle": "刷新离线区域",
|
||||
"refreshAreaDialogSubtitle": "选择要为此区域刷新的内容:",
|
||||
"refreshTiles": "刷新地图瓦片",
|
||||
"refreshTilesSubtitle": "重新下载所有瓦片以获取更新的图像",
|
||||
"refreshNodes": "刷新节点",
|
||||
"refreshNodesSubtitle": "重新获取此区域的节点数据",
|
||||
"startRefresh": "开始刷新",
|
||||
"refreshStarted": "刷新已开始!",
|
||||
"refreshFailed": "刷新失败:{}"
|
||||
},
|
||||
"refineTagsSheet": {
|
||||
"title": "细化标签",
|
||||
"operatorProfile": "运营商配置文件",
|
||||
"done": "完成",
|
||||
"none": "无",
|
||||
"noAdditionalOperatorTags": "无额外运营商标签",
|
||||
"additionalTags": "额外标签",
|
||||
"additionalTagsTitle": "额外标签",
|
||||
"noTagsDefinedForProfile": "此运营商配置文件未定义标签。",
|
||||
"noOperatorProfiles": "未定义运营商配置文件",
|
||||
"noOperatorProfilesMessage": "在设置中创建运营商配置文件,以将额外标签应用于您的节点提交。"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "在下载离线区域时无法更改瓦片类型",
|
||||
"selectMapLayer": "选择地图图层",
|
||||
"noTileProvidersAvailable": "无可用瓦片提供商"
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,11 @@ import 'package:provider/provider.dart';
|
||||
import 'app_state.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/settings_screen.dart';
|
||||
import 'screens/profiles_settings_screen.dart';
|
||||
import 'screens/offline_settings_screen.dart';
|
||||
import 'screens/advanced_settings_screen.dart';
|
||||
import 'screens/language_settings_screen.dart';
|
||||
import 'screens/about_screen.dart';
|
||||
import 'services/localization_service.dart';
|
||||
|
||||
|
||||
@@ -23,7 +28,7 @@ Future<void> main() async {
|
||||
// You can customize this splash/loading screen as needed
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
backgroundColor: Color(0xFF202020),
|
||||
backgroundColor: Color(0xFF152131),
|
||||
body: Center(
|
||||
child: Image.asset(
|
||||
'assets/app_icon.png',
|
||||
@@ -58,6 +63,11 @@ class DeFlockApp extends StatelessWidget {
|
||||
routes: {
|
||||
'/': (context) => const HomeScreen(),
|
||||
'/settings': (context) => const SettingsScreen(),
|
||||
'/settings/profiles': (context) => const ProfilesSettingsScreen(),
|
||||
'/settings/offline': (context) => const OfflineSettingsScreen(),
|
||||
'/settings/advanced': (context) => const AdvancedSettingsScreen(),
|
||||
'/settings/language': (context) => const LanguageSettingsScreen(),
|
||||
'/settings/about': (context) => const AboutScreen(),
|
||||
},
|
||||
initialRoute: '/',
|
||||
);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class OsmCameraNode {
|
||||
class OsmNode {
|
||||
final int id;
|
||||
final LatLng coord;
|
||||
final Map<String, String> tags;
|
||||
|
||||
OsmCameraNode({
|
||||
OsmNode({
|
||||
required this.id,
|
||||
required this.coord,
|
||||
required this.tags,
|
||||
@@ -18,14 +18,14 @@ class OsmCameraNode {
|
||||
'tags': tags,
|
||||
};
|
||||
|
||||
factory OsmCameraNode.fromJson(Map<String, dynamic> json) {
|
||||
factory OsmNode.fromJson(Map<String, dynamic> json) {
|
||||
final tags = <String, String>{};
|
||||
if (json['tags'] != null) {
|
||||
(json['tags'] as Map<String, dynamic>).forEach((k, v) {
|
||||
tags[k.toString()] = v.toString();
|
||||
});
|
||||
}
|
||||
return OsmCameraNode(
|
||||
return OsmNode(
|
||||
id: json['id'] is int ? json['id'] as int : int.tryParse(json['id'].toString()) ?? 0,
|
||||
coord: LatLng((json['lat'] as num).toDouble(), (json['lon'] as num).toDouble()),
|
||||
tags: tags,
|
||||
@@ -51,5 +51,4 @@ class OsmCameraNode {
|
||||
final normalized = ((val % 360) + 360) % 360;
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,13 +3,17 @@ import 'node_profile.dart';
|
||||
import 'operator_profile.dart';
|
||||
import '../state/settings_state.dart';
|
||||
|
||||
enum UploadOperation { create, modify, delete }
|
||||
|
||||
class PendingUpload {
|
||||
final LatLng coord;
|
||||
final double direction;
|
||||
final NodeProfile profile;
|
||||
final OperatorProfile? operatorProfile;
|
||||
final UploadMode uploadMode; // Capture upload destination when queued
|
||||
final int? originalNodeId; // If this is an edit, the ID of the original OSM node
|
||||
final UploadOperation operation; // Type of operation: create, modify, or delete
|
||||
final int? originalNodeId; // If this is modify/delete, the ID of the original OSM node
|
||||
int? submittedNodeId; // The actual node ID returned by OSM after successful submission
|
||||
int attempts;
|
||||
bool error;
|
||||
bool completing; // True when upload succeeded but item is showing checkmark briefly
|
||||
@@ -20,14 +24,23 @@ class PendingUpload {
|
||||
required this.profile,
|
||||
this.operatorProfile,
|
||||
required this.uploadMode,
|
||||
required this.operation,
|
||||
this.originalNodeId,
|
||||
this.submittedNodeId,
|
||||
this.attempts = 0,
|
||||
this.error = false,
|
||||
this.completing = false,
|
||||
});
|
||||
}) : assert(
|
||||
(operation == UploadOperation.create && originalNodeId == null) ||
|
||||
(operation != UploadOperation.create && originalNodeId != null),
|
||||
'originalNodeId must be null for create operations and non-null for modify/delete operations'
|
||||
);
|
||||
|
||||
// True if this is an edit of an existing camera, false if it's a new camera
|
||||
bool get isEdit => originalNodeId != null;
|
||||
// True if this is an edit of an existing node, false if it's a new node
|
||||
bool get isEdit => operation == UploadOperation.modify;
|
||||
|
||||
// True if this is a deletion of an existing node
|
||||
bool get isDeletion => operation == UploadOperation.delete;
|
||||
|
||||
// Get display name for the upload destination
|
||||
String get uploadModeDisplayName {
|
||||
@@ -41,11 +54,11 @@ class PendingUpload {
|
||||
}
|
||||
}
|
||||
|
||||
// Get combined tags from camera profile and operator profile
|
||||
// Get combined tags from node profile and operator profile
|
||||
Map<String, String> getCombinedTags() {
|
||||
final tags = Map<String, String>.from(profile.tags);
|
||||
|
||||
// Add operator profile tags (they override camera profile tags if there are conflicts)
|
||||
// Add operator profile tags (they override node profile tags if there are conflicts)
|
||||
if (operatorProfile != null) {
|
||||
tags.addAll(operatorProfile!.tags);
|
||||
}
|
||||
@@ -65,7 +78,9 @@ class PendingUpload {
|
||||
'profile': profile.toJson(),
|
||||
'operatorProfile': operatorProfile?.toJson(),
|
||||
'uploadMode': uploadMode.index,
|
||||
'operation': operation.index,
|
||||
'originalNodeId': originalNodeId,
|
||||
'submittedNodeId': submittedNodeId,
|
||||
'attempts': attempts,
|
||||
'error': error,
|
||||
'completing': completing,
|
||||
@@ -83,7 +98,11 @@ class PendingUpload {
|
||||
uploadMode: j['uploadMode'] != null
|
||||
? UploadMode.values[j['uploadMode']]
|
||||
: UploadMode.production, // Default for legacy entries
|
||||
operation: j['operation'] != null
|
||||
? UploadOperation.values[j['operation']]
|
||||
: (j['originalNodeId'] != null ? UploadOperation.modify : UploadOperation.create), // Legacy compatibility
|
||||
originalNodeId: j['originalNodeId'],
|
||||
submittedNodeId: j['submittedNodeId'],
|
||||
attempts: j['attempts'] ?? 0,
|
||||
error: j['error'] ?? false,
|
||||
completing: j['completing'] ?? false, // Default to false for legacy entries
|
||||
|
||||
51
lib/screens/about_screen.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class AboutScreen extends StatelessWidget {
|
||||
const AboutScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('settings.aboutThisApp')),
|
||||
),
|
||||
body: FutureBuilder<String>(
|
||||
future: DefaultAssetBundle.of(context).loadString('assets/info.txt'),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Error loading info: ${snapshot.error}',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
snapshot.data ?? 'No info available.',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
33
lib/screens/advanced_settings_screen.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'settings/sections/max_nodes_section.dart';
|
||||
import 'settings/sections/proximity_alerts_section.dart';
|
||||
import 'settings/sections/tile_provider_section.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class AdvancedSettingsScreen extends StatelessWidget {
|
||||
const AdvancedSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('settings.advancedSettings')),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
MaxNodesSection(),
|
||||
Divider(),
|
||||
ProximityAlertsSection(),
|
||||
Divider(),
|
||||
TileProviderSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,11 @@ import '../services/localization_service.dart';
|
||||
|
||||
import '../widgets/add_node_sheet.dart';
|
||||
import '../widgets/edit_node_sheet.dart';
|
||||
import '../widgets/node_tag_sheet.dart';
|
||||
import '../widgets/camera_provider_with_cache.dart';
|
||||
import '../widgets/download_area_dialog.dart';
|
||||
import '../widgets/measured_sheet.dart';
|
||||
import '../models/osm_node.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
@@ -28,9 +30,16 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
late final AnimatedMapController _mapController;
|
||||
bool _editSheetShown = false;
|
||||
|
||||
// Track sheet heights for map padding
|
||||
// Track sheet heights for map positioning
|
||||
double _addSheetHeight = 0.0;
|
||||
double _editSheetHeight = 0.0;
|
||||
double _tagSheetHeight = 0.0;
|
||||
|
||||
// Flag to prevent map bounce when transitioning from tag sheet to edit sheet
|
||||
bool _transitioningToEdit = false;
|
||||
|
||||
// Track selected node for highlighting
|
||||
int? _selectedNodeId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -110,24 +119,97 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
// Disable follow-me when editing a camera so the map doesn't jump around
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
// Set transition flag to prevent map bounce
|
||||
_transitioningToEdit = true;
|
||||
|
||||
// Close any existing tag sheet first
|
||||
if (_tagSheetHeight > 0) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
final session = appState.editSession!; // should be non-null when this is called
|
||||
|
||||
// Small delay to let tag sheet close smoothly
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
if (!mounted) return;
|
||||
|
||||
final controller = _scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
setState(() {
|
||||
_editSheetHeight = height;
|
||||
// Clear transition flag and reset tag sheet height once edit sheet starts sizing
|
||||
if (height > 0 && _transitioningToEdit) {
|
||||
_transitioningToEdit = false;
|
||||
_tagSheetHeight = 0.0; // Now safe to reset
|
||||
_selectedNodeId = null; // Clear selection when moving to edit
|
||||
}
|
||||
});
|
||||
},
|
||||
child: EditNodeSheet(session: session),
|
||||
),
|
||||
);
|
||||
|
||||
// Reset height when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
setState(() {
|
||||
_editSheetHeight = 0.0;
|
||||
_transitioningToEdit = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void openNodeTagSheet(OsmNode node) {
|
||||
setState(() {
|
||||
_selectedNodeId = node.id; // Track selected node for highlighting
|
||||
});
|
||||
|
||||
// Start smooth centering animation simultaneously with sheet opening
|
||||
// Use the same duration as SheetAwareMap (300ms) for coordinated animation
|
||||
try {
|
||||
_mapController.animateTo(
|
||||
dest: node.coord,
|
||||
zoom: _mapController.mapController.camera.zoom,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (_) {
|
||||
// Map controller not ready, fallback to immediate move
|
||||
try {
|
||||
_mapController.mapController.move(node.coord, _mapController.mapController.camera.zoom);
|
||||
} catch (_) {
|
||||
// Controller really not ready, skip centering
|
||||
}
|
||||
}
|
||||
|
||||
final controller = _scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
setState(() {
|
||||
_editSheetHeight = height;
|
||||
_tagSheetHeight = height;
|
||||
});
|
||||
},
|
||||
child: EditNodeSheet(session: session),
|
||||
child: NodeTagSheet(
|
||||
node: node,
|
||||
onEditPressed: () {
|
||||
final appState = context.read<AppState>();
|
||||
appState.startEditSession(node);
|
||||
// This will trigger _openEditNodeSheet via the existing auto-show logic
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Reset height when sheet is dismissed
|
||||
// Reset height and selection when sheet is dismissed (unless transitioning to edit)
|
||||
controller.closed.then((_) {
|
||||
setState(() {
|
||||
_editSheetHeight = 0.0;
|
||||
});
|
||||
if (!_transitioningToEdit) {
|
||||
setState(() {
|
||||
_tagSheetHeight = 0.0;
|
||||
_selectedNodeId = null; // Clear selection
|
||||
});
|
||||
}
|
||||
// If transitioning to edit, keep the height until edit sheet takes over
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,9 +225,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
_editSheetShown = false;
|
||||
}
|
||||
|
||||
// Calculate bottom padding for map (90% of active sheet height)
|
||||
final activeSheetHeight = _addSheetHeight > 0 ? _addSheetHeight : _editSheetHeight;
|
||||
final mapBottomPadding = activeSheetHeight * 0.9;
|
||||
// Pass the active sheet height directly to the map
|
||||
final activeSheetHeight = _addSheetHeight > 0
|
||||
? _addSheetHeight
|
||||
: (_editSheetHeight > 0
|
||||
? _editSheetHeight
|
||||
: _tagSheetHeight);
|
||||
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
@@ -190,7 +275,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
key: _mapViewKey,
|
||||
controller: _mapController,
|
||||
followMeMode: appState.followMeMode,
|
||||
bottomPadding: mapBottomPadding,
|
||||
sheetHeight: activeSheetHeight,
|
||||
selectedNodeId: _selectedNodeId,
|
||||
onNodeTap: openNodeTagSheet,
|
||||
onUserGesture: () {
|
||||
if (appState.followMeMode != FollowMeMode.off) {
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
@@ -201,25 +288,27 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarMargin,
|
||||
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarOffset,
|
||||
left: 8,
|
||||
right: 8,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).shadowColor.withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, -2),
|
||||
)
|
||||
],
|
||||
),
|
||||
margin: EdgeInsets.only(bottom: kBottomButtonBarMargin),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).shadowColor.withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, -2),
|
||||
)
|
||||
],
|
||||
),
|
||||
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnimatedBuilder(
|
||||
@@ -256,6 +345,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
25
lib/screens/language_settings_screen.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'settings/sections/language_section.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class LanguageSettingsScreen extends StatelessWidget {
|
||||
const LanguageSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('settings.language')),
|
||||
),
|
||||
body: const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: LanguageSection(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
30
lib/screens/offline_settings_screen.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'settings/sections/offline_mode_section.dart';
|
||||
import 'settings/sections/offline_areas_section.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class OfflineSettingsScreen extends StatelessWidget {
|
||||
const OfflineSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('settings.offlineSettings')),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
OfflineModeSection(),
|
||||
Divider(),
|
||||
OfflineAreasSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
30
lib/screens/profiles_settings_screen.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'settings/sections/node_profiles_section.dart';
|
||||
import 'settings/sections/operator_profiles_section.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class ProfilesSettingsScreen extends StatelessWidget {
|
||||
const ProfilesSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('settings.profiles')),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
NodeProfilesSection(),
|
||||
Divider(),
|
||||
OperatorProfilesSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
|
||||
class AboutSection extends StatelessWidget {
|
||||
const AboutSection({super.key});
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
|
||||
class AuthSection extends StatelessWidget {
|
||||
const AuthSection({super.key});
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
|
||||
class LanguageSection extends StatefulWidget {
|
||||
const LanguageSection({super.key});
|
||||
@@ -57,15 +57,6 @@ class _LanguageSectionState extends State<LanguageSection> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
locService.t('settings.language'),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
// System Default option
|
||||
RadioListTile<String?>(
|
||||
title: Text(locService.t('settings.systemDefault')),
|
||||
@@ -73,8 +64,18 @@ class _LanguageSectionState extends State<LanguageSection> {
|
||||
groupValue: _selectedLanguage,
|
||||
onChanged: _setLanguage,
|
||||
),
|
||||
// Dynamic language options
|
||||
...locService.availableLanguages.map((langCode) =>
|
||||
// English always appears second (if available)
|
||||
if (locService.availableLanguages.contains('en'))
|
||||
RadioListTile<String>(
|
||||
title: Text(_languageNames['en'] ?? 'English'),
|
||||
value: 'en',
|
||||
groupValue: _selectedLanguage,
|
||||
onChanged: _setLanguage,
|
||||
),
|
||||
// Other language options (excluding English since it's already shown)
|
||||
...locService.availableLanguages
|
||||
.where((langCode) => langCode != 'en')
|
||||
.map((langCode) =>
|
||||
RadioListTile<String>(
|
||||
title: Text(_languageNames[langCode] ?? langCode.toUpperCase()),
|
||||
value: langCode,
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
|
||||
class MaxNodesSection extends StatefulWidget {
|
||||
const MaxNodesSection({super.key});
|
||||
@@ -39,13 +39,17 @@ class _MaxNodesSectionState extends State<MaxNodesSection> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('settings.maxNodes'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.filter_alt),
|
||||
title: Text(locService.t('settings.maxNodes')),
|
||||
title: Text(locService.t('settings.maxNodesSubtitle')),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(locService.t('settings.maxNodesSubtitle')),
|
||||
if (showWarning)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
@@ -1,13 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../models/node_profile.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../profile_editor.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../models/node_profile.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../profile_editor.dart';
|
||||
|
||||
class ProfileListSection extends StatelessWidget {
|
||||
const ProfileListSection({super.key});
|
||||
class NodeProfilesSection extends StatelessWidget {
|
||||
const NodeProfilesSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -22,7 +22,10 @@ class ProfileListSection extends StatelessWidget {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(locService.t('profiles.nodeProfiles'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
Text(
|
||||
locService.t('profiles.nodeProfiles'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
280
lib/screens/settings/sections/offline_areas_section.dart
Normal file
@@ -0,0 +1,280 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../services/offline_area_service.dart';
|
||||
import '../../../services/offline_areas/offline_area_models.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
|
||||
class OfflineAreasSection extends StatefulWidget {
|
||||
const OfflineAreasSection({super.key});
|
||||
|
||||
@override
|
||||
State<OfflineAreasSection> createState() => _OfflineAreasSectionState();
|
||||
}
|
||||
|
||||
class _OfflineAreasSectionState extends State<OfflineAreasSection> {
|
||||
OfflineAreaService get service => OfflineAreaService();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.doWhile(() async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (!mounted) return false;
|
||||
setState(() {});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
void _showRefreshDialog(OfflineArea area) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _RefreshAreaDialog(
|
||||
area: area,
|
||||
onRefresh: (refreshTiles, refreshNodes) {
|
||||
try {
|
||||
// ignore: unawaited_futures
|
||||
service.refreshArea(
|
||||
id: area.id,
|
||||
refreshTiles: refreshTiles,
|
||||
refreshNodes: refreshNodes,
|
||||
onProgress: (progress) => setState(() {}),
|
||||
onComplete: (status) => setState(() {}),
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(LocalizationService.instance.t('offlineAreas.refreshStarted')),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(LocalizationService.instance.t('offlineAreas.refreshFailed', params: [e.toString()])),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final areas = service.offlineAreas;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('offlineAreas.title'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (areas.isEmpty)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.download_for_offline),
|
||||
title: Text(locService.t('offlineAreas.noAreasTitle')),
|
||||
subtitle: Text(locService.t('offlineAreas.noAreasSubtitle')),
|
||||
)
|
||||
else
|
||||
...areas.map((area) {
|
||||
String diskStr = area.sizeBytes > 0
|
||||
? area.sizeBytes > 1024 * 1024
|
||||
? "${(area.sizeBytes / (1024 * 1024)).toStringAsFixed(2)} ${locService.t('offlineAreas.megabytes')}"
|
||||
: "${(area.sizeBytes / 1024).toStringAsFixed(1)} ${locService.t('offlineAreas.kilobytes')}"
|
||||
: '--';
|
||||
|
||||
String subtitle = '${locService.t('offlineAreas.provider')}: ${area.tileProviderDisplay}\n' +
|
||||
'${locService.t('offlineAreas.maxZoom')}: Z${area.maxZoom}' + '\n' +
|
||||
'${locService.t('offlineAreas.latitude')}: ${area.bounds.southWest.latitude.toStringAsFixed(3)}, ${area.bounds.southWest.longitude.toStringAsFixed(3)}\n' +
|
||||
'${locService.t('offlineAreas.latitude')}: ${area.bounds.northEast.latitude.toStringAsFixed(3)}, ${area.bounds.northEast.longitude.toStringAsFixed(3)}';
|
||||
|
||||
if (area.status == OfflineAreaStatus.downloading) {
|
||||
subtitle += '\n${locService.t('offlineAreas.tiles')}: ${area.tilesDownloaded} / ${area.tilesTotal}';
|
||||
} else {
|
||||
subtitle += '\n${locService.t('offlineAreas.tiles')}: ${area.tilesTotal}';
|
||||
}
|
||||
subtitle += '\n${locService.t('offlineAreas.size')}: $diskStr';
|
||||
subtitle += '\n${locService.t('offlineAreas.nodes')}: ${area.nodes.length}';
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Icon(area.status == OfflineAreaStatus.complete
|
||||
? Icons.cloud_done
|
||||
: area.status == OfflineAreaStatus.error
|
||||
? Icons.error
|
||||
: Icons.download_for_offline),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(area.name.isNotEmpty
|
||||
? area.name
|
||||
: locService.t('offlineAreas.areaIdFallback', params: [area.id.substring(0, 6)])),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, size: 20),
|
||||
tooltip: locService.t('offlineAreas.renameArea'),
|
||||
onPressed: () async {
|
||||
String? newName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
final ctrl = TextEditingController(text: area.name);
|
||||
return AlertDialog(
|
||||
title: Text(locService.t('offlineAreas.renameAreaDialogTitle')),
|
||||
content: TextField(
|
||||
controller: ctrl,
|
||||
maxLength: 40,
|
||||
decoration: InputDecoration(labelText: locService.t('offlineAreas.areaNameLabel')),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text(locService.t('actions.cancel')),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx, ctrl.text.trim());
|
||||
},
|
||||
child: Text(locService.t('offlineAreas.renameButton')),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (newName != null && newName.trim().isNotEmpty) {
|
||||
setState(() {
|
||||
area.name = newName.trim();
|
||||
service.saveAreasToDisk();
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
if (area.status != OfflineAreaStatus.downloading) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, color: Colors.blue),
|
||||
tooltip: locService.t('offlineAreas.refreshArea'),
|
||||
onPressed: () => _showRefreshDialog(area),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
tooltip: locService.t('offlineAreas.deleteOfflineArea'),
|
||||
onPressed: () async {
|
||||
service.deleteArea(area.id);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
subtitle: Text(subtitle),
|
||||
isThreeLine: true,
|
||||
trailing: area.status == OfflineAreaStatus.downloading
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
LinearProgressIndicator(value: area.progress),
|
||||
Text(
|
||||
locService.t('offlineAreas.progress', params: [(area.progress * 100).toStringAsFixed(0)]),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.cancel, color: Colors.orange),
|
||||
tooltip: locService.t('offlineAreas.cancelDownload'),
|
||||
onPressed: () {
|
||||
service.cancelDownload(area.id);
|
||||
setState(() {});
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
: null,
|
||||
onLongPress: area.status == OfflineAreaStatus.downloading
|
||||
? () {
|
||||
service.cancelDownload(area.id);
|
||||
setState(() {});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RefreshAreaDialog extends StatefulWidget {
|
||||
final OfflineArea area;
|
||||
final Function(bool refreshTiles, bool refreshNodes) onRefresh;
|
||||
|
||||
const _RefreshAreaDialog({
|
||||
required this.area,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_RefreshAreaDialog> createState() => _RefreshAreaDialogState();
|
||||
}
|
||||
|
||||
class _RefreshAreaDialogState extends State<_RefreshAreaDialog> {
|
||||
bool _refreshTiles = true;
|
||||
bool _refreshNodes = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(locService.t('offlineAreas.refreshAreaDialogTitle')),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(locService.t('offlineAreas.refreshAreaDialogSubtitle')),
|
||||
const SizedBox(height: 16),
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('offlineAreas.refreshTiles')),
|
||||
subtitle: Text(locService.t('offlineAreas.refreshTilesSubtitle')),
|
||||
value: _refreshTiles,
|
||||
onChanged: (value) => setState(() => _refreshTiles = value ?? true),
|
||||
dense: true,
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('offlineAreas.refreshNodes')),
|
||||
subtitle: Text(locService.t('offlineAreas.refreshNodesSubtitle')),
|
||||
value: _refreshNodes,
|
||||
onChanged: (value) => setState(() => _refreshNodes = value ?? true),
|
||||
dense: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(locService.t('actions.cancel')),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: (_refreshTiles || _refreshNodes)
|
||||
? () {
|
||||
Navigator.of(context).pop();
|
||||
widget.onRefresh(_refreshTiles, _refreshNodes);
|
||||
}
|
||||
: null,
|
||||
child: Text(locService.t('offlineAreas.startRefresh')),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../services/offline_area_service.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/offline_area_service.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
|
||||
class OfflineModeSection extends StatelessWidget {
|
||||
const OfflineModeSection({super.key});
|
||||
@@ -61,14 +61,23 @@ class OfflineModeSection extends StatelessWidget {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.wifi_off),
|
||||
title: Text(locService.t('settings.offlineMode')),
|
||||
subtitle: Text(locService.t('settings.offlineModeSubtitle')),
|
||||
trailing: Switch(
|
||||
value: appState.offlineMode,
|
||||
onChanged: (value) => _handleOfflineModeChange(context, appState, value),
|
||||
),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('settings.offlineMode'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.wifi_off),
|
||||
title: Text(locService.t('settings.offlineModeSubtitle')),
|
||||
trailing: Switch(
|
||||
value: appState.offlineMode,
|
||||
onChanged: (value) => _handleOfflineModeChange(context, appState, value),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,13 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../models/operator_profile.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../operator_profile_editor.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../models/operator_profile.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../operator_profile_editor.dart';
|
||||
|
||||
class OperatorProfileListSection extends StatelessWidget {
|
||||
const OperatorProfileListSection({super.key});
|
||||
class OperatorProfilesSection extends StatelessWidget {
|
||||
const OperatorProfilesSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -22,7 +22,10 @@ class OperatorProfileListSection extends StatelessWidget {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(locService.t('operatorProfiles.title'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
Text(
|
||||
locService.t('operatorProfiles.title'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
221
lib/screens/settings/sections/proximity_alerts_section.dart
Normal file
@@ -0,0 +1,221 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../../services/proximity_alert_service.dart';
|
||||
import '../../../dev_config.dart';
|
||||
|
||||
/// Settings section for proximity alerts configuration
|
||||
/// Follows brutalist principles: simple, explicit UI that matches existing patterns
|
||||
class ProximityAlertsSection extends StatefulWidget {
|
||||
const ProximityAlertsSection({super.key});
|
||||
|
||||
@override
|
||||
State<ProximityAlertsSection> createState() => _ProximityAlertsSectionState();
|
||||
}
|
||||
|
||||
class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
|
||||
late final TextEditingController _distanceController;
|
||||
bool _notificationsEnabled = false;
|
||||
bool _checkingPermissions = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final appState = context.read<AppState>();
|
||||
_distanceController = TextEditingController(
|
||||
text: appState.proximityAlertDistance.toString(),
|
||||
);
|
||||
_checkNotificationPermissions();
|
||||
}
|
||||
|
||||
Future<void> _checkNotificationPermissions() async {
|
||||
setState(() {
|
||||
_checkingPermissions = true;
|
||||
});
|
||||
|
||||
final enabled = await ProximityAlertService().areNotificationsEnabled();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_notificationsEnabled = enabled;
|
||||
_checkingPermissions = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestNotificationPermissions() async {
|
||||
setState(() {
|
||||
_checkingPermissions = true;
|
||||
});
|
||||
|
||||
final enabled = await ProximityAlertService().requestNotificationPermissions();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_notificationsEnabled = enabled;
|
||||
_checkingPermissions = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_distanceController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateDistance(AppState appState) {
|
||||
final text = _distanceController.text.trim();
|
||||
final distance = int.tryParse(text);
|
||||
if (distance != null) {
|
||||
appState.setProximityAlertDistance(distance);
|
||||
} else {
|
||||
// Reset to current value if invalid
|
||||
_distanceController.text = appState.proximityAlertDistance.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('settings.proximityAlerts'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Enable/disable toggle
|
||||
SwitchListTile(
|
||||
title: Text(locService.t('proximityAlerts.getNotified')),
|
||||
subtitle: Text(
|
||||
'${locService.t('proximityAlerts.batteryUsage')}\n'
|
||||
'${_notificationsEnabled ? locService.t('proximityAlerts.notificationsEnabled') : locService.t('proximityAlerts.notificationsDisabled')}',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
value: appState.proximityAlertsEnabled,
|
||||
onChanged: (enabled) {
|
||||
appState.setProximityAlertsEnabled(enabled);
|
||||
if (enabled && !_notificationsEnabled) {
|
||||
// Automatically try to request permissions when enabling
|
||||
_requestNotificationPermissions();
|
||||
}
|
||||
},
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
|
||||
// Notification permissions section (only show when proximity alerts are enabled)
|
||||
if (appState.proximityAlertsEnabled && !_notificationsEnabled && !_checkingPermissions) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.notifications_off, color: Colors.orange, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
locService.t('proximityAlerts.permissionRequired'),
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
locService.t('proximityAlerts.permissionExplanation'),
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _requestNotificationPermissions,
|
||||
icon: const Icon(Icons.settings, size: 16),
|
||||
label: Text(locService.t('proximityAlerts.enableNotifications')),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 32),
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Loading indicator
|
||||
if (_checkingPermissions) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('proximityAlerts.checkingPermissions'), style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
// Distance setting (only show when enabled)
|
||||
if (appState.proximityAlertsEnabled) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Text(locService.t('proximityAlerts.alertDistance')),
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: TextField(
|
||||
controller: _distanceController,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 8,
|
||||
),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _updateDistance(appState),
|
||||
onEditingComplete: () => _updateDistance(appState),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('proximityAlerts.meters')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
locService.t('proximityAlerts.rangeInfo', params: [
|
||||
kProximityAlertMinDistance.toString(),
|
||||
kProximityAlertMaxDistance.toString(),
|
||||
kProximityAlertDefaultDistance.toString(),
|
||||
]),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../../state/settings_state.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../../state/settings_state.dart';
|
||||
|
||||
class QueueSection extends StatelessWidget {
|
||||
const QueueSection({super.key});
|
||||
180
lib/screens/settings/sections/tile_provider_section.dart
Normal file
@@ -0,0 +1,180 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../app_state.dart';
|
||||
import '../../../models/tile_provider.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../tile_provider_editor_screen.dart';
|
||||
|
||||
class TileProviderSection extends StatelessWidget {
|
||||
const TileProviderSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
final providers = appState.tileProviders;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('mapTiles.title'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => _addProvider(context),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(locService.t('tileProviders.addProvider')),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (providers.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(locService.t('tileProviders.noProvidersConfigured')),
|
||||
),
|
||||
)
|
||||
else
|
||||
...providers.map((provider) => _buildProviderTile(context, provider, appState)).toList(),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProviderTile(BuildContext context, TileProvider provider, AppState appState) {
|
||||
final locService = LocalizationService.instance;
|
||||
final isSelected = appState.selectedTileProvider?.id == provider.id;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
provider.name,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(locService.t('tileProviders.tileTypesCount', params: [provider.tileTypes.length.toString()])),
|
||||
if (provider.apiKey?.isNotEmpty == true)
|
||||
Text(
|
||||
locService.t('tileProviders.apiKeyConfigured'),
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (!provider.isUsable)
|
||||
Text(
|
||||
locService.t('tileProviders.needsApiKey'),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.surfaceVariant,
|
||||
child: Icon(
|
||||
Icons.map,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: appState.tileProviders.length > 1
|
||||
? PopupMenuButton<String>(
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
_editProvider(context, provider);
|
||||
break;
|
||||
case 'delete':
|
||||
_deleteProvider(context, provider);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('actions.edit')),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('tileProviders.deleteProvider')),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Icon(Icons.lock, size: 16), // Can't delete last provider
|
||||
onTap: () => _editProvider(context, provider),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _addProvider(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const TileProviderEditorScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _editProvider(BuildContext context, TileProvider provider) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TileProviderEditorScreen(provider: provider),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _deleteProvider(BuildContext context, TileProvider provider) {
|
||||
final locService = LocalizationService.instance;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('tileProviders.deleteProvider')),
|
||||
content: Text(locService.t('tileProviders.deleteProviderConfirm', params: [provider.name])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(locService.t('actions.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<AppState>().deleteTileProvider(provider.id);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(locService.t('tileProviders.deleteProvider')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
|
||||
class UploadModeSection extends StatelessWidget {
|
||||
const UploadModeSection({super.key});
|
||||
@@ -53,19 +53,9 @@ class UploadModeSection extends StatelessWidget {
|
||||
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7))
|
||||
);
|
||||
case UploadMode.sandbox:
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('uploadMode.sandboxDescription'),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orange),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
locService.t('uploadMode.sandboxNote'),
|
||||
style: const TextStyle(fontSize: 11, color: Colors.redAccent),
|
||||
),
|
||||
],
|
||||
return Text(
|
||||
locService.t('uploadMode.sandboxDescription'),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orange),
|
||||
);
|
||||
case UploadMode.simulate:
|
||||
default:
|
||||
@@ -1,53 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'settings_screen_sections/auth_section.dart';
|
||||
import 'settings_screen_sections/upload_mode_section.dart';
|
||||
import 'settings_screen_sections/profile_list_section.dart';
|
||||
import 'settings_screen_sections/operator_profile_list_section.dart';
|
||||
import 'settings_screen_sections/queue_section.dart';
|
||||
import 'settings_screen_sections/offline_areas_section.dart';
|
||||
import 'settings_screen_sections/offline_mode_section.dart';
|
||||
import 'settings_screen_sections/about_section.dart';
|
||||
import 'settings_screen_sections/max_nodes_section.dart';
|
||||
import 'settings_screen_sections/tile_provider_section.dart';
|
||||
import 'settings_screen_sections/language_section.dart';
|
||||
import 'settings/sections/auth_section.dart';
|
||||
import 'settings/sections/upload_mode_section.dart';
|
||||
import 'settings/sections/queue_section.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => Scaffold(
|
||||
appBar: AppBar(title: Text(LocalizationService.instance.t('settings.title'))),
|
||||
appBar: AppBar(title: Text(locService.t('settings.title'))),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
UploadModeSection(),
|
||||
Divider(),
|
||||
AuthSection(),
|
||||
Divider(),
|
||||
QueueSection(),
|
||||
Divider(),
|
||||
ProfileListSection(),
|
||||
Divider(),
|
||||
OperatorProfileListSection(),
|
||||
Divider(),
|
||||
MaxNodesSection(),
|
||||
Divider(),
|
||||
TileProviderSection(),
|
||||
Divider(),
|
||||
OfflineModeSection(),
|
||||
Divider(),
|
||||
OfflineAreasSection(),
|
||||
Divider(),
|
||||
LanguageSection(),
|
||||
Divider(),
|
||||
AboutSection(),
|
||||
children: [
|
||||
// Only show upload mode section in development builds
|
||||
if (kEnableDevelopmentModes) ...[
|
||||
const UploadModeSection(),
|
||||
const Divider(),
|
||||
],
|
||||
const AuthSection(),
|
||||
const Divider(),
|
||||
const QueueSection(),
|
||||
const Divider(),
|
||||
|
||||
// Navigation to sub-pages
|
||||
_buildNavigationTile(
|
||||
context,
|
||||
icon: Icons.account_tree,
|
||||
title: locService.t('settings.profiles'),
|
||||
subtitle: locService.t('settings.profilesSubtitle'),
|
||||
onTap: () => Navigator.pushNamed(context, '/settings/profiles'),
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
_buildNavigationTile(
|
||||
context,
|
||||
icon: Icons.cloud_off,
|
||||
title: locService.t('settings.offlineSettings'),
|
||||
subtitle: locService.t('settings.offlineSettingsSubtitle'),
|
||||
onTap: () => Navigator.pushNamed(context, '/settings/offline'),
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
_buildNavigationTile(
|
||||
context,
|
||||
icon: Icons.tune,
|
||||
title: locService.t('settings.advancedSettings'),
|
||||
subtitle: locService.t('settings.advancedSettingsSubtitle'),
|
||||
onTap: () => Navigator.pushNamed(context, '/settings/advanced'),
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
_buildNavigationTile(
|
||||
context,
|
||||
icon: Icons.language,
|
||||
title: locService.t('settings.language'),
|
||||
subtitle: locService.t('settings.languageSubtitle'),
|
||||
onTap: () => Navigator.pushNamed(context, '/settings/language'),
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
_buildNavigationTile(
|
||||
context,
|
||||
icon: Icons.info_outline,
|
||||
title: locService.t('settings.aboutInfo'),
|
||||
subtitle: locService.t('settings.aboutSubtitle'),
|
||||
onTap: () => Navigator.pushNamed(context, '/settings/about'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavigationTile(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../services/offline_area_service.dart';
|
||||
import '../../services/offline_areas/offline_area_models.dart';
|
||||
|
||||
class OfflineAreasSection extends StatefulWidget {
|
||||
const OfflineAreasSection({super.key});
|
||||
|
||||
@override
|
||||
State<OfflineAreasSection> createState() => _OfflineAreasSectionState();
|
||||
}
|
||||
|
||||
class _OfflineAreasSectionState extends State<OfflineAreasSection> {
|
||||
OfflineAreaService get service => OfflineAreaService();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.doWhile(() async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (!mounted) return false;
|
||||
setState(() {});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final areas = service.offlineAreas;
|
||||
if (areas.isEmpty) {
|
||||
return const ListTile(
|
||||
leading: Icon(Icons.download_for_offline),
|
||||
title: Text('No offline areas'),
|
||||
subtitle: Text('Download a map area for offline use.'),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: areas.map((area) {
|
||||
String diskStr = area.sizeBytes > 0
|
||||
? area.sizeBytes > 1024 * 1024
|
||||
? "${(area.sizeBytes / (1024 * 1024)).toStringAsFixed(2)} MB"
|
||||
: "${(area.sizeBytes / 1024).toStringAsFixed(1)} KB"
|
||||
: '--';
|
||||
String subtitle =
|
||||
'Provider: ${area.tileProviderDisplay}\n' +
|
||||
'Z${area.minZoom}-${area.maxZoom}\n' +
|
||||
'Lat: ${area.bounds.southWest.latitude.toStringAsFixed(3)}, ${area.bounds.southWest.longitude.toStringAsFixed(3)}\n' +
|
||||
'Lat: ${area.bounds.northEast.latitude.toStringAsFixed(3)}, ${area.bounds.northEast.longitude.toStringAsFixed(3)}';
|
||||
if (area.status == OfflineAreaStatus.downloading) {
|
||||
subtitle += '\nTiles: ${area.tilesDownloaded} / ${area.tilesTotal}';
|
||||
} else {
|
||||
subtitle += '\nTiles: ${area.tilesTotal}';
|
||||
}
|
||||
subtitle += '\nSize: $diskStr';
|
||||
if (!area.isPermanent) {
|
||||
subtitle += '\nCameras: ${area.cameras.length}';
|
||||
}
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Icon(area.status == OfflineAreaStatus.complete
|
||||
? Icons.cloud_done
|
||||
: area.status == OfflineAreaStatus.error
|
||||
? Icons.error
|
||||
: Icons.download_for_offline),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(area.name.isNotEmpty
|
||||
? area.name
|
||||
: 'Area ${area.id.substring(0, 6)}...'),
|
||||
),
|
||||
if (!area.isPermanent)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, size: 20),
|
||||
tooltip: 'Rename area',
|
||||
onPressed: () async {
|
||||
String? newName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
final ctrl = TextEditingController(text: area.name);
|
||||
return AlertDialog(
|
||||
title: const Text('Rename Offline Area'),
|
||||
content: TextField(
|
||||
controller: ctrl,
|
||||
maxLength: 40,
|
||||
decoration: const InputDecoration(labelText: 'Area Name'),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx, ctrl.text.trim());
|
||||
},
|
||||
child: const Text('Rename'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (newName != null && newName.trim().isNotEmpty) {
|
||||
setState(() {
|
||||
area.name = newName.trim();
|
||||
service.saveAreasToDisk();
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
if (area.isPermanent && area.status != OfflineAreaStatus.downloading)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, color: Colors.blue),
|
||||
tooltip: 'Refresh/re-download world tiles',
|
||||
onPressed: () async {
|
||||
await service.downloadArea(
|
||||
id: area.id,
|
||||
bounds: area.bounds,
|
||||
minZoom: area.minZoom,
|
||||
maxZoom: area.maxZoom,
|
||||
directory: area.directory,
|
||||
name: area.name,
|
||||
onProgress: (progress) {},
|
||||
onComplete: (status) {},
|
||||
tileProviderId: area.tileProviderId,
|
||||
tileProviderName: area.tileProviderName,
|
||||
tileTypeId: area.tileTypeId,
|
||||
tileTypeName: area.tileTypeName,
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
)
|
||||
else if (!area.isPermanent && area.status != OfflineAreaStatus.downloading)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
tooltip: 'Delete offline area',
|
||||
onPressed: () async {
|
||||
service.deleteArea(area.id);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Text(subtitle),
|
||||
isThreeLine: true,
|
||||
trailing: area.status == OfflineAreaStatus.downloading
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
LinearProgressIndicator(value: area.progress),
|
||||
Text(
|
||||
'${(area.progress * 100).toStringAsFixed(0)}%',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.cancel, color: Colors.orange),
|
||||
tooltip: 'Cancel download',
|
||||
onPressed: () {
|
||||
service.cancelDownload(area.id);
|
||||
setState(() {});
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
: null,
|
||||
onLongPress: area.status == OfflineAreaStatus.downloading
|
||||
? () {
|
||||
service.cancelDownload(area.id);
|
||||
setState(() {});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../models/tile_provider.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../tile_provider_management_screen.dart';
|
||||
|
||||
class TileProviderSection extends StatelessWidget {
|
||||
const TileProviderSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('mapTiles.title'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const TileProviderManagementScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.settings),
|
||||
label: Text(locService.t('mapTiles.manageProviders')),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:collection/collection.dart';
|
||||
import '../app_state.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
class TileProviderEditorScreen extends StatefulWidget {
|
||||
final TileProvider? provider; // null for adding new provider
|
||||
@@ -378,11 +379,11 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
|
||||
});
|
||||
|
||||
try {
|
||||
// Use a sample tile (zoom 10, somewhere in the world)
|
||||
// Use a sample tile from configured preview location
|
||||
final url = _urlController.text
|
||||
.replaceAll('{z}', '10')
|
||||
.replaceAll('{x}', '512')
|
||||
.replaceAll('{y}', '384');
|
||||
.replaceAll('{z}', kPreviewTileZoom.toString())
|
||||
.replaceAll('{x}', kPreviewTileX.toString())
|
||||
.replaceAll('{y}', kPreviewTileY.toString());
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import 'tile_provider_editor_screen.dart';
|
||||
|
||||
class TileProviderManagementScreen extends StatelessWidget {
|
||||
const TileProviderManagementScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
final providers = appState.tileProviders;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('tileProviders.title')),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () => _addProvider(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: providers.isEmpty
|
||||
? Center(
|
||||
child: Text(locService.t('tileProviders.noProvidersConfigured')),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: providers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final provider = providers[index];
|
||||
final isSelected = appState.selectedTileProvider?.id == provider.id;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
provider.name,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(locService.t('tileProviders.tileTypesCount', params: [provider.tileTypes.length.toString()])),
|
||||
if (provider.apiKey?.isNotEmpty == true)
|
||||
Text(
|
||||
locService.t('tileProviders.apiKeyConfigured'),
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (!provider.isUsable)
|
||||
Text(
|
||||
locService.t('tileProviders.needsApiKey'),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.surfaceVariant,
|
||||
child: Icon(
|
||||
Icons.map,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: providers.length > 1
|
||||
? PopupMenuButton<String>(
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
_editProvider(context, provider);
|
||||
break;
|
||||
case 'delete':
|
||||
_deleteProvider(context, provider);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('actions.edit')),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('tileProviders.deleteProvider')),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Icon(Icons.lock, size: 16), // Can't delete last provider
|
||||
onTap: () => _editProvider(context, provider),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _addProvider(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const TileProviderEditorScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _editProvider(BuildContext context, TileProvider provider) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TileProviderEditorScreen(provider: provider),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _deleteProvider(BuildContext context, TileProvider provider) {
|
||||
final locService = LocalizationService.instance;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('tileProviders.deleteProvider')),
|
||||
content: Text(locService.t('tileProviders.deleteProviderConfirm', params: [provider.name])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(locService.t('actions.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<AppState>().deleteTileProvider(provider.id);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(locService.t('tileProviders.deleteProvider')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,36 @@ class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
// Restore login state from stored token (for app startup)
|
||||
Future<String?> restoreLogin() async {
|
||||
if (_mode == UploadMode.simulate) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final isLoggedIn = prefs.getBool('sim_user_logged_in') ?? false;
|
||||
if (isLoggedIn) {
|
||||
_displayName = 'Demo User';
|
||||
return _displayName;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get stored token directly from SharedPreferences
|
||||
final accessToken = await getAccessToken();
|
||||
if (accessToken == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
_displayName = await _fetchUsername(accessToken);
|
||||
return _displayName;
|
||||
} catch (e) {
|
||||
print('AuthService: Error restoring login with stored token: $e');
|
||||
log('Error restoring login with stored token: $e');
|
||||
// Token might be expired or invalid, clear it
|
||||
await logout();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
if (_mode == UploadMode.simulate) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
class CameraCache {
|
||||
// Singleton instance
|
||||
static final CameraCache instance = CameraCache._internal();
|
||||
factory CameraCache() => instance;
|
||||
CameraCache._internal();
|
||||
|
||||
final Map<int, OsmCameraNode> _nodes = {};
|
||||
|
||||
/// Add or update a batch of camera nodes in the cache.
|
||||
void addOrUpdate(List<OsmCameraNode> nodes) {
|
||||
for (var node in nodes) {
|
||||
final existing = _nodes[node.id];
|
||||
if (existing != null) {
|
||||
// Preserve any tags starting with underscore when updating existing nodes
|
||||
final mergedTags = Map<String, String>.from(node.tags);
|
||||
for (final entry in existing.tags.entries) {
|
||||
if (entry.key.startsWith('_')) {
|
||||
mergedTags[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
_nodes[node.id] = OsmCameraNode(
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: mergedTags,
|
||||
);
|
||||
} else {
|
||||
_nodes[node.id] = node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Query for all cached cameras currently within the given LatLngBounds.
|
||||
List<OsmCameraNode> queryByBounds(LatLngBounds bounds) {
|
||||
return _nodes.values
|
||||
.where((node) => _inBounds(node.coord, bounds))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Retrieve all cached cameras.
|
||||
List<OsmCameraNode> getAll() => _nodes.values.toList();
|
||||
|
||||
/// Optionally clear the cache (rarely needed)
|
||||
void clear() => _nodes.clear();
|
||||
|
||||
/// Utility: point-in-bounds for coordinates
|
||||
bool _inBounds(LatLng coord, LatLngBounds bounds) {
|
||||
return coord.latitude >= bounds.southWest.latitude &&
|
||||
coord.latitude <= bounds.northEast.latitude &&
|
||||
coord.longitude >= bounds.southWest.longitude &&
|
||||
coord.longitude <= bounds.northEast.longitude;
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,44 @@ class LocalizationService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _discoverAvailableLanguages() async {
|
||||
// For now, we'll hardcode the languages we support
|
||||
// In the future, this could scan the assets directory
|
||||
_availableLanguages = ['en', 'es', 'fr', 'de'];
|
||||
_availableLanguages = [];
|
||||
|
||||
try {
|
||||
// Get the asset manifest to find all localization files
|
||||
final manifestContent = await rootBundle.loadString('AssetManifest.json');
|
||||
final Map<String, dynamic> manifestMap = json.decode(manifestContent);
|
||||
|
||||
// Find all .json files in lib/localizations/
|
||||
final localizationFiles = manifestMap.keys
|
||||
.where((String key) => key.startsWith('lib/localizations/') && key.endsWith('.json'))
|
||||
.toList();
|
||||
|
||||
for (final filePath in localizationFiles) {
|
||||
// Extract language code from filename (e.g., 'lib/localizations/pt.json' -> 'pt')
|
||||
final fileName = filePath.split('/').last;
|
||||
final languageCode = fileName.substring(0, fileName.length - 5); // Remove '.json'
|
||||
|
||||
try {
|
||||
// Try to load and parse the file to ensure it's valid
|
||||
final jsonString = await rootBundle.loadString(filePath);
|
||||
final parsedJson = json.decode(jsonString);
|
||||
|
||||
// Basic validation - ensure it has the expected structure
|
||||
if (parsedJson is Map && parsedJson.containsKey('language')) {
|
||||
_availableLanguages.add(languageCode);
|
||||
debugPrint('Found localization: $languageCode');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to load localization file $filePath: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to read AssetManifest.json: $e');
|
||||
// If manifest reading fails, we'll have an empty list
|
||||
// The system will handle this gracefully by falling back to 'en' in _loadSavedLanguage
|
||||
}
|
||||
|
||||
debugPrint('Available languages: $_availableLanguages');
|
||||
}
|
||||
|
||||
Future<void> _loadSavedLanguage() async {
|
||||
@@ -99,10 +134,10 @@ class LocalizationService extends ChangeNotifier {
|
||||
|
||||
String result = current is String ? current : key;
|
||||
|
||||
// Replace parameters if provided
|
||||
// Replace parameters if provided - replace first occurrence only for each parameter
|
||||
if (params != null) {
|
||||
for (int i = 0; i < params.length; i++) {
|
||||
result = result.replaceAll('{}', params[i]);
|
||||
result = result.replaceFirst('{}', params[i]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@ import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../app_state.dart';
|
||||
import 'map_data_submodules/nodes_from_overpass.dart';
|
||||
import 'map_data_submodules/nodes_from_osm_api.dart';
|
||||
import 'map_data_submodules/tiles_from_remote.dart';
|
||||
import 'map_data_submodules/nodes_from_local.dart';
|
||||
import 'map_data_submodules/tiles_from_local.dart';
|
||||
import 'network_status.dart';
|
||||
|
||||
enum MapSource { local, remote, auto } // For future use
|
||||
|
||||
@@ -33,12 +35,13 @@ class MapDataProvider {
|
||||
|
||||
/// Fetch surveillance nodes from OSM/Overpass or local storage.
|
||||
/// Remote is default. If source is MapSource.auto, remote is tried first unless offline.
|
||||
Future<List<OsmCameraNode>> getNodes({
|
||||
Future<List<OsmNode>> getNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
MapSource source = MapSource.auto,
|
||||
}) async {
|
||||
try {
|
||||
final offline = AppState.instance.offlineMode;
|
||||
|
||||
// Explicit remote request: error if offline, else always remote
|
||||
@@ -46,7 +49,7 @@ class MapDataProvider {
|
||||
if (offline) {
|
||||
throw OfflineModeException("Cannot fetch remote nodes in offline mode.");
|
||||
}
|
||||
return fetchOverpassNodes(
|
||||
return _fetchRemoteNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
@@ -62,51 +65,97 @@ class MapDataProvider {
|
||||
);
|
||||
}
|
||||
|
||||
// AUTO: default = remote first, fallback to local only if offline
|
||||
// AUTO: In offline mode, behavior depends on upload mode
|
||||
if (offline) {
|
||||
return fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxNodes: AppState.instance.maxCameras,
|
||||
);
|
||||
} else {
|
||||
// Try remote, fallback to local ONLY if remote throws (optional, could be removed for stricter behavior)
|
||||
try {
|
||||
return await fetchOverpassNodes(
|
||||
if (uploadMode == UploadMode.sandbox) {
|
||||
// Offline + Sandbox = no nodes (local cache is production data)
|
||||
debugPrint('[MapDataProvider] Offline + Sandbox mode: returning no nodes (local cache is production data)');
|
||||
return <OsmNode>[];
|
||||
} else {
|
||||
// Offline + Production = use local cache
|
||||
return fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: AppState.instance.maxCameras,
|
||||
maxNodes: AppState.instance.maxCameras,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Falling back to local.');
|
||||
return fetchLocalNodes(
|
||||
}
|
||||
} else if (uploadMode == UploadMode.sandbox) {
|
||||
// Sandbox mode: Only fetch from sandbox API, ignore local production nodes
|
||||
debugPrint('[MapDataProvider] Sandbox mode: fetching only from sandbox API, ignoring local cache');
|
||||
return _fetchRemoteNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: AppState.instance.maxCameras,
|
||||
);
|
||||
} else {
|
||||
// Production mode: fetch both remote and local, then merge with deduplication
|
||||
final List<Future<List<OsmNode>>> futures = [];
|
||||
|
||||
// Always try to get local nodes (fast, cached)
|
||||
futures.add(fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxNodes: AppState.instance.maxCameras,
|
||||
);
|
||||
));
|
||||
|
||||
// Always try to get remote nodes (slower, fresh data)
|
||||
futures.add(_fetchRemoteNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: AppState.instance.maxCameras,
|
||||
).catchError((e) {
|
||||
debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Continuing with local only.');
|
||||
return <OsmNode>[]; // Return empty list on remote failure
|
||||
}));
|
||||
|
||||
// Wait for both, then merge with deduplication by node ID
|
||||
final results = await Future.wait(futures);
|
||||
final localNodes = results[0];
|
||||
final remoteNodes = results[1];
|
||||
|
||||
// Merge with deduplication - prefer remote data over local for same node ID
|
||||
final Map<int, OsmNode> mergedNodes = {};
|
||||
|
||||
// Add local nodes first
|
||||
for (final node in localNodes) {
|
||||
mergedNodes[node.id] = node;
|
||||
}
|
||||
|
||||
// Add remote nodes, overwriting any local duplicates
|
||||
for (final node in remoteNodes) {
|
||||
mergedNodes[node.id] = node;
|
||||
}
|
||||
|
||||
// Apply maxCameras limit to the merged result
|
||||
final finalNodes = mergedNodes.values.take(AppState.instance.maxCameras).toList();
|
||||
return finalNodes;
|
||||
}
|
||||
} finally {
|
||||
// Always report node completion, regardless of success or failure
|
||||
NetworkStatus.instance.reportNodeComplete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Bulk/paged node fetch for offline downloads (handling paging, dedup, and Overpass retries)
|
||||
/// Only use for offline area download, not for map browsing! Ignores maxCameras config.
|
||||
Future<List<OsmCameraNode>> getAllNodesForDownload({
|
||||
Future<List<OsmNode>> getAllNodesForDownload({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
int pageSize = 500,
|
||||
int maxResults = 0, // 0 = no limit for offline downloads
|
||||
int maxTries = 3,
|
||||
}) async {
|
||||
final offline = AppState.instance.offlineMode;
|
||||
if (offline) {
|
||||
throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode.");
|
||||
}
|
||||
return fetchOverpassNodes(
|
||||
return _fetchRemoteNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: pageSize,
|
||||
maxResults: maxResults, // Pass 0 for unlimited
|
||||
);
|
||||
}
|
||||
|
||||
@@ -163,4 +212,58 @@ class MapDataProvider {
|
||||
void clearTileQueue() {
|
||||
clearRemoteTileQueue();
|
||||
}
|
||||
|
||||
/// Fetch remote nodes with Overpass first, OSM API fallback
|
||||
Future<List<OsmNode>> _fetchRemoteNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
required int maxResults,
|
||||
}) async {
|
||||
// For sandbox mode, skip Overpass and go directly to OSM API
|
||||
// (Overpass doesn't have sandbox data)
|
||||
if (uploadMode == UploadMode.sandbox) {
|
||||
debugPrint('[MapDataProvider] Sandbox mode detected, using OSM API directly');
|
||||
return fetchOsmApiNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults,
|
||||
);
|
||||
}
|
||||
|
||||
// For production mode, try Overpass first, then fallback to OSM API
|
||||
try {
|
||||
final nodes = await fetchOverpassNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults,
|
||||
);
|
||||
|
||||
// If Overpass returns nodes, we're good
|
||||
if (nodes.isNotEmpty) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// If Overpass returns empty (could be no data or could be an issue),
|
||||
// try OSM API as well to be thorough
|
||||
debugPrint('[MapDataProvider] Overpass returned no nodes, trying OSM API fallback');
|
||||
return fetchOsmApiNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults,
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[MapDataProvider] Overpass failed ($e), trying OSM API fallback');
|
||||
return fetchOsmApiNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,21 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import '../../models/osm_camera_node.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../models/node_profile.dart';
|
||||
import '../offline_area_service.dart';
|
||||
import '../offline_areas/offline_area_models.dart';
|
||||
|
||||
/// Fetch surveillance nodes from all offline areas intersecting the bounds/profile list.
|
||||
Future<List<OsmCameraNode>> fetchLocalNodes({
|
||||
Future<List<OsmNode>> fetchLocalNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
int? maxNodes,
|
||||
}) async {
|
||||
final areas = OfflineAreaService().offlineAreas;
|
||||
final Map<int, OsmCameraNode> deduped = {};
|
||||
final Map<int, OsmNode> deduped = {};
|
||||
|
||||
for (final area in areas) {
|
||||
if (area.status != OfflineAreaStatus.complete) continue;
|
||||
@@ -37,16 +38,32 @@ Future<List<OsmCameraNode>> fetchLocalNodes({
|
||||
}
|
||||
|
||||
// Try in-memory first, else load from disk
|
||||
Future<List<OsmCameraNode>> _loadAreaNodes(OfflineArea area) async {
|
||||
if (area.cameras.isNotEmpty) {
|
||||
return area.cameras;
|
||||
Future<List<OsmNode>> _loadAreaNodes(OfflineArea area) async {
|
||||
if (area.nodes.isNotEmpty) {
|
||||
return area.nodes;
|
||||
}
|
||||
final file = File('${area.directory}/cameras.json');
|
||||
if (await file.exists()) {
|
||||
final str = await file.readAsString();
|
||||
final jsonList = jsonDecode(str) as List;
|
||||
return jsonList.map((e) => OsmCameraNode.fromJson(e)).toList();
|
||||
|
||||
// Try new nodes.json first, fall back to legacy cameras.json for backward compatibility
|
||||
final nodeFile = File('${area.directory}/nodes.json');
|
||||
final legacyCameraFile = File('${area.directory}/cameras.json');
|
||||
|
||||
File? fileToLoad;
|
||||
if (await nodeFile.exists()) {
|
||||
fileToLoad = nodeFile;
|
||||
} else if (await legacyCameraFile.exists()) {
|
||||
fileToLoad = legacyCameraFile;
|
||||
}
|
||||
|
||||
if (fileToLoad != null) {
|
||||
try {
|
||||
final str = await fileToLoad.readAsString();
|
||||
final jsonList = jsonDecode(str) as List;
|
||||
return jsonList.map((e) => OsmNode.fromJson(e)).toList();
|
||||
} catch (e) {
|
||||
debugPrint('[_loadAreaNodes] Error loading nodes from ${fileToLoad.path}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -57,14 +74,14 @@ bool _pointInBounds(LatLng pt, LatLngBounds bounds) {
|
||||
pt.longitude <= bounds.northEast.longitude;
|
||||
}
|
||||
|
||||
bool _matchesAnyProfile(OsmCameraNode node, List<NodeProfile> profiles) {
|
||||
bool _matchesAnyProfile(OsmNode node, List<NodeProfile> profiles) {
|
||||
for (final prof in profiles) {
|
||||
if (_nodeMatchesProfile(node, prof)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _nodeMatchesProfile(OsmCameraNode node, NodeProfile profile) {
|
||||
bool _nodeMatchesProfile(OsmNode node, NodeProfile profile) {
|
||||
for (final e in profile.tags.entries) {
|
||||
if (node.tags[e.key] != e.value) return false; // All profile tags must match
|
||||
}
|
||||
|
||||
129
lib/services/map_data_submodules/nodes_from_osm_api.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
import '../../models/node_profile.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../network_status.dart';
|
||||
|
||||
/// Fetches surveillance nodes from the direct OSM API using bbox query.
|
||||
/// This is a fallback for when Overpass is not available (e.g., sandbox mode).
|
||||
Future<List<OsmNode>> fetchOsmApiNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
required int maxResults,
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
// Choose API endpoint based on upload mode
|
||||
final String apiHost = uploadMode == UploadMode.sandbox
|
||||
? 'api06.dev.openstreetmap.org'
|
||||
: 'api.openstreetmap.org';
|
||||
|
||||
// Build the map query URL - fetches all data in bounding box
|
||||
final left = bounds.southWest.longitude;
|
||||
final bottom = bounds.southWest.latitude;
|
||||
final right = bounds.northEast.longitude;
|
||||
final top = bounds.northEast.latitude;
|
||||
|
||||
final url = 'https://$apiHost/api/0.6/map?bbox=$left,$bottom,$right,$top';
|
||||
|
||||
try {
|
||||
debugPrint('[fetchOsmApiNodes] Querying OSM API for nodes in bbox...');
|
||||
debugPrint('[fetchOsmApiNodes] URL: $url');
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
debugPrint('[fetchOsmApiNodes] OSM API error: ${response.statusCode} - ${response.body}');
|
||||
NetworkStatus.instance.reportOverpassIssue(); // Reuse same status tracking
|
||||
return [];
|
||||
}
|
||||
|
||||
// Parse XML response
|
||||
final document = XmlDocument.parse(response.body);
|
||||
final nodes = <OsmNode>[];
|
||||
|
||||
// Find all node elements
|
||||
for (final nodeElement in document.findAllElements('node')) {
|
||||
final id = int.tryParse(nodeElement.getAttribute('id') ?? '');
|
||||
final latStr = nodeElement.getAttribute('lat');
|
||||
final lonStr = nodeElement.getAttribute('lon');
|
||||
|
||||
if (id == null || latStr == null || lonStr == null) continue;
|
||||
|
||||
final lat = double.tryParse(latStr);
|
||||
final lon = double.tryParse(lonStr);
|
||||
if (lat == null || lon == null) continue;
|
||||
|
||||
// Parse tags
|
||||
final tags = <String, String>{};
|
||||
for (final tagElement in nodeElement.findElements('tag')) {
|
||||
final key = tagElement.getAttribute('k');
|
||||
final value = tagElement.getAttribute('v');
|
||||
if (key != null && value != null) {
|
||||
tags[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this node matches any of our profiles
|
||||
if (_nodeMatchesProfiles(tags, profiles)) {
|
||||
nodes.add(OsmNode(
|
||||
id: id,
|
||||
coord: LatLng(lat, lon),
|
||||
tags: tags,
|
||||
));
|
||||
}
|
||||
|
||||
// Respect maxResults limit if set
|
||||
if (maxResults > 0 && nodes.length >= maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nodes.isNotEmpty) {
|
||||
debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes');
|
||||
}
|
||||
|
||||
NetworkStatus.instance.reportOverpassSuccess(); // Reuse same status tracking
|
||||
return nodes;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[fetchOsmApiNodes] Exception: $e');
|
||||
|
||||
// Report network issues for connection errors
|
||||
if (e.toString().contains('Connection refused') ||
|
||||
e.toString().contains('Connection timed out') ||
|
||||
e.toString().contains('Connection reset')) {
|
||||
NetworkStatus.instance.reportOverpassIssue();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a node's tags match any of the given profiles
|
||||
bool _nodeMatchesProfiles(Map<String, String> nodeTags, List<NodeProfile> profiles) {
|
||||
for (final profile in profiles) {
|
||||
if (_nodeMatchesProfile(nodeTags, profile)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Check if a node's tags match a specific profile
|
||||
bool _nodeMatchesProfile(Map<String, String> nodeTags, NodeProfile profile) {
|
||||
// All profile tags must be present in the node for it to match
|
||||
for (final entry in profile.tags.entries) {
|
||||
if (nodeTags[entry.key] != entry.value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -5,12 +5,13 @@ import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../../models/node_profile.dart';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../models/pending_upload.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../network_status.dart';
|
||||
|
||||
/// Fetches surveillance nodes from the Overpass OSM API for the given bounds and profiles.
|
||||
Future<List<OsmCameraNode>> fetchOverpassNodes({
|
||||
Future<List<OsmNode>> fetchOverpassNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
@@ -47,14 +48,19 @@ Future<List<OsmCameraNode>> fetchOverpassNodes({
|
||||
|
||||
NetworkStatus.instance.reportOverpassSuccess();
|
||||
|
||||
return elements.whereType<Map<String, dynamic>>().map((element) {
|
||||
return OsmCameraNode(
|
||||
final nodes = elements.whereType<Map<String, dynamic>>().map((element) {
|
||||
return OsmNode(
|
||||
id: element['id'],
|
||||
coord: LatLng(element['lat'], element['lon']),
|
||||
tags: Map<String, String>.from(element['tags'] ?? {}),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// Clean up any pending uploads that now appear in Overpass results
|
||||
_cleanupCompletedUploads(nodes);
|
||||
|
||||
return nodes;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[fetchOverpassNodes] Exception: $e');
|
||||
|
||||
@@ -82,11 +88,50 @@ String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int
|
||||
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
|
||||
}).join('\n ');
|
||||
|
||||
// Use unlimited output if maxResults is 0
|
||||
final outputClause = maxResults > 0 ? 'out body $maxResults;' : 'out body;';
|
||||
|
||||
return '''
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
$nodeClauses
|
||||
);
|
||||
out body $maxResults;
|
||||
$outputClause
|
||||
''';
|
||||
}
|
||||
|
||||
/// Clean up pending uploads that now appear in Overpass results
|
||||
void _cleanupCompletedUploads(List<OsmNode> overpassNodes) {
|
||||
try {
|
||||
final appState = AppState.instance;
|
||||
final pendingUploads = appState.pendingUploads;
|
||||
|
||||
if (pendingUploads.isEmpty) return;
|
||||
|
||||
final overpassNodeIds = overpassNodes.map((n) => n.id).toSet();
|
||||
|
||||
// Find pending uploads whose submitted node IDs now appear in Overpass results
|
||||
final uploadsToRemove = <PendingUpload>[];
|
||||
|
||||
for (final upload in pendingUploads) {
|
||||
if (upload.submittedNodeId != null &&
|
||||
overpassNodeIds.contains(upload.submittedNodeId!)) {
|
||||
uploadsToRemove.add(upload);
|
||||
debugPrint('[OverpassCleanup] Found submitted node ${upload.submittedNodeId} in Overpass results, removing from pending queue');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the completed uploads from the queue
|
||||
for (final upload in uploadsToRemove) {
|
||||
appState.removeFromQueue(upload);
|
||||
}
|
||||
|
||||
if (uploadsToRemove.isNotEmpty) {
|
||||
debugPrint('[OverpassCleanup] Cleaned up ${uploadsToRemove.length} completed uploads');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[OverpassCleanup] Error during cleanup: $e');
|
||||
// Don't let cleanup errors break the main functionality
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import '../app_state.dart';
|
||||
enum NetworkIssueType { osmTiles, overpassApi, both }
|
||||
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success }
|
||||
|
||||
/// Simple loading state for dual-source async operations (brutalist approach)
|
||||
enum LoadingState { ready, waiting, success, timeout }
|
||||
|
||||
class NetworkStatus extends ChangeNotifier {
|
||||
static final NetworkStatus instance = NetworkStatus._();
|
||||
NetworkStatus._();
|
||||
@@ -23,6 +26,13 @@ class NetworkStatus extends ChangeNotifier {
|
||||
Timer? _noDataResetTimer;
|
||||
Timer? _successResetTimer;
|
||||
|
||||
// New dual-source loading state (brutalist approach)
|
||||
LoadingState _tileLoadingState = LoadingState.ready;
|
||||
LoadingState _nodeLoadingState = LoadingState.ready;
|
||||
Timer? _tileTimeoutTimer;
|
||||
Timer? _nodeTimeoutTimer;
|
||||
Timer? _successDisplayTimer;
|
||||
|
||||
// Getters
|
||||
bool get hasAnyIssues => _osmTilesHaveIssues || _overpassHasIssues;
|
||||
bool get osmTilesHaveIssues => _osmTilesHaveIssues;
|
||||
@@ -32,7 +42,22 @@ class NetworkStatus extends ChangeNotifier {
|
||||
bool get hasNoData => _hasNoData;
|
||||
bool get hasSuccess => _hasSuccess;
|
||||
|
||||
// New dual-source getters (brutalist approach)
|
||||
LoadingState get tileLoadingState => _tileLoadingState;
|
||||
LoadingState get nodeLoadingState => _nodeLoadingState;
|
||||
|
||||
/// Derive overall loading status from dual sources
|
||||
bool get isDualSourceLoading => _tileLoadingState == LoadingState.waiting || _nodeLoadingState == LoadingState.waiting;
|
||||
bool get isDualSourceTimeout => _tileLoadingState == LoadingState.timeout || _nodeLoadingState == LoadingState.timeout;
|
||||
bool get isDualSourceSuccess => _tileLoadingState == LoadingState.success && _nodeLoadingState == LoadingState.success;
|
||||
|
||||
NetworkStatusType get currentStatus {
|
||||
// Check new dual-source states first
|
||||
if (isDualSourceTimeout) return NetworkStatusType.timedOut;
|
||||
if (isDualSourceLoading) return NetworkStatusType.waiting;
|
||||
if (isDualSourceSuccess) return NetworkStatusType.success;
|
||||
|
||||
// Fall back to legacy states for compatibility
|
||||
if (hasAnyIssues) return NetworkStatusType.issues;
|
||||
if (_isWaitingForData) return NetworkStatusType.waiting;
|
||||
if (_isTimedOut) return NetworkStatusType.timedOut;
|
||||
@@ -206,12 +231,91 @@ class NetworkStatus extends ChangeNotifier {
|
||||
});
|
||||
}
|
||||
|
||||
// New dual-source loading methods (brutalist approach)
|
||||
|
||||
/// Start waiting for both tiles and nodes
|
||||
void setDualSourceWaiting() {
|
||||
_tileLoadingState = LoadingState.waiting;
|
||||
_nodeLoadingState = LoadingState.waiting;
|
||||
|
||||
// Set timeout timers for both
|
||||
_tileTimeoutTimer?.cancel();
|
||||
_tileTimeoutTimer = Timer(const Duration(seconds: 8), () {
|
||||
if (_tileLoadingState == LoadingState.waiting) {
|
||||
_tileLoadingState = LoadingState.timeout;
|
||||
debugPrint('[NetworkStatus] Tile loading timed out');
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
|
||||
_nodeTimeoutTimer?.cancel();
|
||||
_nodeTimeoutTimer = Timer(const Duration(seconds: 8), () {
|
||||
if (_nodeLoadingState == LoadingState.waiting) {
|
||||
_nodeLoadingState = LoadingState.timeout;
|
||||
debugPrint('[NetworkStatus] Node loading timed out');
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Report tile loading completion
|
||||
void reportTileComplete() {
|
||||
if (_tileLoadingState == LoadingState.waiting) {
|
||||
_tileLoadingState = LoadingState.success;
|
||||
_tileTimeoutTimer?.cancel();
|
||||
_checkDualSourceComplete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Report node loading completion
|
||||
void reportNodeComplete() {
|
||||
if (_nodeLoadingState == LoadingState.waiting) {
|
||||
_nodeLoadingState = LoadingState.success;
|
||||
_nodeTimeoutTimer?.cancel();
|
||||
_checkDualSourceComplete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if both sources are complete and show success briefly
|
||||
void _checkDualSourceComplete() {
|
||||
if (_tileLoadingState == LoadingState.success && _nodeLoadingState == LoadingState.success) {
|
||||
debugPrint('[NetworkStatus] Both tiles and nodes loaded successfully');
|
||||
notifyListeners();
|
||||
|
||||
// Auto-reset to ready after showing success briefly
|
||||
_successDisplayTimer?.cancel();
|
||||
_successDisplayTimer = Timer(const Duration(seconds: 2), () {
|
||||
_tileLoadingState = LoadingState.ready;
|
||||
_nodeLoadingState = LoadingState.ready;
|
||||
notifyListeners();
|
||||
});
|
||||
} else {
|
||||
// Just notify if one completed but not both yet
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset dual-source state to ready
|
||||
void resetDualSourceState() {
|
||||
_tileLoadingState = LoadingState.ready;
|
||||
_nodeLoadingState = LoadingState.ready;
|
||||
_tileTimeoutTimer?.cancel();
|
||||
_nodeTimeoutTimer?.cancel();
|
||||
_successDisplayTimer?.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_osmRecoveryTimer?.cancel();
|
||||
_overpassRecoveryTimer?.cancel();
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
_tileTimeoutTimer?.cancel();
|
||||
_nodeTimeoutTimer?.cancel();
|
||||
_successDisplayTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
110
lib/services/node_cache.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
class NodeCache {
|
||||
// Singleton instance
|
||||
static final NodeCache instance = NodeCache._internal();
|
||||
factory NodeCache() => instance;
|
||||
NodeCache._internal();
|
||||
|
||||
final Map<int, OsmNode> _nodes = {};
|
||||
|
||||
/// Add or update a batch of nodes in the cache.
|
||||
void addOrUpdate(List<OsmNode> nodes) {
|
||||
for (var node in nodes) {
|
||||
final existing = _nodes[node.id];
|
||||
if (existing != null) {
|
||||
// Preserve any tags starting with underscore when updating existing nodes
|
||||
final mergedTags = Map<String, String>.from(node.tags);
|
||||
for (final entry in existing.tags.entries) {
|
||||
if (entry.key.startsWith('_')) {
|
||||
mergedTags[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
_nodes[node.id] = OsmNode(
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: mergedTags,
|
||||
);
|
||||
} else {
|
||||
_nodes[node.id] = node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Query for all cached nodes currently within the given LatLngBounds.
|
||||
List<OsmNode> queryByBounds(LatLngBounds bounds) {
|
||||
return _nodes.values
|
||||
.where((node) => _inBounds(node.coord, bounds))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Retrieve all cached nodes.
|
||||
List<OsmNode> getAll() => _nodes.values.toList();
|
||||
|
||||
/// Optionally clear the cache (rarely needed)
|
||||
void clear() => _nodes.clear();
|
||||
|
||||
/// Remove the _pending_edit marker from a specific node
|
||||
void removePendingEditMarker(int nodeId) {
|
||||
final node = _nodes[nodeId];
|
||||
if (node != null && node.tags.containsKey('_pending_edit')) {
|
||||
final cleanTags = Map<String, String>.from(node.tags);
|
||||
cleanTags.remove('_pending_edit');
|
||||
|
||||
_nodes[nodeId] = OsmNode(
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: cleanTags,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a node by ID from the cache (used for successful deletions)
|
||||
void removeNodeById(int nodeId) {
|
||||
if (_nodes.remove(nodeId) != null) {
|
||||
print('[NodeCache] Removed node $nodeId from cache (successful deletion)');
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove temporary nodes (negative IDs) with _pending_upload marker at the given coordinate
|
||||
/// This is used when a real node ID is assigned to clean up temp placeholders
|
||||
void removeTempNodesByCoordinate(LatLng coord, {double tolerance = 0.00001}) {
|
||||
final nodesToRemove = <int>[];
|
||||
|
||||
for (final entry in _nodes.entries) {
|
||||
final nodeId = entry.key;
|
||||
final node = entry.value;
|
||||
|
||||
// Only consider temp nodes (negative IDs) with pending upload marker
|
||||
if (nodeId < 0 &&
|
||||
node.tags.containsKey('_pending_upload') &&
|
||||
_coordsMatch(node.coord, coord, tolerance)) {
|
||||
nodesToRemove.add(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
for (final nodeId in nodesToRemove) {
|
||||
_nodes.remove(nodeId);
|
||||
}
|
||||
|
||||
if (nodesToRemove.isNotEmpty) {
|
||||
print('[NodeCache] Removed ${nodesToRemove.length} temp nodes at coordinate ${coord.latitude}, ${coord.longitude}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if two coordinates match within tolerance
|
||||
bool _coordsMatch(LatLng coord1, LatLng coord2, double tolerance) {
|
||||
return (coord1.latitude - coord2.latitude).abs() < tolerance &&
|
||||
(coord1.longitude - coord2.longitude).abs() < tolerance;
|
||||
}
|
||||
|
||||
/// Utility: point-in-bounds for coordinates
|
||||
bool _inBounds(LatLng coord, LatLngBounds bounds) {
|
||||
return coord.latitude >= bounds.southWest.latitude &&
|
||||
coord.latitude <= bounds.northEast.latitude &&
|
||||
coord.longitude >= bounds.southWest.longitude &&
|
||||
coord.longitude <= bounds.northEast.longitude;
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'offline_areas/offline_area_models.dart';
|
||||
import 'offline_areas/offline_tile_utils.dart';
|
||||
import 'offline_areas/offline_area_downloader.dart';
|
||||
import 'offline_areas/world_area_manager.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
|
||||
import '../models/osm_node.dart';
|
||||
import '../app_state.dart';
|
||||
import 'map_data_provider.dart';
|
||||
import 'package:deflockapp/dev_config.dart';
|
||||
@@ -59,8 +59,7 @@ class OfflineAreaService {
|
||||
if (_initialized) return;
|
||||
|
||||
await _loadAreasFromDisk();
|
||||
await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea);
|
||||
await saveAreasToDisk(); // Save any world area updates
|
||||
await _cleanupLegacyWorldAreas();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
@@ -262,9 +261,6 @@ class OfflineAreaService {
|
||||
}
|
||||
_areas.remove(area);
|
||||
await saveAreasToDisk();
|
||||
if (area.isPermanent) {
|
||||
await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea);
|
||||
}
|
||||
}
|
||||
|
||||
void deleteArea(String id) async {
|
||||
@@ -276,6 +272,140 @@ class OfflineAreaService {
|
||||
_areas.remove(area);
|
||||
await saveAreasToDisk();
|
||||
}
|
||||
|
||||
/// Refresh/update an existing offline area - tiles, nodes, or both
|
||||
Future<void> refreshArea({
|
||||
required String id,
|
||||
required bool refreshTiles,
|
||||
required bool refreshNodes,
|
||||
void Function(double progress)? onProgress,
|
||||
void Function(OfflineAreaStatus status)? onComplete,
|
||||
}) async {
|
||||
final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found');
|
||||
|
||||
if (area.status == OfflineAreaStatus.downloading) {
|
||||
throw 'Area is already downloading';
|
||||
}
|
||||
|
||||
// Set area to downloading state
|
||||
area.status = OfflineAreaStatus.downloading;
|
||||
area.progress = 0.0;
|
||||
|
||||
// Only reset tile count if we're actually refreshing tiles
|
||||
if (refreshTiles) {
|
||||
area.tilesDownloaded = 0;
|
||||
}
|
||||
|
||||
await saveAreasToDisk();
|
||||
|
||||
try {
|
||||
bool success = true;
|
||||
|
||||
if (refreshTiles && refreshNodes) {
|
||||
// Refresh both - use the full download process
|
||||
success = await OfflineAreaDownloader.downloadArea(
|
||||
area: area,
|
||||
bounds: area.bounds,
|
||||
minZoom: area.minZoom,
|
||||
maxZoom: area.maxZoom,
|
||||
directory: area.directory,
|
||||
onProgress: onProgress,
|
||||
saveAreasToDisk: saveAreasToDisk,
|
||||
getAreaSizeBytes: getAreaSizeBytes,
|
||||
);
|
||||
} else if (refreshTiles) {
|
||||
// Refresh tiles only
|
||||
success = await _refreshTilesOnly(area, onProgress);
|
||||
} else if (refreshNodes) {
|
||||
// Refresh nodes only
|
||||
success = await _refreshNodesOnly(area, onProgress);
|
||||
} else {
|
||||
// Neither option selected - shouldn't happen but handle gracefully
|
||||
success = true;
|
||||
area.progress = 1.0;
|
||||
}
|
||||
|
||||
await getAreaSizeBytes(area);
|
||||
|
||||
if (success) {
|
||||
area.status = OfflineAreaStatus.complete;
|
||||
area.progress = 1.0;
|
||||
debugPrint('Area $id: refresh completed successfully.');
|
||||
} else {
|
||||
area.status = OfflineAreaStatus.error;
|
||||
debugPrint('Area $id: refresh failed after maximum retry attempts.');
|
||||
}
|
||||
await saveAreasToDisk();
|
||||
onComplete?.call(area.status);
|
||||
} catch (e) {
|
||||
area.status = OfflineAreaStatus.error;
|
||||
await saveAreasToDisk();
|
||||
onComplete?.call(area.status);
|
||||
debugPrint('Area $id: refresh failed with exception: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh only the tiles for an area
|
||||
Future<bool> _refreshTilesOnly(OfflineArea area, void Function(double progress)? onProgress) async {
|
||||
final allTiles = computeTileList(area.bounds, area.minZoom, area.maxZoom);
|
||||
area.tilesTotal = allTiles.length;
|
||||
|
||||
return await OfflineAreaDownloader.downloadTilesWithRetry(
|
||||
area: area,
|
||||
allTiles: allTiles,
|
||||
directory: area.directory,
|
||||
onProgress: onProgress,
|
||||
saveAreasToDisk: saveAreasToDisk,
|
||||
getAreaSizeBytes: getAreaSizeBytes,
|
||||
);
|
||||
}
|
||||
|
||||
/// Refresh only the nodes for an area
|
||||
Future<bool> _refreshNodesOnly(OfflineArea area, void Function(double progress)? onProgress) async {
|
||||
try {
|
||||
// Use the same logic as in the downloader for consistency
|
||||
final nodeZoom = (area.minZoom + 1).clamp(8, 16);
|
||||
final expandedNodeBounds = OfflineAreaDownloader.calculateNodeBounds(area.bounds, nodeZoom);
|
||||
|
||||
final nodes = await MapDataProvider().getAllNodesForDownload(
|
||||
bounds: expandedNodeBounds,
|
||||
profiles: AppState.instance.profiles,
|
||||
);
|
||||
|
||||
area.nodes = nodes;
|
||||
await OfflineAreaDownloader.saveNodes(nodes, area.directory);
|
||||
|
||||
// Set progress to complete for nodes-only refresh
|
||||
onProgress?.call(1.0);
|
||||
area.progress = 1.0;
|
||||
|
||||
debugPrint('Area ${area.id}: Refreshed ${nodes.length} nodes');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('Area ${area.id}: Failed to refresh nodes: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove any legacy world areas from previous versions
|
||||
Future<void> _cleanupLegacyWorldAreas() async {
|
||||
final worldAreas = _areas.where((area) => area.isPermanent || area.id == 'world').toList();
|
||||
|
||||
if (worldAreas.isNotEmpty) {
|
||||
debugPrint('OfflineAreaService: Cleaning up ${worldAreas.length} legacy world area(s)');
|
||||
|
||||
for (final area in worldAreas) {
|
||||
final dir = Directory(area.directory);
|
||||
if (await dir.exists()) {
|
||||
await dir.delete(recursive: true);
|
||||
debugPrint('OfflineAreaService: Deleted world area directory: ${area.directory}');
|
||||
}
|
||||
_areas.remove(area);
|
||||
}
|
||||
|
||||
await saveAreasToDisk();
|
||||
debugPrint('OfflineAreaService: Legacy world area cleanup complete');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@ import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../map_data_provider.dart';
|
||||
import 'offline_area_models.dart';
|
||||
import 'offline_tile_utils.dart';
|
||||
import 'package:deflockapp/dev_config.dart';
|
||||
|
||||
/// Handles the actual downloading process for offline areas
|
||||
class OfflineAreaDownloader {
|
||||
@@ -27,16 +26,11 @@ class OfflineAreaDownloader {
|
||||
required Future<void> Function() saveAreasToDisk,
|
||||
required Future<void> Function(OfflineArea) getAreaSizeBytes,
|
||||
}) async {
|
||||
Set<List<int>> allTiles;
|
||||
if (area.isPermanent) {
|
||||
allTiles = computeTileList(globalWorldBounds(), kWorldMinZoom, kWorldMaxZoom);
|
||||
} else {
|
||||
allTiles = computeTileList(bounds, minZoom, maxZoom);
|
||||
}
|
||||
Set<List<int>> allTiles = computeTileList(bounds, minZoom, maxZoom);
|
||||
area.tilesTotal = allTiles.length;
|
||||
|
||||
// Download tiles with retry logic
|
||||
final success = await _downloadTilesWithRetry(
|
||||
final success = await downloadTilesWithRetry(
|
||||
area: area,
|
||||
allTiles: allTiles,
|
||||
directory: directory,
|
||||
@@ -45,23 +39,19 @@ class OfflineAreaDownloader {
|
||||
getAreaSizeBytes: getAreaSizeBytes,
|
||||
);
|
||||
|
||||
// Download cameras for non-permanent areas
|
||||
if (!area.isPermanent) {
|
||||
await _downloadCameras(
|
||||
area: area,
|
||||
bounds: bounds,
|
||||
minZoom: minZoom,
|
||||
directory: directory,
|
||||
);
|
||||
} else {
|
||||
area.cameras = [];
|
||||
}
|
||||
// Download nodes for all areas
|
||||
await _downloadNodes(
|
||||
area: area,
|
||||
bounds: bounds,
|
||||
minZoom: minZoom,
|
||||
directory: directory,
|
||||
);
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/// Download tiles with retry logic
|
||||
static Future<bool> _downloadTilesWithRetry({
|
||||
static Future<bool> downloadTilesWithRetry({
|
||||
required OfflineArea area,
|
||||
required Set<List<int>> allTiles,
|
||||
required String directory,
|
||||
@@ -138,26 +128,29 @@ class OfflineAreaDownloader {
|
||||
return missingTiles;
|
||||
}
|
||||
|
||||
/// Download cameras for the area with expanded bounds
|
||||
static Future<void> _downloadCameras({
|
||||
/// Download nodes for the area with modest expansion (one zoom level lower)
|
||||
static Future<void> _downloadNodes({
|
||||
required OfflineArea area,
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required String directory,
|
||||
}) async {
|
||||
// Calculate expanded camera bounds that cover the entire tile area at minimum zoom
|
||||
final cameraBounds = _calculateCameraBounds(bounds, minZoom);
|
||||
final cameras = await MapDataProvider().getAllNodesForDownload(
|
||||
bounds: cameraBounds,
|
||||
// Modest expansion: use tiles at minZoom + 1 instead of minZoom
|
||||
// This gives a reasonable buffer without capturing entire states
|
||||
final nodeZoom = (minZoom + 1).clamp(8, 16); // Reasonable bounds for node fetching
|
||||
final expandedNodeBounds = calculateNodeBounds(bounds, nodeZoom);
|
||||
|
||||
final nodes = await MapDataProvider().getAllNodesForDownload(
|
||||
bounds: expandedNodeBounds,
|
||||
profiles: AppState.instance.profiles, // Use ALL profiles, not just enabled ones
|
||||
);
|
||||
area.cameras = cameras;
|
||||
await OfflineAreaDownloader.saveCameras(cameras, directory);
|
||||
debugPrint('Area ${area.id}: Downloaded ${cameras.length} cameras from expanded bounds (all profiles)');
|
||||
area.nodes = nodes;
|
||||
await OfflineAreaDownloader.saveNodes(nodes, directory);
|
||||
debugPrint('Area ${area.id}: Downloaded ${nodes.length} nodes from modestly expanded bounds (all profiles)');
|
||||
}
|
||||
|
||||
/// Calculate expanded bounds that cover the entire tile area at minimum zoom
|
||||
static LatLngBounds _calculateCameraBounds(LatLngBounds visibleBounds, int minZoom) {
|
||||
static LatLngBounds calculateNodeBounds(LatLngBounds visibleBounds, int minZoom) {
|
||||
final tiles = computeTileList(visibleBounds, minZoom, minZoom);
|
||||
if (tiles.isEmpty) return visibleBounds;
|
||||
|
||||
@@ -188,9 +181,9 @@ class OfflineAreaDownloader {
|
||||
await file.writeAsBytes(bytes);
|
||||
}
|
||||
|
||||
/// Save cameras to disk as JSON
|
||||
static Future<void> saveCameras(List<OsmCameraNode> cams, String dir) async {
|
||||
final file = File('$dir/cameras.json');
|
||||
await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList()));
|
||||
/// Save nodes to disk as JSON
|
||||
static Future<void> saveNodes(List<OsmNode> nodes, String dir) async {
|
||||
final file = File('$dir/nodes.json');
|
||||
await file.writeAsString(jsonEncode(nodes.map((n) => n.toJson()).toList()));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import '../../models/osm_camera_node.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
|
||||
/// Status of an offline area
|
||||
enum OfflineAreaStatus { downloading, complete, error, cancelled }
|
||||
@@ -17,7 +17,7 @@ class OfflineArea {
|
||||
double progress; // 0.0 - 1.0
|
||||
int tilesDownloaded;
|
||||
int tilesTotal;
|
||||
List<OsmCameraNode> cameras;
|
||||
List<OsmNode> nodes;
|
||||
int sizeBytes; // Disk size in bytes
|
||||
final bool isPermanent; // Not user-deletable if true
|
||||
|
||||
@@ -38,7 +38,7 @@ class OfflineArea {
|
||||
this.progress = 0,
|
||||
this.tilesDownloaded = 0,
|
||||
this.tilesTotal = 0,
|
||||
this.cameras = const [],
|
||||
this.nodes = const [],
|
||||
this.sizeBytes = 0,
|
||||
this.isPermanent = false,
|
||||
this.tileProviderId,
|
||||
@@ -61,7 +61,7 @@ class OfflineArea {
|
||||
'progress': progress,
|
||||
'tilesDownloaded': tilesDownloaded,
|
||||
'tilesTotal': tilesTotal,
|
||||
'cameras': cameras.map((c) => c.toJson()).toList(),
|
||||
'nodes': nodes.map((n) => n.toJson()).toList(),
|
||||
'sizeBytes': sizeBytes,
|
||||
'isPermanent': isPermanent,
|
||||
'tileProviderId': tileProviderId,
|
||||
@@ -87,8 +87,8 @@ class OfflineArea {
|
||||
progress: (json['progress'] ?? 0).toDouble(),
|
||||
tilesDownloaded: json['tilesDownloaded'] ?? 0,
|
||||
tilesTotal: json['tilesTotal'] ?? 0,
|
||||
cameras: (json['cameras'] as List? ?? [])
|
||||
.map((e) => OsmCameraNode.fromJson(e)).toList(),
|
||||
nodes: (json['nodes'] as List? ?? json['cameras'] as List? ?? [])
|
||||
.map((e) => OsmNode.fromJson(e)).toList(),
|
||||
sizeBytes: json['sizeBytes'] ?? 0,
|
||||
isPermanent: json['isPermanent'] ?? false,
|
||||
tileProviderId: json['tileProviderId'],
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'offline_area_models.dart';
|
||||
import 'offline_tile_utils.dart';
|
||||
import 'package:deflockapp/dev_config.dart';
|
||||
|
||||
/// Manages the world area (permanent offline area for base map)
|
||||
class WorldAreaManager {
|
||||
static const String _worldAreaId = 'world';
|
||||
static const String _worldAreaName = 'World Base Map';
|
||||
|
||||
/// Ensure world area exists and check if download is needed
|
||||
static Future<OfflineArea> ensureWorldArea(
|
||||
List<OfflineArea> areas,
|
||||
Future<Directory> Function() getOfflineAreaDir,
|
||||
Future<void> Function({
|
||||
required String id,
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
required String directory,
|
||||
String? name,
|
||||
String? tileProviderId,
|
||||
String? tileProviderName,
|
||||
String? tileTypeId,
|
||||
String? tileTypeName,
|
||||
}) downloadArea,
|
||||
) async {
|
||||
// Find existing world area
|
||||
OfflineArea? world;
|
||||
for (final area in areas) {
|
||||
if (area.isPermanent) {
|
||||
world = area;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Create world area if it doesn't exist, or update existing area without provider info
|
||||
if (world == null) {
|
||||
final appDocDir = await getOfflineAreaDir();
|
||||
final dir = "${appDocDir.path}/$_worldAreaId";
|
||||
world = OfflineArea(
|
||||
id: _worldAreaId,
|
||||
name: _worldAreaName,
|
||||
bounds: globalWorldBounds(),
|
||||
minZoom: kWorldMinZoom,
|
||||
maxZoom: kWorldMaxZoom,
|
||||
directory: dir,
|
||||
status: OfflineAreaStatus.downloading,
|
||||
isPermanent: true,
|
||||
// World area always uses OpenStreetMap
|
||||
tileProviderId: 'openstreetmap',
|
||||
tileProviderName: 'OpenStreetMap',
|
||||
tileTypeId: 'osm_street',
|
||||
tileTypeName: 'Street Map',
|
||||
);
|
||||
areas.insert(0, world);
|
||||
} else if (world.tileProviderId == null || world.tileTypeId == null) {
|
||||
// Update existing world area that lacks provider metadata
|
||||
final updatedWorld = OfflineArea(
|
||||
id: world.id,
|
||||
name: world.name,
|
||||
bounds: world.bounds,
|
||||
minZoom: world.minZoom,
|
||||
maxZoom: world.maxZoom,
|
||||
directory: world.directory,
|
||||
status: world.status,
|
||||
progress: world.progress,
|
||||
tilesDownloaded: world.tilesDownloaded,
|
||||
tilesTotal: world.tilesTotal,
|
||||
cameras: world.cameras,
|
||||
sizeBytes: world.sizeBytes,
|
||||
isPermanent: world.isPermanent,
|
||||
// Add missing provider metadata
|
||||
tileProviderId: 'openstreetmap',
|
||||
tileProviderName: 'OpenStreetMap',
|
||||
tileTypeId: 'osm_street',
|
||||
tileTypeName: 'Street Map',
|
||||
);
|
||||
final index = areas.indexOf(world);
|
||||
areas[index] = updatedWorld;
|
||||
world = updatedWorld;
|
||||
}
|
||||
|
||||
// Check world area status and start download if needed
|
||||
await _checkAndStartWorldDownload(world, downloadArea);
|
||||
return world;
|
||||
}
|
||||
|
||||
/// Check world area download status and start if needed
|
||||
static Future<void> _checkAndStartWorldDownload(
|
||||
OfflineArea world,
|
||||
Future<void> Function({
|
||||
required String id,
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
required String directory,
|
||||
String? name,
|
||||
String? tileProviderId,
|
||||
String? tileProviderName,
|
||||
String? tileTypeId,
|
||||
String? tileTypeName,
|
||||
}) downloadArea,
|
||||
) async {
|
||||
if (world.status == OfflineAreaStatus.complete) return;
|
||||
|
||||
// Count existing tiles
|
||||
final expectedTiles = computeTileList(
|
||||
globalWorldBounds(),
|
||||
kWorldMinZoom,
|
||||
kWorldMaxZoom,
|
||||
);
|
||||
|
||||
int filesFound = 0;
|
||||
for (final tile in expectedTiles) {
|
||||
final file = File('${world.directory}/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png');
|
||||
if (file.existsSync()) {
|
||||
filesFound++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update world area stats
|
||||
world.tilesTotal = expectedTiles.length;
|
||||
world.tilesDownloaded = filesFound;
|
||||
world.progress = (world.tilesTotal == 0) ? 0.0 : (filesFound / world.tilesTotal);
|
||||
|
||||
if (filesFound == world.tilesTotal) {
|
||||
world.status = OfflineAreaStatus.complete;
|
||||
debugPrint('WorldAreaManager: World area download already complete.');
|
||||
} else {
|
||||
world.status = OfflineAreaStatus.downloading;
|
||||
debugPrint('WorldAreaManager: Starting world area download. ${world.tilesDownloaded}/${world.tilesTotal} tiles found.');
|
||||
|
||||
// Start download (fire and forget) - use OSM for world areas
|
||||
downloadArea(
|
||||
id: world.id,
|
||||
bounds: world.bounds,
|
||||
minZoom: world.minZoom,
|
||||
maxZoom: world.maxZoom,
|
||||
directory: world.directory,
|
||||
name: world.name,
|
||||
tileProviderId: 'openstreetmap',
|
||||
tileProviderName: 'OpenStreetMap',
|
||||
tileTypeId: 'osm_street',
|
||||
tileTypeName: 'Street Map',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
257
lib/services/proximity_alert_service.dart
Normal file
@@ -0,0 +1,257 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/osm_node.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
/// Simple data class for tracking recent proximity alerts to prevent spam
|
||||
class RecentAlert {
|
||||
final int nodeId;
|
||||
final DateTime alertTime;
|
||||
|
||||
RecentAlert({required this.nodeId, required this.alertTime});
|
||||
}
|
||||
|
||||
/// Service for handling proximity alerts when approaching surveillance nodes
|
||||
/// Follows brutalist principles: simple, explicit, easy to understand
|
||||
class ProximityAlertService {
|
||||
static final ProximityAlertService _instance = ProximityAlertService._internal();
|
||||
factory ProximityAlertService() => _instance;
|
||||
ProximityAlertService._internal();
|
||||
|
||||
FlutterLocalNotificationsPlugin? _notifications;
|
||||
bool _isInitialized = false;
|
||||
|
||||
// Simple in-memory tracking of recent alerts to prevent spam
|
||||
final List<RecentAlert> _recentAlerts = [];
|
||||
static const Duration _alertCooldown = kProximityAlertCooldown;
|
||||
|
||||
// Callback for showing in-app visual alerts
|
||||
VoidCallback? _onVisualAlert;
|
||||
|
||||
/// Initialize the notification plugin and request permissions
|
||||
Future<void> initialize({VoidCallback? onVisualAlert}) async {
|
||||
_onVisualAlert = onVisualAlert;
|
||||
|
||||
_notifications = FlutterLocalNotificationsPlugin();
|
||||
|
||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
|
||||
const initSettings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
);
|
||||
|
||||
try {
|
||||
final initialized = await _notifications!.initialize(initSettings);
|
||||
_isInitialized = initialized ?? false;
|
||||
|
||||
// Request notification permissions (especially important for Android 13+)
|
||||
if (_isInitialized) {
|
||||
await _requestNotificationPermissions();
|
||||
}
|
||||
|
||||
debugPrint('[ProximityAlertService] Initialized: $_isInitialized');
|
||||
} catch (e) {
|
||||
debugPrint('[ProximityAlertService] Failed to initialize: $e');
|
||||
_isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Request notification permissions on both platforms
|
||||
Future<void> _requestNotificationPermissions() async {
|
||||
if (_notifications == null) return;
|
||||
|
||||
try {
|
||||
// Request permissions - this will show the permission dialog on Android 13+
|
||||
final result = await _notifications!
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestNotificationsPermission();
|
||||
|
||||
debugPrint('[ProximityAlertService] Android notification permission result: $result');
|
||||
|
||||
// Also request for iOS (though this was already done in initialization)
|
||||
await _notifications!
|
||||
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[ProximityAlertService] Failed to request permissions: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Check proximity to nodes and trigger alerts if needed
|
||||
/// This should be called on GPS position updates
|
||||
Future<void> checkProximity({
|
||||
required LatLng userLocation,
|
||||
required List<OsmNode> nodes,
|
||||
required List<NodeProfile> enabledProfiles,
|
||||
required int alertDistance,
|
||||
}) async {
|
||||
if (!_isInitialized || nodes.isEmpty) return;
|
||||
|
||||
// Clean up old alerts (anything older than cooldown period)
|
||||
final cutoffTime = DateTime.now().subtract(_alertCooldown);
|
||||
_recentAlerts.removeWhere((alert) => alert.alertTime.isBefore(cutoffTime));
|
||||
|
||||
// Check each node for proximity
|
||||
for (final node in nodes) {
|
||||
// Skip if we recently alerted for this node
|
||||
if (_recentAlerts.any((alert) => alert.nodeId == node.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate distance using Geolocator's distanceBetween
|
||||
final distance = Geolocator.distanceBetween(
|
||||
userLocation.latitude,
|
||||
userLocation.longitude,
|
||||
node.coord.latitude,
|
||||
node.coord.longitude,
|
||||
);
|
||||
|
||||
// Check if within alert distance
|
||||
if (distance <= alertDistance) {
|
||||
// Determine node type for alert message
|
||||
final nodeType = _getNodeTypeDescription(node, enabledProfiles);
|
||||
|
||||
// Trigger both push notification and visual alert
|
||||
await _showNotification(node, nodeType, distance.round());
|
||||
_showVisualAlert();
|
||||
|
||||
// Track this alert to prevent spam
|
||||
_recentAlerts.add(RecentAlert(
|
||||
nodeId: node.id,
|
||||
alertTime: DateTime.now(),
|
||||
));
|
||||
|
||||
debugPrint('[ProximityAlertService] Alert triggered for node ${node.id} ($nodeType) at ${distance.round()}m');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show push notification for proximity alert
|
||||
Future<void> _showNotification(OsmNode node, String nodeType, int distance) async {
|
||||
if (!_isInitialized || _notifications == null) return;
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'proximity_alerts',
|
||||
'Proximity Alerts',
|
||||
channelDescription: 'Notifications when approaching surveillance devices',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
enableVibration: true,
|
||||
playSound: true,
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: false,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
final title = 'Surveillance Device Nearby';
|
||||
final body = '$nodeType detected ${distance}m ahead';
|
||||
|
||||
try {
|
||||
await _notifications!.show(
|
||||
node.id, // Use node ID as notification ID
|
||||
title,
|
||||
body,
|
||||
notificationDetails,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[ProximityAlertService] Failed to show notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger visual alert in the app UI
|
||||
void _showVisualAlert() {
|
||||
_onVisualAlert?.call();
|
||||
}
|
||||
|
||||
/// Get a user-friendly description of the node type
|
||||
String _getNodeTypeDescription(OsmNode node, List<NodeProfile> enabledProfiles) {
|
||||
final tags = node.tags;
|
||||
|
||||
// Check for specific surveillance types
|
||||
if (tags.containsKey('man_made') && tags['man_made'] == 'surveillance') {
|
||||
final surveillanceType = tags['surveillance:type'] ?? 'surveillance device';
|
||||
if (surveillanceType == 'camera') return 'Camera';
|
||||
if (surveillanceType == 'ALPR') return 'License plate reader';
|
||||
return 'Surveillance device';
|
||||
}
|
||||
|
||||
// Check for emergency devices
|
||||
if (tags.containsKey('emergency') && tags['emergency'] == 'siren') {
|
||||
return 'Emergency siren';
|
||||
}
|
||||
|
||||
// Fall back to checking enabled profiles to see what type this might be
|
||||
for (final profile in enabledProfiles) {
|
||||
bool matches = true;
|
||||
for (final entry in profile.tags.entries) {
|
||||
if (node.tags[entry.key] != entry.value) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches) {
|
||||
return profile.name;
|
||||
}
|
||||
}
|
||||
|
||||
return 'Surveillance device';
|
||||
}
|
||||
|
||||
/// Get count of recent alerts (for debugging/testing)
|
||||
int get recentAlertCount => _recentAlerts.length;
|
||||
|
||||
/// Clear recent alerts (for testing)
|
||||
void clearRecentAlerts() {
|
||||
_recentAlerts.clear();
|
||||
}
|
||||
|
||||
/// Check if notification permissions are granted
|
||||
Future<bool> areNotificationsEnabled() async {
|
||||
if (!_isInitialized || _notifications == null) return false;
|
||||
|
||||
try {
|
||||
// Check Android permissions
|
||||
final androidImpl = _notifications!
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
if (androidImpl != null) {
|
||||
final result = await androidImpl.areNotificationsEnabled();
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
// For iOS, assume enabled if we got this far (permissions were requested during init)
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[ProximityAlertService] Failed to check notification permissions: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Request permissions again (can be called from settings)
|
||||
Future<bool> requestNotificationPermissions() async {
|
||||
await _requestNotificationPermissions();
|
||||
return await areNotificationsEnabled();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import 'network_status.dart';
|
||||
class SimpleTileHttpClient extends http.BaseClient {
|
||||
final http.Client _inner = http.Client();
|
||||
final MapDataProvider _mapDataProvider = MapDataProvider();
|
||||
|
||||
// Tile completion tracking (brutalist approach)
|
||||
int _pendingTileRequests = 0;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
@@ -48,14 +51,14 @@ class SimpleTileHttpClient extends http.BaseClient {
|
||||
}
|
||||
|
||||
Future<http.StreamedResponse> _handleTileRequest(int z, int x, int y) async {
|
||||
// Increment pending counter (brutalist completion detection)
|
||||
_pendingTileRequests++;
|
||||
|
||||
try {
|
||||
// Always go through MapDataProvider - it handles offline/online routing
|
||||
// MapDataProvider will get current provider from AppState
|
||||
final tileBytes = await _mapDataProvider.getTile(z: z, x: x, y: y, source: MapSource.auto);
|
||||
|
||||
// Show success status briefly
|
||||
NetworkStatus.instance.setSuccess();
|
||||
|
||||
// Serve tile with proper cache headers
|
||||
return http.StreamedResponse(
|
||||
Stream.value(tileBytes),
|
||||
@@ -71,15 +74,18 @@ class SimpleTileHttpClient extends http.BaseClient {
|
||||
} catch (e) {
|
||||
debugPrint('[SimpleTileService] Could not get tile $z/$x/$y: $e');
|
||||
|
||||
// 404 means no tiles available - show "no data" status briefly
|
||||
NetworkStatus.instance.setNoData();
|
||||
|
||||
// Return 404 and let flutter_map handle it gracefully
|
||||
return http.StreamedResponse(
|
||||
Stream.value(<int>[]),
|
||||
404,
|
||||
reasonPhrase: 'Tile unavailable: $e',
|
||||
);
|
||||
} finally {
|
||||
// Decrement pending counter and report completion when all done
|
||||
_pendingTileRequests--;
|
||||
if (_pendingTileRequests == 0) {
|
||||
NetworkStatus.instance.reportTileComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
78
lib/services/tile_preview_service.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/tile_provider.dart';
|
||||
import '../state/settings_state.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
/// Service for fetching missing tile preview images
|
||||
class TilePreviewService {
|
||||
static const Duration _timeout = Duration(seconds: 10);
|
||||
|
||||
/// Attempt to fetch missing preview tiles for tile types that don't already have preview data
|
||||
/// Fails silently - no error handling or user notification on failure
|
||||
static Future<void> fetchMissingPreviews(SettingsState settingsState) async {
|
||||
try {
|
||||
bool anyUpdates = false;
|
||||
|
||||
for (final provider in settingsState.tileProviders) {
|
||||
final updatedTileTypes = <TileType>[];
|
||||
bool providerNeedsUpdate = false;
|
||||
|
||||
for (final tileType in provider.tileTypes) {
|
||||
// Only fetch if preview tile is missing
|
||||
if (tileType.previewTile == null) {
|
||||
// Skip if tile type requires API key but provider doesn't have one
|
||||
if (tileType.requiresApiKey && (provider.apiKey == null || provider.apiKey!.isEmpty)) {
|
||||
updatedTileTypes.add(tileType);
|
||||
continue;
|
||||
}
|
||||
|
||||
final previewData = await _fetchPreviewForTileType(tileType, provider.apiKey);
|
||||
if (previewData != null) {
|
||||
// Create updated tile type with preview data
|
||||
final updatedTileType = tileType.copyWith(previewTile: previewData);
|
||||
updatedTileTypes.add(updatedTileType);
|
||||
providerNeedsUpdate = true;
|
||||
} else {
|
||||
updatedTileTypes.add(tileType);
|
||||
}
|
||||
} else {
|
||||
updatedTileTypes.add(tileType);
|
||||
}
|
||||
}
|
||||
|
||||
if (providerNeedsUpdate) {
|
||||
final updatedProvider = provider.copyWith(tileTypes: updatedTileTypes);
|
||||
await settingsState.addOrUpdateTileProvider(updatedProvider);
|
||||
anyUpdates = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (anyUpdates) {
|
||||
debugPrint('TilePreviewService: Updated providers with new preview tiles');
|
||||
}
|
||||
} catch (e) {
|
||||
// Fail silently as requested
|
||||
debugPrint('TilePreviewService: Error during preview fetching: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Uint8List?> _fetchPreviewForTileType(TileType tileType, String? apiKey) async {
|
||||
try {
|
||||
final url = tileType.getTileUrl(kPreviewTileZoom, kPreviewTileX, kPreviewTileY, apiKey: apiKey);
|
||||
|
||||
final response = await http.get(Uri.parse(url)).timeout(_timeout);
|
||||
|
||||
if (response.statusCode == 200 && response.bodyBytes.isNotEmpty) {
|
||||
debugPrint('TilePreviewService: Fetched preview for ${tileType.name}');
|
||||
return response.bodyBytes;
|
||||
}
|
||||
} catch (e) {
|
||||
// Fail silently - just log for debugging
|
||||
debugPrint('TilePreviewService: Failed to fetch preview for ${tileType.name}: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ class Uploader {
|
||||
Uploader(this.accessToken, this.onSuccess, {this.uploadMode = UploadMode.production});
|
||||
|
||||
final String accessToken;
|
||||
final void Function() onSuccess;
|
||||
final void Function(int nodeId) onSuccess;
|
||||
final UploadMode uploadMode;
|
||||
|
||||
Future<bool> upload(PendingUpload p) async {
|
||||
@@ -17,7 +17,18 @@ class Uploader {
|
||||
print('Uploader: Starting upload for node at ${p.coord.latitude}, ${p.coord.longitude}');
|
||||
|
||||
// 1. open changeset
|
||||
final action = p.isEdit ? 'Update' : 'Add';
|
||||
String action;
|
||||
switch (p.operation) {
|
||||
case UploadOperation.create:
|
||||
action = 'Add';
|
||||
break;
|
||||
case UploadOperation.modify:
|
||||
action = 'Update';
|
||||
break;
|
||||
case UploadOperation.delete:
|
||||
action = 'Delete';
|
||||
break;
|
||||
}
|
||||
final csXml = '''
|
||||
<osm>
|
||||
<changeset>
|
||||
@@ -35,63 +46,100 @@ class Uploader {
|
||||
final csId = csResp.body.trim();
|
||||
print('Uploader: Created changeset ID: $csId');
|
||||
|
||||
// 2. create or update node
|
||||
final mergedTags = p.getCombinedTags();
|
||||
final tagsXml = mergedTags.entries.map((e) =>
|
||||
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
|
||||
|
||||
// 2. create, update, or delete node
|
||||
final http.Response nodeResp;
|
||||
final String nodeId;
|
||||
|
||||
if (p.isEdit) {
|
||||
// First, fetch the current node to get its version
|
||||
print('Uploader: Fetching current node ${p.originalNodeId} to get version...');
|
||||
final currentNodeResp = await _get('/api/0.6/node/${p.originalNodeId}');
|
||||
print('Uploader: Current node response: ${currentNodeResp.statusCode}');
|
||||
if (currentNodeResp.statusCode != 200) {
|
||||
print('Uploader: Failed to fetch current node');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse version from the response XML
|
||||
final currentNodeXml = currentNodeResp.body;
|
||||
final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml);
|
||||
if (versionMatch == null) {
|
||||
print('Uploader: Could not parse version from current node XML');
|
||||
return false;
|
||||
}
|
||||
final currentVersion = versionMatch.group(1)!;
|
||||
print('Uploader: Current node version: $currentVersion');
|
||||
|
||||
// Update existing node with version
|
||||
final nodeXml = '''
|
||||
<osm>
|
||||
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
$tagsXml
|
||||
</node>
|
||||
</osm>''';
|
||||
print('Uploader: Updating node ${p.originalNodeId}...');
|
||||
nodeResp = await _put('/api/0.6/node/${p.originalNodeId}', nodeXml);
|
||||
nodeId = p.originalNodeId.toString();
|
||||
} else {
|
||||
// Create new node
|
||||
final nodeXml = '''
|
||||
switch (p.operation) {
|
||||
case UploadOperation.create:
|
||||
// Create new node
|
||||
final mergedTags = p.getCombinedTags();
|
||||
final tagsXml = mergedTags.entries.map((e) =>
|
||||
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
|
||||
final nodeXml = '''
|
||||
<osm>
|
||||
<node changeset="$csId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
$tagsXml
|
||||
</node>
|
||||
</osm>''';
|
||||
print('Uploader: Creating new node...');
|
||||
nodeResp = await _put('/api/0.6/node/create', nodeXml);
|
||||
nodeId = nodeResp.body.trim();
|
||||
print('Uploader: Creating new node...');
|
||||
nodeResp = await _put('/api/0.6/node/create', nodeXml);
|
||||
nodeId = nodeResp.body.trim();
|
||||
break;
|
||||
|
||||
case UploadOperation.modify:
|
||||
// First, fetch the current node to get its version
|
||||
print('Uploader: Fetching current node ${p.originalNodeId} to get version...');
|
||||
final currentNodeResp = await _get('/api/0.6/node/${p.originalNodeId}');
|
||||
print('Uploader: Current node response: ${currentNodeResp.statusCode}');
|
||||
if (currentNodeResp.statusCode != 200) {
|
||||
print('Uploader: Failed to fetch current node');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse version from the response XML
|
||||
final currentNodeXml = currentNodeResp.body;
|
||||
final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml);
|
||||
if (versionMatch == null) {
|
||||
print('Uploader: Could not parse version from current node XML');
|
||||
return false;
|
||||
}
|
||||
final currentVersion = versionMatch.group(1)!;
|
||||
print('Uploader: Current node version: $currentVersion');
|
||||
|
||||
// Update existing node with version
|
||||
final mergedTags = p.getCombinedTags();
|
||||
final tagsXml = mergedTags.entries.map((e) =>
|
||||
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
|
||||
final nodeXml = '''
|
||||
<osm>
|
||||
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
$tagsXml
|
||||
</node>
|
||||
</osm>''';
|
||||
print('Uploader: Updating node ${p.originalNodeId}...');
|
||||
nodeResp = await _put('/api/0.6/node/${p.originalNodeId}', nodeXml);
|
||||
nodeId = p.originalNodeId.toString();
|
||||
break;
|
||||
|
||||
case UploadOperation.delete:
|
||||
// First, fetch the current node to get its version and coordinates
|
||||
print('Uploader: Fetching current node ${p.originalNodeId} for deletion...');
|
||||
final currentNodeResp = await _get('/api/0.6/node/${p.originalNodeId}');
|
||||
print('Uploader: Current node response: ${currentNodeResp.statusCode}');
|
||||
if (currentNodeResp.statusCode != 200) {
|
||||
print('Uploader: Failed to fetch current node');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse version and tags from the response XML
|
||||
final currentNodeXml = currentNodeResp.body;
|
||||
final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml);
|
||||
if (versionMatch == null) {
|
||||
print('Uploader: Could not parse version from current node XML');
|
||||
return false;
|
||||
}
|
||||
final currentVersion = versionMatch.group(1)!;
|
||||
print('Uploader: Current node version: $currentVersion');
|
||||
|
||||
// Delete node - OSM requires current tags and coordinates
|
||||
final nodeXml = '''
|
||||
<osm>
|
||||
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
</node>
|
||||
</osm>''';
|
||||
print('Uploader: Deleting node ${p.originalNodeId}...');
|
||||
nodeResp = await _delete('/api/0.6/node/${p.originalNodeId}', nodeXml);
|
||||
nodeId = p.originalNodeId.toString();
|
||||
break;
|
||||
}
|
||||
|
||||
print('Uploader: Node response: ${nodeResp.statusCode} - ${nodeResp.body}');
|
||||
if (nodeResp.statusCode != 200) {
|
||||
print('Uploader: Failed to ${p.isEdit ? "update" : "create"} node');
|
||||
print('Uploader: Failed to ${p.operation.name} node');
|
||||
return false;
|
||||
}
|
||||
print('Uploader: ${p.isEdit ? "Updated" : "Created"} node ID: $nodeId');
|
||||
print('Uploader: ${p.operation.name.capitalize()} node ID: $nodeId');
|
||||
|
||||
// 3. close changeset
|
||||
print('Uploader: Closing changeset...');
|
||||
@@ -99,7 +147,8 @@ class Uploader {
|
||||
print('Uploader: Close response: ${closeResp.statusCode}');
|
||||
|
||||
print('Uploader: Upload successful!');
|
||||
onSuccess();
|
||||
final nodeIdInt = int.parse(nodeId);
|
||||
onSuccess(nodeIdInt);
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('Uploader: Upload failed with error: $e');
|
||||
@@ -134,9 +183,21 @@ class Uploader {
|
||||
body: body,
|
||||
);
|
||||
|
||||
Future<http.Response> _delete(String path, String body) => http.delete(
|
||||
Uri.https(_host, path),
|
||||
headers: _headers,
|
||||
body: body,
|
||||
);
|
||||
|
||||
Map<String, String> get _headers => {
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
'Content-Type': 'text/xml',
|
||||
};
|
||||
}
|
||||
|
||||
extension StringExtension on String {
|
||||
String capitalize() {
|
||||
return "${this[0].toUpperCase()}${substring(1)}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ class AuthState extends ChangeNotifier {
|
||||
|
||||
try {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
_username = await _auth.login();
|
||||
_username = await _auth.restoreLogin();
|
||||
}
|
||||
} catch (e) {
|
||||
print("AuthState: Error during auth initialization: $e");
|
||||
@@ -44,7 +44,7 @@ class AuthState extends ChangeNotifier {
|
||||
Future<void> refreshAuthState() async {
|
||||
try {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
_username = await _auth.login();
|
||||
_username = await _auth.restoreLogin();
|
||||
} else {
|
||||
_username = null;
|
||||
}
|
||||
@@ -83,7 +83,7 @@ class AuthState extends ChangeNotifier {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
final isValid = await validateToken();
|
||||
if (isValid) {
|
||||
_username = await _auth.login();
|
||||
_username = await _auth.restoreLogin();
|
||||
} else {
|
||||
await logout(); // This clears _username also.
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../models/osm_node.dart';
|
||||
|
||||
// ------------------ AddNodeSession ------------------
|
||||
class AddNodeSession {
|
||||
@@ -23,7 +23,7 @@ class EditNodeSession {
|
||||
required this.target,
|
||||
});
|
||||
|
||||
final OsmCameraNode originalNode; // The original node being edited
|
||||
final OsmNode originalNode; // The original node being edited
|
||||
NodeProfile profile;
|
||||
OperatorProfile? operatorProfile;
|
||||
double directionDegrees;
|
||||
@@ -48,7 +48,7 @@ class SessionState extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void startEditSession(OsmCameraNode node, List<NodeProfile> enabledProfiles) {
|
||||
void startEditSession(OsmNode node, List<NodeProfile> enabledProfiles) {
|
||||
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
|
||||
// Try to find a matching profile based on the node's tags
|
||||
|
||||