mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-13 01:03:03 +00:00
Compare commits
167 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8e396a6eb | ||
|
|
75014be485 | ||
|
|
af42e18f6e | ||
|
|
c7cfdc471c | ||
|
|
19b3ca236e | ||
|
|
2e0dcb1b2b | ||
|
|
59afd75887 | ||
|
|
83370fba7e | ||
|
|
ba6c7cdbda | ||
|
|
311125e1f5 | ||
|
|
5abcc58a78 | ||
|
|
71776ee8f0 | ||
|
|
6607e30038 | ||
|
|
ef4205f4bd | ||
|
|
ef6fc1c9c8 | ||
|
|
26c85df7e8 | ||
|
|
20c1b9b108 | ||
|
|
0207f999ee | ||
|
|
4a342aee9d | ||
|
|
3827a6fa1d | ||
|
|
ed38e9467c | ||
|
|
d124cee9b3 | ||
|
|
c13dd8e58a | ||
|
|
037165653c | ||
|
|
98b73fe019 | ||
|
|
86e0d656d3 | ||
|
|
a149562001 | ||
|
|
e4b36719d7 | ||
|
|
3570104800 | ||
|
|
4fddd8e807 | ||
|
|
3dada20ec2 | ||
|
|
c712aba724 | ||
|
|
498e36f69d | ||
|
|
e2d0d1b790 | ||
|
|
61a2a99bbc | ||
|
|
7e67859b2f | ||
|
|
e559b86400 | ||
|
|
73160c32de | ||
|
|
d6f7e99941 | ||
|
|
0ffb7956c5 | ||
|
|
2620c8758e | ||
|
|
8804fdadf4 | ||
|
|
c50d43e00c | ||
|
|
5df16f376d | ||
|
|
38245bfb5b | ||
|
|
aba919f8d4 | ||
|
|
659cf5c0f0 | ||
|
|
ff5821b184 | ||
|
|
dd1838319d | ||
|
|
f76268f241 | ||
|
|
ba3b844c1e | ||
|
|
1dd0258c0b | ||
|
|
83d7814fb6 | ||
|
|
d095736078 | ||
|
|
77648db32f | ||
|
|
9a17d7e666 | ||
|
|
a34b786918 | ||
|
|
6707efebbe | ||
|
|
69ebd43e0d | ||
|
|
79d2fe711d | ||
|
|
4a36c52982 | ||
|
|
f478a3eb2d | ||
|
|
9621e5f35a | ||
|
|
f048ebc7db | ||
|
|
33ae6473bb | ||
|
|
0957670a15 | ||
|
|
3fc3a72cde | ||
|
|
1d65d5ecca | ||
|
|
1873d6e768 | ||
|
|
4638a18887 | ||
|
|
6bfdfadd97 | ||
|
|
72f3c9ee79 | ||
|
|
05e2e4e7c6 | ||
|
|
2e679c9a7e | ||
|
|
3ef053126b | ||
|
|
ae354c43a4 | ||
|
|
34eac41a96 | ||
|
|
816dadfbd1 | ||
|
|
607ecbafaf | ||
|
|
8b44b3abf5 | ||
|
|
a675cf185a | ||
|
|
26b479bf20 | ||
|
|
ae795a7607 | ||
|
|
a05e03567e | ||
|
|
da6887f7d3 | ||
|
|
89fb0d9bbd | ||
|
|
9db7c11a49 | ||
|
|
c3752fd17e | ||
|
|
aab4f6d445 | ||
|
|
f8643da8e2 | ||
|
|
45a72ede30 | ||
|
|
0c324fc78f | ||
|
|
42b5707d0e | ||
|
|
a941a5a5f0 | ||
|
|
6363cabacf | ||
|
|
5312456a15 | ||
|
|
8493679526 | ||
|
|
2047645e89 | ||
|
|
656dbc8ce8 | ||
|
|
eca227032d | ||
|
|
ca7bfc01ad | ||
|
|
4a4fc30828 | ||
|
|
e6b18bf89b | ||
|
|
6ed30dcff8 | ||
|
|
98e7e499d4 | ||
|
|
7fb467872a | ||
|
|
405ec220d0 | ||
|
|
56d55bb922 | ||
|
|
d665db868a | ||
|
|
b0d2ae22fe | ||
|
|
ffec43495b | ||
|
|
16b8acad3a | ||
|
|
4fba26ff55 | ||
|
|
b02623deac | ||
|
|
adbe8c340c | ||
|
|
8c4f53ff7b | ||
|
|
b1a39a2320 | ||
|
|
59064f7165 | ||
|
|
24214e94f9 | ||
|
|
6cda350f22 | ||
|
|
89f8ad2e0a | ||
|
|
cc1a335a49 | ||
|
|
473d65c83e | ||
|
|
b176724fc5 | ||
|
|
d6519a76bf | ||
|
|
14b4fb4a0a | ||
|
|
1583eca7a4 | ||
|
|
2aef3148f6 | ||
|
|
3f83d67bc1 | ||
|
|
043a036075 | ||
|
|
0ec53c3a11 | ||
|
|
9782352909 | ||
|
|
db5c7311b1 | ||
|
|
bb3d398c9c | ||
|
|
3d5edf320e | ||
|
|
a0601cd6ae | ||
|
|
5043ef3e34 | ||
|
|
d902495312 | ||
|
|
c81014d530 | ||
|
|
4d5a078378 | ||
|
|
31f6960d44 | ||
|
|
0d13fdee37 | ||
|
|
c4d9cd7986 | ||
|
|
bc03dcbe89 | ||
|
|
f3a5238f50 | ||
|
|
9e07439f08 | ||
|
|
dccafc898b | ||
|
|
560a5db14d | ||
|
|
df0377b41f | ||
|
|
153377e9e6 | ||
|
|
c6d73d42ee | ||
|
|
45f1635e10 | ||
|
|
2b2349dd16 | ||
|
|
3868236816 | ||
|
|
52af77e1ed | ||
|
|
c150e3ccee | ||
|
|
c6cc68c9b4 | ||
|
|
961465ebb5 | ||
|
|
7ff04851f4 | ||
|
|
3baed3c328 | ||
|
|
3ade06eef1 | ||
|
|
c7b70dddc4 | ||
|
|
d747c66990 | ||
|
|
5673c2b627 | ||
|
|
32a0ac17ad | ||
|
|
dec957790c | ||
|
|
9319bbda48 |
26
.github/workflows/pr.yml
vendored
Normal file
26
.github/workflows/pr.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Validate PR
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Analyze & Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
|
||||
- run: flutter pub get
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze
|
||||
|
||||
- name: Test
|
||||
run: flutter test
|
||||
12
.github/workflows/workflow.yml
vendored
12
.github/workflows/workflow.yml
vendored
@@ -58,8 +58,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Validate localizations
|
||||
run: dart run scripts/validate_localizations.dart
|
||||
- name: Run tests
|
||||
run: flutter test
|
||||
|
||||
- name: Generate icons and splash screens
|
||||
run: |
|
||||
@@ -110,8 +110,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Validate localizations
|
||||
run: dart run scripts/validate_localizations.dart
|
||||
- name: Run tests
|
||||
run: flutter test
|
||||
|
||||
- name: Generate icons and splash screens
|
||||
run: |
|
||||
@@ -155,8 +155,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Validate localizations
|
||||
run: dart run scripts/validate_localizations.dart
|
||||
- name: Run tests
|
||||
run: flutter test
|
||||
|
||||
- name: Generate icons and splash screens
|
||||
run: |
|
||||
|
||||
363
DEVELOPER.md
363
DEVELOPER.md
@@ -72,6 +72,7 @@ The app includes a comprehensive system for welcoming new users and notifying ex
|
||||
### Components
|
||||
- **ChangelogService**: Manages version tracking and changelog loading
|
||||
- **WelcomeDialog**: First launch popup with privacy information and quick links
|
||||
- **SubmissionGuideDialog**: One-time popup before first node submission with best practices
|
||||
- **ChangelogDialog**: Update notification popup for version changes
|
||||
- **ReleaseNotesScreen**: Settings page for viewing all changelog history
|
||||
|
||||
@@ -96,9 +97,15 @@ Changelog content is stored in `assets/changelog.json`:
|
||||
|
||||
### User Experience Flow
|
||||
- **First Launch**: Welcome popup with "don't show again" option
|
||||
- **Location Permission**: Requested immediately after welcome dialog on first launch (v2.6.3+)
|
||||
- **First Submission**: Submission guide popup with best practices and resource links
|
||||
- **Version Updates**: Changelog popup (only if content exists, no "don't show again")
|
||||
- **Settings Access**: Complete changelog history available in Settings > About > Release Notes
|
||||
|
||||
### Permission Handling (Updated v2.6.3)
|
||||
- **Location Permission**: Requested on first launch for core GPS functionality
|
||||
- **Notification Permission**: Requested on-demand when user enables proximity alerts
|
||||
|
||||
### Privacy Integration
|
||||
The welcome popup explains that the app:
|
||||
- Runs entirely locally on device
|
||||
@@ -123,7 +130,7 @@ The welcome popup explains that the app:
|
||||
|
||||
**Key methods:**
|
||||
- `getNodes()`: Returns cache immediately, triggers pre-fetch if needed (spatial or temporal)
|
||||
- `getTile()`: Tile fetching with enhanced retry strategy (6 attempts, 1-8s delays)
|
||||
- `getTile()`: Tile fetching with unlimited retry strategy (retries until success)
|
||||
- `_fetchRemoteNodes()`: Handles Overpass → OSM API fallback
|
||||
|
||||
**Smart caching flow:**
|
||||
@@ -180,24 +187,66 @@ class AddNodeSession {
|
||||
**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
|
||||
### 3. Upload Queue System & Three-Stage Upload Process
|
||||
|
||||
**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
|
||||
- **Three explicit stages**: Create changeset → Upload node → Close changeset
|
||||
- **Operation-agnostic**: Same queue handles create/modify/delete/extract
|
||||
- **Offline-capable**: Queue persists between app sessions
|
||||
- **Visual feedback**: Each operation type and stage has distinct UI state
|
||||
- **Stage-specific error recovery**: Appropriate retry logic for each of the 3 stages
|
||||
|
||||
**Queue workflow:**
|
||||
1. User action (add/edit/delete) → `PendingUpload` created
|
||||
**Three-stage upload workflow:**
|
||||
1. **Stage 1 - Create Changeset**: Generate changeset XML and create on OSM
|
||||
- Retries: Up to 3 attempts with 20s delays
|
||||
- Failures: Reset to pending for full retry
|
||||
2. **Stage 2 - Node Operation**: Create/modify/delete the surveillance node
|
||||
- Retries: Up to 3 attempts with 20s delays
|
||||
- Failures: Close orphaned changeset, then retry from stage 1
|
||||
3. **Stage 3 - Close Changeset**: Close the changeset to finalize
|
||||
- Retries: Exponential backoff up to 59 minutes
|
||||
- Failures: OSM auto-closes after 60 minutes, so we eventually give up
|
||||
|
||||
**Queue processing workflow (v2.3.0+ concurrent processing):**
|
||||
1. User action (add/edit/delete) → `PendingUpload` created with `UploadState.pending`
|
||||
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
|
||||
3. Background uploader starts new uploads every 5 seconds (configurable via `kUploadQueueProcessingInterval`):
|
||||
- **Concurrency limit**: Maximum 5 uploads processing simultaneously (`kMaxConcurrentUploads`)
|
||||
- **Individual lifecycles**: Each upload processes through all three stages independently
|
||||
- **Timer role**: Only used to start new pending uploads, not control stage progression
|
||||
4. Each upload processes through stages without waiting for other uploads:
|
||||
- **Pending** → Create changeset → **CreatingChangeset** → **Uploading**
|
||||
- **Uploading** → Upload node → **ClosingChangeset**
|
||||
- **ClosingChangeset** → Close changeset → **Complete**
|
||||
5. Success → cache updated with real data, temp markers removed
|
||||
6. Failures → appropriate retry logic based on which stage failed
|
||||
|
||||
**Performance improvement (v2.3.0):**
|
||||
- **Before**: Sequential processing with 10-second delays between each stage of each upload
|
||||
- **After**: Concurrent processing with uploads completing in 10-30 seconds regardless of queue size
|
||||
- **User benefit**: 3-5x faster upload processing for users with good internet connections
|
||||
|
||||
**Why three explicit stages:**
|
||||
The previous implementation conflated changeset creation + node operation as one step, making error handling unclear. The new approach:
|
||||
- **Tracks which stage failed**: Users see exactly what went wrong
|
||||
- **Handles step 2 failures correctly**: Node operation failures now properly close orphaned changesets
|
||||
- **Provides clear UI feedback**: "Creating changeset...", "Uploading...", "Closing changeset..."
|
||||
- **Enables appropriate retry logic**: Different stages have different retry needs
|
||||
|
||||
**Stage-specific error handling:**
|
||||
- **Stage 1 failure**: Simple retry (no cleanup needed)
|
||||
- **Stage 2 failure**: Close orphaned changeset, then retry from stage 1
|
||||
- **Stage 3 failure**: Keep retrying with exponential backoff (most important for OSM data integrity)
|
||||
|
||||
**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.
|
||||
|
||||
**Queue persistence & cache synchronization (v1.5.4+):**
|
||||
- **Startup repopulation**: Queue initialization now repopulates cache with pending nodes, ensuring visual continuity after app restarts
|
||||
- **Specific node cleanup**: Each upload stores a `tempNodeId` for precise removal, preventing accidental cleanup of other pending nodes at the same location
|
||||
- **Proximity awareness**: Proximity warnings now consider pending nodes to prevent duplicate submissions at the same location
|
||||
- **Processing status UI**: Upload queue screen shows clear indicators when processing is paused due to offline mode or user settings
|
||||
|
||||
### 4. Cache & Visual States
|
||||
|
||||
**Node visual states:**
|
||||
@@ -207,6 +256,10 @@ Users expect instant response to their actions. By immediately updating the cach
|
||||
- **Orange ring**: Node currently being edited
|
||||
- **Red ring**: Nodes pending deletion
|
||||
|
||||
**Node dimming behavior:**
|
||||
- **Dimmed (50% opacity)**: Non-selected nodes when a specific node is selected for tag viewing, or all nodes during search/navigation modes
|
||||
- **Selection persistence**: When viewing a node's tag sheet, other nodes remain dimmed even when the map is moved, until the sheet is closed (v2.1.3+ fix)
|
||||
|
||||
**Direction cone visual states:**
|
||||
- **Full opacity**: Active session direction (currently being edited)
|
||||
- **Reduced opacity (40%)**: Inactive session directions
|
||||
@@ -249,15 +302,55 @@ These are internal app tags, not OSM tags. The underscore prefix makes this expl
|
||||
- **Rate limiting**: Extended backoff (30s), no splitting (would make it worse)
|
||||
- **Surgical detection**: Only splits on actual limit errors, not network issues
|
||||
|
||||
**Query optimization:**
|
||||
**Query optimization & deduplication:**
|
||||
- **Pre-fetch limit**: 4x user's display limit (e.g., 1000 nodes for 250 display limit)
|
||||
- **Profile deduplication**: Automatically removes redundant profiles from queries using subsumption analysis
|
||||
- **User-initiated detection**: Only reports loading status for user-facing operations
|
||||
- **Background operations**: Pre-fetch runs silently, doesn't trigger loading states
|
||||
|
||||
**Why this approach:**
|
||||
Dense urban areas (SF, NYC) with many profiles enabled can easily exceed both 50k node limits and 25s timeouts. Splitting reduces query complexity while surgical error detection avoids unnecessary API load from network issues.
|
||||
**Profile subsumption optimization (v2.1.1+):**
|
||||
To reduce Overpass query complexity, profiles are deduplicated before query generation:
|
||||
- **Subsumption rule**: Profile A subsumes profile B if all of A's non-empty tags exist in B with identical values
|
||||
- **Example**: `Generic ALPR` (tags: `man_made=surveillance, surveillance:type=ALPR`) subsumes `Flock` (same tags + `manufacturer=Flock Safety`)
|
||||
- **Result**: Default profile set reduces from ~11 to ~2 query clauses (Generic ALPR + Generic Gunshot)
|
||||
- **UI unchanged**: All enabled profiles still used for post-query filtering and display matching
|
||||
|
||||
### 6. Offline vs Online Mode Behavior
|
||||
**Why this approach:**
|
||||
Dense urban areas (SF, NYC) with many profiles enabled can easily exceed both 50k node limits and 25s timeouts. Profile deduplication reduces query complexity by ~80% for default setups, while automatic splitting handles remaining edge cases. Surgical error detection avoids unnecessary API load from network issues.
|
||||
|
||||
### 6. Uploader Service Architecture (Refactored v1.5.3)
|
||||
|
||||
**Three-method approach:**
|
||||
The `Uploader` class now provides three distinct methods matching the OSM API workflow:
|
||||
|
||||
```dart
|
||||
// Step 1: Create changeset
|
||||
Future<UploadResult> createChangeset(PendingUpload p) async
|
||||
|
||||
// Step 2: Perform node operation (create/modify/delete/extract)
|
||||
Future<UploadResult> performNodeOperation(PendingUpload p, String changesetId) async
|
||||
|
||||
// Step 3: Close changeset
|
||||
Future<UploadResult> closeChangeset(String changesetId) async
|
||||
```
|
||||
|
||||
**Simplified UploadResult:**
|
||||
Replaced complex boolean flags with simple success/failure:
|
||||
```dart
|
||||
UploadResult.success({changesetId, nodeId}) // Operation succeeded
|
||||
UploadResult.failure({errorMessage, ...}) // Operation failed with details
|
||||
```
|
||||
|
||||
**Legacy compatibility:**
|
||||
The `upload()` method still exists for simulate mode and backwards compatibility, but now internally calls the three-step methods in sequence.
|
||||
|
||||
**Why this architecture:**
|
||||
- **Brutalist simplicity**: Each method does exactly one thing
|
||||
- **Clear failure points**: No confusion about which step failed
|
||||
- **Easier testing**: Each stage can be unit tested independently
|
||||
- **Better error messages**: Specific failure context for each stage
|
||||
|
||||
### 7. Offline vs Online Mode Behavior
|
||||
|
||||
**Mode combinations:**
|
||||
```
|
||||
@@ -270,20 +363,22 @@ 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.
|
||||
|
||||
### 7. Proximity Alerts & Background Monitoring
|
||||
### 8. Proximity Alerts & Background Monitoring
|
||||
|
||||
**Design approach:**
|
||||
- **Simple cooldown system**: In-memory tracking to prevent notification spam
|
||||
- **Dual alert types**: Push notifications (background) and visual banners (foreground)
|
||||
- **Configurable distance**: 25-200 meter alert radius
|
||||
- **Battery awareness**: Users explicitly opt into background location monitoring
|
||||
- **On-demand permissions**: Notification permission requested only when user enables proximity alerts (v2.6.3+)
|
||||
|
||||
**Implementation notes:**
|
||||
- Uses Flutter Local Notifications for cross-platform background alerts
|
||||
- Simple RecentAlert tracking prevents duplicate notifications
|
||||
- Visual callback system for in-app alerts when app is active
|
||||
- Permission requests deferred until feature activation for better UX
|
||||
|
||||
### 8. Compass Indicator & North Lock
|
||||
### 9. Compass Indicator & North Lock
|
||||
|
||||
**Purpose**: Visual compass showing map orientation with optional north-lock functionality
|
||||
|
||||
@@ -307,25 +402,88 @@ Local cache contains production data. Showing production nodes in sandbox mode w
|
||||
**Why separate from follow mode:**
|
||||
Users often want to follow their location while keeping the map oriented north. Previous "north up" follow mode was confusing because it didn't actually keep north up. This separation provides clear, predictable behavior.
|
||||
|
||||
### 9. Suspected Locations
|
||||
### 10. Network Status Indicator (Refactored in v2.6.1)
|
||||
|
||||
**Purpose**: Show loading and error states for surveillance data fetching only
|
||||
|
||||
**Brutalist approach (v2.6.1+):**
|
||||
- **Single enum state**: Replaced multiple boolean flags with simple `NetworkRequestStatus` enum
|
||||
- **User-initiated only**: Only tracks latest user-initiated requests (pan/zoom), background requests ignored
|
||||
- **Auto-reset timers**: Different timeouts for different states (success: 2s, errors: 5s, rate limit: 2min)
|
||||
|
||||
**Status states:**
|
||||
```dart
|
||||
enum NetworkRequestStatus {
|
||||
idle, // No indicator shown
|
||||
loading, // Initial request in progress
|
||||
splitting, // Request being split due to limits/timeouts
|
||||
success, // Data loaded successfully (brief confirmation)
|
||||
timeout, // Request timed out
|
||||
rateLimited, // API rate limited
|
||||
noData, // No offline data available
|
||||
error, // Other network errors
|
||||
}
|
||||
```
|
||||
|
||||
**State transitions:**
|
||||
- **Normal flow**: `idle` → `loading` → `success` → `idle`
|
||||
- **Split requests**: `loading` → `splitting` → `success` → `idle`
|
||||
- **Errors**: `loading` → `timeout/error/rateLimited` → `idle`
|
||||
|
||||
**Why the refactor:**
|
||||
The previous system used multiple boolean flags with complex reconciliation logic, creating potential for conflicting states and race conditions. The new enum-based approach provides clear, explicit state management with predictable transitions and eliminates complexity while maintaining all functionality.
|
||||
|
||||
**Request ownership:**
|
||||
Only user-initiated requests (pan, zoom, manual refresh) report status. Background requests (pre-fetch, cache warming) complete silently to avoid status indicator noise.
|
||||
|
||||
### 11. Suspected Locations (v1.8.0+: SQLite Database Storage)
|
||||
|
||||
**Data pipeline:**
|
||||
- **CSV ingestion**: Downloads utility permit data from alprwatch.org
|
||||
- **CSV ingestion**: Downloads utility permit data from alprwatch.org (100MB+ datasets)
|
||||
- **SQLite storage**: Batch insertion into database with geographic indexing (v1.8.0+)
|
||||
- **Dynamic field parsing**: Stores all CSV columns (except `location` and `ticket_no`) for flexible display
|
||||
- **GeoJSON processing**: Handles Point, Polygon, and MultiPolygon geometries
|
||||
- **Proximity filtering**: Hides suspected locations near confirmed devices
|
||||
- **Regional availability**: Currently select locations, expanding regularly
|
||||
|
||||
**Storage architecture (v1.8.0+):**
|
||||
- **Database**: SQLite with spatial indexing for efficient geographic queries
|
||||
- **Hybrid caching**: Sync cache for immediate UI response + async database queries
|
||||
- **Memory efficiency**: No longer loads entire dataset into memory
|
||||
- **Legacy migration**: Automatic migration from SharedPreferences to SQLite
|
||||
|
||||
**Performance improvements:**
|
||||
- **Startup time**: Reduced from 5-15 seconds to <1 second
|
||||
- **Memory usage**: Reduced from 200-400MB to <10MB
|
||||
- **Query time**: Reduced from 100-500ms to 10-50ms with indexed queries
|
||||
- **Progressive loading**: UI shows cached results immediately, updates with fresh data
|
||||
|
||||
**Display approach:**
|
||||
- **Required fields**: `ticket_no` (for heading) and `location` (for map positioning)
|
||||
- **Dynamic display**: All other CSV fields shown automatically, no hardcoded field list
|
||||
- **Server control**: Field names and content controlled server-side via CSV headers
|
||||
- **Brutalist rendering**: Fields displayed as-is from CSV, empty fields hidden
|
||||
|
||||
**Database schema:**
|
||||
```sql
|
||||
CREATE TABLE suspected_locations (
|
||||
ticket_no TEXT PRIMARY KEY,
|
||||
centroid_lat REAL NOT NULL,
|
||||
centroid_lng REAL NOT NULL,
|
||||
bounds TEXT,
|
||||
geo_json TEXT,
|
||||
all_fields TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_centroid ON suspected_locations (centroid_lat, centroid_lng);
|
||||
```
|
||||
|
||||
**Why utility permits:**
|
||||
Utility companies often must file permits when installing surveillance infrastructure. This creates a paper trail that can indicate potential surveillance sites before devices are confirmed through direct observation.
|
||||
|
||||
### 10. Upload Mode Simplification
|
||||
**Why SQLite migration:**
|
||||
The original SharedPreferences approach became untenable as the CSV dataset grew beyond 100MB, causing memory pressure and long startup times. SQLite provides efficient storage and querying while maintaining the simple, brutalist architecture the project follows.
|
||||
|
||||
### 12. Upload Mode Simplification
|
||||
|
||||
**Release vs Debug builds:**
|
||||
- **Release builds**: Production OSM only (simplified UX)
|
||||
@@ -338,12 +496,67 @@ Most users should contribute to production; testing modes add complexity
|
||||
bool get showUploadModeSelector => kDebugMode;
|
||||
```
|
||||
|
||||
### 11. Navigation & Routing (Implemented, Awaiting Integration)
|
||||
### 13. Tile Provider System & Clean Architecture (v1.5.2+)
|
||||
|
||||
**Architecture (post-v1.5.2):**
|
||||
- **Custom TileProvider**: Clean Flutter Map integration using `DeflockTileProvider`
|
||||
- **Direct MapDataProvider integration**: Tiles go through existing offline/online routing
|
||||
- **No HTTP interception**: Eliminated fake URLs and complex HTTP clients
|
||||
- **Simplified caching**: Single cache layer (FlutterMap's internal cache)
|
||||
|
||||
**Key components:**
|
||||
- `DeflockTileProvider`: Custom Flutter Map TileProvider implementation
|
||||
- `DeflockTileImageProvider`: Handles tile fetching through MapDataProvider
|
||||
- Automatic offline/online routing: Uses `MapSource.auto` for each tile
|
||||
|
||||
**Tile provider configuration:**
|
||||
- **Flexible URL templates**: Support multiple coordinate systems and load-balancing patterns
|
||||
- **Built-in providers**: Curated set of high-quality, reliable tile sources
|
||||
- **Custom providers**: Users can add any tile service with full validation
|
||||
- **API key management**: Secure storage with per-provider API keys
|
||||
|
||||
**Supported URL placeholders:**
|
||||
```
|
||||
{x}, {y}, {z} - Standard TMS tile coordinates
|
||||
{quadkey} - Bing Maps quadkey format (alternative to x/y/z)
|
||||
{0_3} - Subdomain 0-3 for load balancing
|
||||
{1_4} - Subdomain 1-4 for providers using 1-based indexing
|
||||
{api_key} - API key insertion point (optional)
|
||||
```
|
||||
|
||||
**Built-in providers:**
|
||||
- **OpenStreetMap**: Standard street map tiles, no API key required
|
||||
- **Bing Maps**: High-quality satellite imagery using quadkey system, no API key required
|
||||
- **Mapbox**: Satellite and street tiles, requires API key
|
||||
- **OpenTopoMap**: Topographic maps, no API key required
|
||||
|
||||
**Why the architectural change:**
|
||||
The previous HTTP interception approach (`SimpleTileHttpClient` with fake URLs) fought against Flutter Map's architecture and created unnecessary complexity. The new `TileProvider` approach:
|
||||
- **Cleaner integration**: Works with Flutter Map's design instead of against it
|
||||
- **Smart cache routing**: Only checks offline cache when needed, eliminating expensive filesystem searches
|
||||
- **Better error handling**: Graceful fallbacks for missing tiles
|
||||
- **Cross-platform performance**: Optimizations that work well on both iOS and Android
|
||||
|
||||
**Tile Loading Performance Fix (v1.5.2):**
|
||||
The major performance issue was discovered to be double caching with expensive operations:
|
||||
1. **Problem**: Every tile request checked offline areas via filesystem I/O, even when no offline data existed
|
||||
2. **Solution**: Smart cache detection - only check offline cache when in offline mode OR when offline areas actually exist for the current provider
|
||||
3. **Result**: Dramatically improved tile loading from 0.5-5 tiles/sec back to ~70 tiles/sec for normal browsing
|
||||
|
||||
**Cross-Platform Optimizations:**
|
||||
- **Request deduplication**: Prevents multiple simultaneous requests for identical tile coordinates
|
||||
- **Optimized retry timing**: Faster initial retry (150ms vs 200ms) with shorter backoff for quicker recovery
|
||||
- **Queue size limits**: Maximum 100 queued requests to prevent memory bloat
|
||||
- **Smart queue management**: Drops oldest requests when queue fills up
|
||||
- **Reduced concurrent connections**: 8 threads instead of 10 for better stability across platforms
|
||||
|
||||
### 14. Navigation & Routing (Implemented and Active)
|
||||
|
||||
**Current state:**
|
||||
- **Search functionality**: Fully implemented and active
|
||||
- **Basic routing**: Complete but disabled pending API integration
|
||||
- **Avoidance routing**: Awaiting alprwatch.org/directions API
|
||||
- **Avoidance routing**: Fully implemented and active
|
||||
- **Distance feedback**: Shows real-time distance when selecting second route point
|
||||
- **Long distance warnings**: Alerts users when routes may timeout (configurable threshold)
|
||||
- **Offline routing**: Requires vector map tiles
|
||||
|
||||
**Architecture:**
|
||||
@@ -351,6 +564,12 @@ bool get showUploadModeSelector => kDebugMode;
|
||||
- RoutingService handles API communication and route calculation
|
||||
- SearchService provides location lookup and geocoding
|
||||
|
||||
**Distance warning system (v1.7.0):**
|
||||
- **Real-time distance display**: Shows distance from first to second point during selection
|
||||
- **Configurable threshold**: `kNavigationDistanceWarningThreshold` in dev_config (default 30km)
|
||||
- **User feedback**: Warning message about potential timeouts for long routes
|
||||
- **Brutalist approach**: Simple distance calculation using existing `Distance()` utility
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions & Rationales
|
||||
@@ -581,32 +800,104 @@ The app uses a **clean, release-triggered workflow** that rebuilds from scratch
|
||||
## Build & Development Setup
|
||||
|
||||
### Prerequisites
|
||||
- **Flutter SDK**: Latest stable version
|
||||
- **Xcode**: For iOS builds (macOS only)
|
||||
- **Android Studio**: For Android builds
|
||||
- **Git**: For version control
|
||||
|
||||
**macOS** (required for iOS builds; Android-only contributors can use macOS or Linux):
|
||||
|
||||
| Tool | Install | Notes |
|
||||
|------|---------|-------|
|
||||
| **Homebrew** | [brew.sh](https://brew.sh) | Package manager for macOS |
|
||||
| **Flutter SDK 3.35+** | `brew install --cask flutter` | Installs Flutter + Dart (3.35+ required for RadioGroup widget) |
|
||||
| **Xcode** | Mac App Store | Required for iOS builds |
|
||||
| **CocoaPods** | `brew install cocoapods` | Required for iOS plugin resolution |
|
||||
| **Android SDK** | See below | Required for Android builds |
|
||||
| **Java 17+** | `brew install --cask temurin` | Required by Android toolchain (skip if already installed) |
|
||||
|
||||
After installing, verify with:
|
||||
```bash
|
||||
flutter doctor # All checks should be green
|
||||
```
|
||||
|
||||
### Android SDK Setup (without Android Studio)
|
||||
|
||||
You don't need the full Android Studio IDE. Install the command-line tools and let Flutter's build system pull what it needs:
|
||||
|
||||
```bash
|
||||
# 1. Install command-line tools
|
||||
brew install --cask android-commandlinetools
|
||||
|
||||
# 2. Create the SDK directory and install required components
|
||||
mkdir -p ~/Library/Android/sdk/licenses
|
||||
# Write license acceptance hashes
|
||||
printf "\n24333f8a63b6825ea9c5514f83c2829b004d1fee" > ~/Library/Android/sdk/licenses/android-sdk-license
|
||||
printf "\n84831b9409646a918e30573bab4c9c91346d8abd" > ~/Library/Android/sdk/licenses/android-sdk-preview-license
|
||||
|
||||
# 3. Install platform tools and the SDK platform Flutter needs
|
||||
"$(brew --prefix android-commandlinetools)/cmdline-tools/latest/bin/sdkmanager" \
|
||||
--sdk_root="$HOME/Library/Android/sdk" \
|
||||
"platform-tools" "platforms;android-36" "build-tools;35.0.0"
|
||||
|
||||
# 4. Copy cmdline-tools into the SDK root (Flutter expects them there)
|
||||
mkdir -p ~/Library/Android/sdk/cmdline-tools
|
||||
cp -R "$(brew --prefix android-commandlinetools)/cmdline-tools/latest" \
|
||||
~/Library/Android/sdk/cmdline-tools/latest
|
||||
|
||||
# 5. Point Flutter at the SDK and accept licenses
|
||||
flutter config --android-sdk ~/Library/Android/sdk
|
||||
yes | flutter doctor --android-licenses
|
||||
```
|
||||
|
||||
> **Note:** The first `flutter build apk` will auto-download additional components it needs (NDK, CMake, etc). This is normal and only happens once.
|
||||
|
||||
### OAuth2 Setup
|
||||
|
||||
**Required registrations:**
|
||||
To run the app with working OSM authentication, register OAuth2 applications:
|
||||
|
||||
1. **Production OSM**: https://www.openstreetmap.org/oauth2/applications
|
||||
2. **Sandbox OSM**: https://master.apis.dev.openstreetmap.org/oauth2/applications
|
||||
|
||||
**Configuration:**
|
||||
For local builds, create `build_keys.conf` (gitignored):
|
||||
```bash
|
||||
cp lib/keys.dart.example lib/keys.dart
|
||||
# Edit keys.dart with your OAuth2 client IDs
|
||||
cp build_keys.conf.example build_keys.conf
|
||||
# Edit build_keys.conf with your OAuth2 client IDs
|
||||
```
|
||||
|
||||
### iOS Setup
|
||||
You can also pass keys directly via `--dart-define`:
|
||||
```bash
|
||||
cd ios && pod install
|
||||
flutter run --dart-define=OSM_PROD_CLIENTID=your_id --dart-define=OSM_SANDBOX_CLIENTID=your_id
|
||||
```
|
||||
|
||||
### First Build
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
flutter pub get
|
||||
|
||||
# 2. Generate icons and splash screens (gitignored, must be regenerated)
|
||||
./gen_icons_splashes.sh
|
||||
|
||||
# 3. Build Android
|
||||
flutter build apk --debug \
|
||||
--dart-define=OSM_PROD_CLIENTID=your_id \
|
||||
--dart-define=OSM_SANDBOX_CLIENTID=your_id
|
||||
|
||||
# 4. Build iOS (macOS only, no signing needed for testing)
|
||||
flutter build ios --no-codesign \
|
||||
--dart-define=OSM_PROD_CLIENTID=your_id \
|
||||
--dart-define=OSM_SANDBOX_CLIENTID=your_id
|
||||
```
|
||||
|
||||
> **Important:** You must run `./gen_icons_splashes.sh` before the first build. The generated icons and splash screen assets are gitignored, so the build will fail without this step.
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter run
|
||||
# Run on connected device or simulator
|
||||
flutter run --dart-define=OSM_PROD_CLIENTID=your_id --dart-define=OSM_SANDBOX_CLIENTID=your_id
|
||||
|
||||
# Or use the build script (reads keys from build_keys.conf)
|
||||
./do_builds.sh # Both platforms
|
||||
./do_builds.sh --android # Android only
|
||||
./do_builds.sh --ios # iOS only
|
||||
```
|
||||
|
||||
### Testing
|
||||
@@ -702,4 +993,4 @@ debugPrint('[ComponentName] Detailed message: $data');
|
||||
|
||||
---
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
63
README.md
63
README.md
@@ -21,7 +21,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
||||
- **Map surveillance infrastructure** including cameras, ALPRs, gunshot detectors, and more with precise location, direction, and manufacturer details
|
||||
- **Upload to OpenStreetMap** with OAuth2 integration (live or sandbox modes)
|
||||
- **Work completely offline** with downloadable map areas and device data, plus upload queue
|
||||
- **Multiple map types** including satellite imagery from USGS, Esri, Mapbox, and topographic maps from OpenTopoMap, plus custom map tile provider support
|
||||
- **Multiple map types** including satellite imagery from Bing Maps, USGS, Esri, Mapbox, and topographic maps from OpenTopoMap, plus custom map tile provider support
|
||||
- **Editing Ability** to update existing device locations and properties
|
||||
- **Built-in device profiles** for Flock Safety, Motorola, Genetec, Leonardo, and other major manufacturers, plus custom profiles for more specific tag sets
|
||||
|
||||
@@ -30,7 +30,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
||||
## Key Features
|
||||
|
||||
### Map & Navigation
|
||||
- **Multi-source tiles**: Switch between OpenStreetMap, USGS imagery, Esri imagery, Mapbox, OpenTopoMap, and any custom providers
|
||||
- **Multi-source tiles**: Switch between OpenStreetMap, Bing satellite imagery, USGS imagery, Esri imagery, Mapbox, OpenTopoMap, and any custom providers
|
||||
- **Offline-first design**: Download a region for complete offline operation
|
||||
- **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, compass indicator with north-lock, and gesture-friendly interactions
|
||||
- **Device visualization**: Color-coded markers showing real devices (blue), pending uploads (purple), pending edits (grey), devices being edited (orange), and pending deletions (red)
|
||||
@@ -53,6 +53,12 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
||||
- **Queue management**: Review, edit, retry, or cancel pending uploads
|
||||
- **Changeset tracking**: Automatic grouping and commenting for organized contributions
|
||||
|
||||
### Profile Import & Sharing
|
||||
- **Deep link support**: Import custom profiles via `deflockapp://profiles/add?p=<base64>` URLs
|
||||
- **Website integration**: Generate profile import links from [deflock.me](https://deflock.me)
|
||||
- **Pre-filled editor**: Imported profiles open in the profile editor for review and modification
|
||||
- **Seamless workflow**: Edit imported profiles like any custom profile before saving
|
||||
|
||||
### Offline Operations
|
||||
- **Smart area downloads**: Automatically calculate tile counts and storage requirements
|
||||
- **Device caching**: Offline areas include surveillance device data for complete functionality without network
|
||||
@@ -66,7 +72,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
||||
1. **Install** the app on iOS or Android - a welcome popup will guide you through key information
|
||||
2. **Enable location** permissions
|
||||
3. **Log into OpenStreetMap**: Choose upload mode and get OAuth2 credentials
|
||||
4. **Add your first device**: Tap the "New Node" button, position the pin, set direction(s), select a profile, and tap submit
|
||||
4. **Add your first device**: Tap the "New Node" button, position the pin, set direction(s), select a profile, and tap submit - a guidance popup will help you with best practices on your first submission
|
||||
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.
|
||||
@@ -84,12 +90,16 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
||||
- Code organization and contribution guidelines
|
||||
- Debugging tips and troubleshooting
|
||||
|
||||
**Quick setup:**
|
||||
**Quick setup (macOS with Homebrew):**
|
||||
```shell
|
||||
flutter pub get
|
||||
cp lib/keys.dart.example lib/keys.dart
|
||||
# Add OAuth2 client IDs, then: flutter run
|
||||
brew install --cask flutter # Install Flutter SDK
|
||||
brew install cocoapods # Required for iOS
|
||||
flutter pub get # Install dependencies
|
||||
./gen_icons_splashes.sh # Generate icons & splash screens (required before first build)
|
||||
cp build_keys.conf.example build_keys.conf # Add your OSM OAuth2 client IDs
|
||||
./do_builds.sh # Build both platforms
|
||||
```
|
||||
See [DEVELOPER.md](DEVELOPER.md) for cross-platform instructions and Android SDK setup.
|
||||
|
||||
**Releases**: The app uses GitHub's release system for automated building and store uploads. Simply create a GitHub release and use the "pre-release" checkbox to control whether builds go to app stores - checked for beta releases, unchecked for production releases.
|
||||
|
||||
@@ -97,44 +107,7 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Needed Bugfixes
|
||||
- Update node cache to reflect cleared queue entries
|
||||
- Decide what to do for extracting nodes attached to a way/relation:
|
||||
- Auto extract (how?)
|
||||
- Leave it alone (wrong answer unless user chooses intentionally)
|
||||
- Manual cleanup (cognitive load for users)
|
||||
- Delete the old one (also wrong answer unless user chooses intentionally)
|
||||
- Give multiple of these options??
|
||||
- Nav start+end too close together error (warning + disable submit button?)
|
||||
- Improve/retune tile fetching backoff/retry
|
||||
- Add some builtin satellite tile provider
|
||||
- Are offline areas preferred for fast loading even when online? Check working.
|
||||
- Fix network indicator - only done when fetch queue is empty!
|
||||
|
||||
### Current Development
|
||||
- Persistent cache for MY submissions: assume submissions worked, cache,clean up when we see that node appear in overpass/OSM results or when older than 24h
|
||||
- Dropdown on "refine tags" page to select acceptable options for camera:mount= (is this a boolean property of a profile?)
|
||||
- Tutorial / info guide before submitting first node, info and links before creating first profile
|
||||
- Option to pull in profiles from NSI (man_made=surveillance only?)
|
||||
|
||||
### On Pause
|
||||
- Suspected locations expansion to more regions
|
||||
- Import/Export map providers
|
||||
- Swap in alprwatch.org/directions avoidance routing API
|
||||
- Clean cache when nodes have disappeared / been deleted by others / queue item was deleted
|
||||
- Improve offline area node refresh live display
|
||||
|
||||
### Future Features & Wishlist
|
||||
- Update offline area nodes while browsing?
|
||||
- Offline navigation (pending vector map tiles)
|
||||
|
||||
### Maybes
|
||||
- Yellow ring for devices missing specific tag details
|
||||
- "Cache accumulating" offline area
|
||||
- "Offline areas" as tile provider
|
||||
- Grab the full latest database for each profile just like for suspected locations (instead of overpass)
|
||||
- Optional custom icons for profiles to aid identification
|
||||
- Custom device providers and OSM/Overpass alternatives
|
||||
See [GitHub Issues](https://github.com/FoggedLens/deflock-app/issues) for the full list of planned features, known bugs, and ideas.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ android {
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// oauth2_client 4.x & flutter_web_auth_2 5.x require minSdk 23
|
||||
// ────────────────────────────────────────────────────────────
|
||||
minSdk = 23
|
||||
minSdk = maxOf(flutter.minSdkVersion, 23)
|
||||
targetSdk = 36
|
||||
|
||||
// Flutter tool injects these during `flutter build`
|
||||
|
||||
@@ -35,6 +35,14 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
|
||||
<!-- Profile import deep links -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="deflockapp" android:host="profiles"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- flutter_web_auth_2 callback activity (V2 embedding) -->
|
||||
|
||||
@@ -1,4 +1,248 @@
|
||||
{
|
||||
"2.7.1": {
|
||||
"content": [
|
||||
"• Fixed operator profile selection being lost when moving node position, adjusting direction, or changing profiles"
|
||||
]
|
||||
},
|
||||
"2.6.4": {
|
||||
"content": [
|
||||
"• Added imperial units support (miles, feet) in addition to metric units (km, meters)",
|
||||
"• Moved units setting from Navigation to Language & Region settings page",
|
||||
"• Search text now automatically clears when route planning starts"
|
||||
]
|
||||
},
|
||||
"2.6.3": {
|
||||
"content": [
|
||||
"• Improved first launch experience - location permission is now requested immediately after welcome dialog",
|
||||
"• Prevent edit submissions where nothing (location, tags, direction) has been changed",
|
||||
"• Allow customizing changeset comment on refine tags page",
|
||||
"• Moved upload queue pause toggle to upload queue screen for better discoverability"
|
||||
]
|
||||
},
|
||||
"2.6.2": {
|
||||
"content": [
|
||||
"• Enhanced edit workflow; new '<Existing tags>' profile preserves current tags while allowing direction and location edits",
|
||||
"• New '<Existing operator>' profile when editing nodes with operator tags; preserves operator details automatically",
|
||||
"• Tag pre-population; existing node values automatically fill empty profile tags to prevent data loss"
|
||||
]
|
||||
},
|
||||
"2.6.1": {
|
||||
"content": [
|
||||
"• Simplified network status indicator - cleaner state management",
|
||||
"• Improved error handling for surveillance data requests",
|
||||
"• Better status reporting for background vs. user-initiated requests"
|
||||
]
|
||||
},
|
||||
"2.6.0": {
|
||||
"content": [
|
||||
"• Fix slow node loading, offline node loading",
|
||||
"• Prevent submissions when we have no data in that area"
|
||||
]
|
||||
},
|
||||
"2.5.0": {
|
||||
"content": [
|
||||
"• NEW: 'Get more...' button in profile dropdowns - easily browse and import profiles from deflock.me/identify",
|
||||
"• NEW: Profile creation choice dialog - when adding profiles in settings, choose between creating custom profiles or importing from website",
|
||||
"• Enhanced profile discovery workflow - clearer path for users to find and import community-created profiles"
|
||||
]
|
||||
},
|
||||
"2.4.4": {
|
||||
"content": [
|
||||
"• Search results now prioritize locations near your current map view"
|
||||
]
|
||||
},
|
||||
"2.4.3": {
|
||||
"content": [
|
||||
"• Fixed 360° FOV rendering - devices with full circle coverage now render as complete rings instead of having a wedge cut out or being a line",
|
||||
"• Fixed 360° FOV submission - now correctly submits '0-360' to OpenStreetMap instead of incorrect '180-180' values, disables direction slider"
|
||||
]
|
||||
},
|
||||
"2.4.1": {
|
||||
"content": [
|
||||
"• Save button moved to top-right corner of profile editor screens",
|
||||
"• Fixed issue where FOV values could not be removed from profiles",
|
||||
"• Direction slider is now disabled for profiles with 360° FOV"
|
||||
]
|
||||
},
|
||||
"2.4.0": {
|
||||
"content": [
|
||||
"• Profile import from website links",
|
||||
"• Visit deflock.me for profile links to auto-populate custom profiles"
|
||||
]
|
||||
},
|
||||
"2.3.1": {
|
||||
"content": [
|
||||
"• Follow-me mode now automatically restores when add/edit/tag sheets are closed",
|
||||
"• Follow-me button is greyed out while node sheets are open (add/edit/tag) since following doesn't make sense during node operations",
|
||||
"• Drop support for approximate location since I can't get it to work reliably; apologies"
|
||||
]
|
||||
},
|
||||
"2.3.0": {
|
||||
"content": [
|
||||
"• Concurrent upload queue processing",
|
||||
"• Each submission is now much faster"
|
||||
]
|
||||
},
|
||||
"2.2.1": {
|
||||
"content": [
|
||||
"• Fixed network status indicator timing out prematurely",
|
||||
"• Improved GPS follow-me reliability - fixed sync issues that could cause tracking to stop working",
|
||||
"• Network status now accurately shows 'taking a while' when requests split or backoff, and only shows 'timed out' for actual network failures"
|
||||
]
|
||||
},
|
||||
"2.2.0": {
|
||||
"content": [
|
||||
"• Fixed follow-me sync issues where tracking would sometimes stop working after mode changes",
|
||||
"• Added cancel button to submission guide dialog - users can now go back and revise their submissions",
|
||||
"• When not logged in, submit/edit buttons now say 'Log In' and navigate to account settings instead of being disabled",
|
||||
"• Improved NSI tag suggestions: now only shows values with sufficient usage (100+ occurrences) to avoid rare/unhelpful suggestions like for 'image=' tags",
|
||||
"• Enhanced tag refinement: refine tags sheet now allows arbitrary text entry like the profile editor, not just dropdown selection",
|
||||
"• New tags are now added to the top of the profile tag list for immediate visibility instead of being hidden at the bottom"
|
||||
]
|
||||
},
|
||||
"2.1.3": {
|
||||
"content": [
|
||||
"• Fixed nodes losing their greyed-out appearance when map is moved while viewing a node's tag sheet",
|
||||
"• Improved GPS location handling - follow-me button is now greyed out when location is unavailable",
|
||||
"• Added approximate location fallback - if precise location is denied, app will use approximate location",
|
||||
"• Higher frequency GPS updates when follow-me modes are active for smoother tracking (1-meter updates vs 5-meter)"
|
||||
]
|
||||
},
|
||||
"2.1.2": {
|
||||
"content": [
|
||||
"• New positioning tutorial - first-time users must drag the map to refine location when creating or editing nodes, helping ensure accurate positioning",
|
||||
"• Tutorial automatically dismisses after moving the map at least 1 meter and never shows again"
|
||||
]
|
||||
},
|
||||
"2.1.0": {
|
||||
"content": [
|
||||
"• Profile tag refinement system - any profile tag with an empty value now shows a dropdown in refine tags",
|
||||
"• OSM Name Suggestion Index (NSI) integration - shows most commonly used tag values from TagInfo API, both when creating/editing profiles and refining tags",
|
||||
"• FIXED: Can now remove FOV values from profiles",
|
||||
"• FIXED: Profile deletion while add/edit sheets are open no longer causes a crash"
|
||||
]
|
||||
},
|
||||
"1.8.3": {
|
||||
"content": [
|
||||
"• Fixed node limit indicator disappearing when navigation sheet opens during search/routing",
|
||||
"• Improved indicator architecture - moved node limit indicator to screen coordinates for consistency with other UI overlays"
|
||||
]
|
||||
},
|
||||
"1.8.2": {
|
||||
"content": [
|
||||
"• Fixed map positioning for node tags and suspected location sheets - map now correctly centers above sheet when opened",
|
||||
"• Improved sheet transition coordination - prevents map bounce when transitioning from tag sheet to edit sheet",
|
||||
"• Enhanced debugging for sheet height measurement and coordination"
|
||||
]
|
||||
},
|
||||
"1.8.0": {
|
||||
"content": [
|
||||
"• Better performance and reduced memory usage when using suspected location data by using a database"
|
||||
]
|
||||
},
|
||||
"1.7.0": {
|
||||
"content": [
|
||||
"• Distance display when selecting second navigation point; shows distance from first location in real-time",
|
||||
"• Long distance warning; routes over 20km display a warning about potential timeouts"
|
||||
]
|
||||
},
|
||||
"1.6.3": {
|
||||
"content": [
|
||||
"• Fixed navigation sheet button flow - route to/from buttons no longer reappear after selecting second location",
|
||||
"• Added cancel button when selecting second route point for easier exit from route planning",
|
||||
"• Removed placeholder FOV values from built-in device profiles - oops"
|
||||
]
|
||||
},
|
||||
"1.6.2": {
|
||||
"content": [
|
||||
"• Improved node rendering bounds - nodes appear slightly before sliding into view and stay visible until just after sliding out, eliminating edge blinking",
|
||||
"• Navigation interaction conflict prevention - nodes and suspected locations are now dimmed and non-clickable during route planning and route overview to prevent state conflicts",
|
||||
"• Enhanced route overview behavior - follow-me is automatically disabled when opening overview and intelligently restored when resuming based on proximity to route",
|
||||
"• Smart route resume - centers on GPS location with follow-me if near route, or route start without follow-me if far away, with configurable proximity threshold"
|
||||
]
|
||||
},
|
||||
"1.6.1": {
|
||||
"content": [
|
||||
"• Navigation route calculation timeout increased from 15 to 30 seconds - better success rate for complex routes in dense areas",
|
||||
"• Route timeout is now configurable in dev_config for easier future adjustments",
|
||||
"• Fix accidentally opening edit sheet on node tap instead of tags sheet"
|
||||
]
|
||||
},
|
||||
"1.6.0": {
|
||||
"content": [
|
||||
"• Internal code organization improvements - better separation of concerns for improved maintainability",
|
||||
"• Extracted specialized manager classes for map data, interactions, sheets, and navigation",
|
||||
"• Improved code modularity while preserving all existing functionality"
|
||||
]
|
||||
},
|
||||
"1.5.4": {
|
||||
"content": [
|
||||
"• OSM message notifications - dot appears on Settings button and OSM Account section when you have unread messages on OpenStreetMap",
|
||||
"• Download area max zoom level is now limited to the currently selected tile provider's maximum zoom level",
|
||||
"• Navigation route planning now prevents selecting start and end locations that are too close together",
|
||||
"• Cleaned up internal 'maxCameras' references to use 'maxNodes' terminology consistently",
|
||||
"• Proximity warnings now consider pending nodes - prevents submitting multiple nodes at the same location without warning",
|
||||
"• Pending nodes now reappear on the map after app restart - queue items repopulate the visual cache on startup",
|
||||
"• Upload queue screen shows when processing is paused (offline mode or manually paused)"
|
||||
]
|
||||
},
|
||||
"1.5.3": {
|
||||
"content": [
|
||||
"• Uploads now correctly track changeset creation, node operation, and changeset closing as separate steps",
|
||||
"• Upload queue processing is now more robust and continues when individual items encounter errors",
|
||||
"• Enhanced upload error handling - failures in each stage (create changeset, upload node, close changeset) are now handled appropriately",
|
||||
"• Improved upload status display - shows 'Creating changeset...', 'Uploading...', and 'Closing changeset...' with time remaining for changeset close",
|
||||
"• You can now tap the error icon (!) on failed uploads to see exactly what went wrong and at which stage",
|
||||
"• Moved 'Delete OSM Account' link from About page to OSM Account page - now only appears when logged in",
|
||||
"• Removing queue items or clearing the queue now properly removes temporary markers from the map",
|
||||
"• Removed placeholder FOV values from built-in profiles - FOV functionality remains available"
|
||||
]
|
||||
},
|
||||
"1.5.2": {
|
||||
"content": [
|
||||
"• Simplified tile loading architecture - replaced HTTP interception with clean TileProvider implementation",
|
||||
"• Improved tile loading performance - eliminate expensive filesystem searches on every tile request",
|
||||
"• Network status indicator now indicates only node data loading, not tile loading",
|
||||
"• Network status indicator no longer shows false timeouts during surveillance data splitting operations",
|
||||
"• Max nodes setting now correctly limits rendering only (not data fetching)",
|
||||
"• New node limit indicator shows when not all devices are displayed due to rendering limit"
|
||||
]
|
||||
},
|
||||
"1.5.1": {
|
||||
"content": [
|
||||
"• NEW: Bing satellite imagery - high-quality satellite tiles used by the iD editor, no API key required",
|
||||
"• IMPROVED: Enhanced tile provider system with quadkey format support (for Bing Maps and similar providers)",
|
||||
"• IMPROVED: Flexible subdomain patterns - supports both 0-3 and 1-4 subdomain ranges for load balancing",
|
||||
"• IMPROVED: Tile URL validation now accepts either {quadkey} or {x}/{y}/{z} coordinate systems"
|
||||
]
|
||||
},
|
||||
"1.5.0": {
|
||||
"content": [
|
||||
"• NEW: First-submission guide popup - provides essential guidance and links before your first device submission",
|
||||
"• NEW: Manual access to dialogs in Settings > About - view welcome message and submission guide anytime"
|
||||
]
|
||||
},
|
||||
"1.4.6": {
|
||||
"content": [
|
||||
"• IMPROVED: Tile fetching reliability - removed retry limits so visible tiles always load eventually",
|
||||
"• FIXED: Queue management - cancel requests for off-screen tiles, ongoing requests continue normally"
|
||||
]
|
||||
},
|
||||
"1.4.5": {
|
||||
"content": [
|
||||
"• NEW: Minimum zoom level (Z15) enforced for adding and editing surveillance nodes to ensure precise positioning",
|
||||
"• NEW: Minimum zoom level (Z10) enforced for offline area downloads to prevent insanely large areas",
|
||||
"• IMPROVED: Offline area download confirmation now shows as popup with 'View Progress in Settings' button instead of snackbar"
|
||||
]
|
||||
},
|
||||
"1.4.4": {
|
||||
"content": [
|
||||
"• FOV range notation parsing - now supports OSM data like '90-270' (180° FOV centered at 180°)",
|
||||
"• Complex range notation support: 'ESE;90-125;290' displays multiple FOV cones correctly",
|
||||
"• Profiles now support optional specific FOV values",
|
||||
"• Smart cone rendering - variable FOV widths, 360° cameras show full circles"
|
||||
]
|
||||
},
|
||||
"1.4.3": {
|
||||
"content": [
|
||||
"• NEW: Proximity warning when placing nodes too close together - prevents accidental duplicate submissions"
|
||||
@@ -106,4 +350,4 @@
|
||||
"• New suspected locations feature"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +80,9 @@ fi
|
||||
# Build the dart-define arguments
|
||||
DART_DEFINE_ARGS="--dart-define=OSM_PROD_CLIENTID=$OSM_PROD_CLIENTID --dart-define=OSM_SANDBOX_CLIENTID=$OSM_SANDBOX_CLIENTID"
|
||||
|
||||
# Validate localizations before building
|
||||
echo "Validating localizations..."
|
||||
dart run scripts/validate_localizations.dart || exit 1
|
||||
# Run tests before building
|
||||
echo "Running tests..."
|
||||
flutter test || exit 1
|
||||
echo
|
||||
|
||||
appver=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ' | cut -d '+' -f 1)
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>12.0</string>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
# platform :ios, '12.0'
|
||||
# platform :ios, '13.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
@@ -455,7 +455,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -588,7 +588,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -639,7 +639,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -11,15 +14,18 @@ import 'models/suspected_location.dart';
|
||||
import 'models/tile_provider.dart';
|
||||
import 'models/search_result.dart';
|
||||
import 'services/offline_area_service.dart';
|
||||
import 'services/node_cache.dart';
|
||||
import 'services/map_data_provider.dart';
|
||||
import 'services/node_data_manager.dart';
|
||||
import 'services/tile_preview_service.dart';
|
||||
import 'services/changelog_service.dart';
|
||||
import 'services/operator_profile_service.dart';
|
||||
import 'services/deep_link_service.dart';
|
||||
import 'widgets/node_provider_with_cache.dart';
|
||||
import 'services/profile_service.dart';
|
||||
import 'widgets/camera_provider_with_cache.dart';
|
||||
import 'widgets/proximity_warning_dialog.dart';
|
||||
import 'widgets/reauth_messages_dialog.dart';
|
||||
import 'dev_config.dart';
|
||||
import 'state/auth_state.dart';
|
||||
import 'state/messages_state.dart';
|
||||
import 'state/navigation_state.dart';
|
||||
import 'state/operator_profile_state.dart';
|
||||
import 'state/profile_state.dart';
|
||||
@@ -33,6 +39,7 @@ import 'state/upload_queue_state.dart';
|
||||
export 'state/navigation_state.dart' show AppNavigationMode;
|
||||
export 'state/settings_state.dart' show UploadMode, FollowMeMode;
|
||||
export 'state/session_state.dart' show AddNodeSession, EditNodeSession;
|
||||
export 'models/pending_upload.dart' show UploadOperation;
|
||||
|
||||
// ------------------ AppState ------------------
|
||||
class AppState extends ChangeNotifier {
|
||||
@@ -40,6 +47,7 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
// State modules
|
||||
late final AuthState _authState;
|
||||
late final MessagesState _messagesState;
|
||||
late final NavigationState _navigationState;
|
||||
late final OperatorProfileState _operatorProfileState;
|
||||
late final ProfileState _profileState;
|
||||
@@ -50,10 +58,16 @@ class AppState extends ChangeNotifier {
|
||||
late final UploadQueueState _uploadQueueState;
|
||||
|
||||
bool _isInitialized = false;
|
||||
|
||||
// Positioning tutorial state
|
||||
LatLng? _tutorialStartPosition; // Track where the tutorial started
|
||||
VoidCallback? _tutorialCompletionCallback; // Callback when tutorial is completed
|
||||
Timer? _messageCheckTimer;
|
||||
|
||||
AppState() {
|
||||
instance = this;
|
||||
_authState = AuthState();
|
||||
_messagesState = MessagesState();
|
||||
_navigationState = NavigationState();
|
||||
_operatorProfileState = OperatorProfileState();
|
||||
_profileState = ProfileState();
|
||||
@@ -65,6 +79,7 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
// Set up state change listeners
|
||||
_authState.addListener(_onStateChanged);
|
||||
_messagesState.addListener(_onStateChanged);
|
||||
_navigationState.addListener(_onStateChanged);
|
||||
_operatorProfileState.addListener(_onStateChanged);
|
||||
_profileState.addListener(_onStateChanged);
|
||||
@@ -104,6 +119,9 @@ class AppState extends ChangeNotifier {
|
||||
double? get routeDistance => _navigationState.routeDistance;
|
||||
bool get settingRouteStart => _navigationState.settingRouteStart;
|
||||
bool get isSettingSecondPoint => _navigationState.isSettingSecondPoint;
|
||||
bool get areRoutePointsTooClose => _navigationState.areRoutePointsTooClose;
|
||||
double? get distanceFromFirstPoint => _navigationState.distanceFromFirstPoint;
|
||||
bool get distanceExceedsWarningThreshold => _navigationState.distanceExceedsWarningThreshold;
|
||||
bool get isCalculating => _navigationState.isCalculating;
|
||||
bool get showingOverview => _navigationState.showingOverview;
|
||||
String? get routingError => _navigationState.routingError;
|
||||
@@ -112,6 +130,8 @@ class AppState extends ChangeNotifier {
|
||||
// Navigation search state
|
||||
bool get isNavigationSearchLoading => _navigationState.isSearchLoading;
|
||||
List<SearchResult> get navigationSearchResults => _navigationState.searchResults;
|
||||
int get navigationAvoidanceDistance => _settingsState.navigationAvoidanceDistance;
|
||||
DistanceUnit get distanceUnit => _settingsState.distanceUnit;
|
||||
|
||||
// Profile state
|
||||
List<NodeProfile> get profiles => _profileState.profiles;
|
||||
@@ -133,7 +153,7 @@ class AppState extends ChangeNotifier {
|
||||
// Settings state
|
||||
bool get offlineMode => _settingsState.offlineMode;
|
||||
bool get pauseQueueProcessing => _settingsState.pauseQueueProcessing;
|
||||
int get maxCameras => _settingsState.maxCameras;
|
||||
int get maxNodes => _settingsState.maxNodes;
|
||||
UploadMode get uploadMode => _settingsState.uploadMode;
|
||||
FollowMeMode get followMeMode => _settingsState.followMeMode;
|
||||
|
||||
@@ -142,6 +162,11 @@ class AppState extends ChangeNotifier {
|
||||
bool get networkStatusIndicatorEnabled => _settingsState.networkStatusIndicatorEnabled;
|
||||
int get suspectedLocationMinDistance => _settingsState.suspectedLocationMinDistance;
|
||||
|
||||
// Messages state
|
||||
int? get unreadMessageCount => _messagesState.unreadCount;
|
||||
bool get hasUnreadMessages => _messagesState.hasUnreadMessages;
|
||||
bool get isCheckingMessages => _messagesState.isChecking;
|
||||
|
||||
// Tile provider state
|
||||
List<TileProvider> get tileProviders => _settingsState.tileProviders;
|
||||
TileType? get selectedTileType => _settingsState.selectedTileType;
|
||||
@@ -157,7 +182,8 @@ class AppState extends ChangeNotifier {
|
||||
SuspectedLocation? get selectedSuspectedLocation => _suspectedLocationState.selectedLocation;
|
||||
bool get suspectedLocationsEnabled => _suspectedLocationState.isEnabled;
|
||||
bool get suspectedLocationsLoading => _suspectedLocationState.isLoading;
|
||||
DateTime? get suspectedLocationsLastFetch => _suspectedLocationState.lastFetchTime;
|
||||
double? get suspectedLocationsDownloadProgress => _suspectedLocationState.downloadProgress;
|
||||
Future<DateTime?> get suspectedLocationsLastFetch => _suspectedLocationState.lastFetchTime;
|
||||
|
||||
void _onStateChanged() {
|
||||
notifyListeners();
|
||||
@@ -189,6 +215,9 @@ class AppState extends ChangeNotifier {
|
||||
await _operatorProfileState.init(addDefaults: shouldAddOperatorDefaults);
|
||||
await _profileState.init(addDefaults: shouldAddNodeDefaults);
|
||||
|
||||
// Set up callback to clear stale sessions when profiles are deleted
|
||||
_profileState.setProfileDeletedCallback(_onProfileDeleted);
|
||||
|
||||
// Mark defaults as initialized if this was first launch
|
||||
if (isFirstLaunch) {
|
||||
await prefs.setBool(firstLaunchKey, true);
|
||||
@@ -198,23 +227,67 @@ class AppState extends ChangeNotifier {
|
||||
await _uploadQueueState.init();
|
||||
await _authState.init(_settingsState.uploadMode);
|
||||
|
||||
// Set up callback to repopulate pending nodes after cache clears
|
||||
NodeProviderWithCache.instance.setOnCacheClearedCallback(() {
|
||||
_uploadQueueState.repopulateCacheFromQueue();
|
||||
});
|
||||
|
||||
// Check for messages on app launch if user is already logged in
|
||||
if (isLoggedIn) {
|
||||
checkMessages();
|
||||
}
|
||||
|
||||
// Note: Re-auth check will be triggered from home screen after init
|
||||
|
||||
// Initialize OfflineAreaService to ensure offline areas are loaded
|
||||
await OfflineAreaService().ensureInitialized();
|
||||
|
||||
// Preload offline nodes into cache for immediate display
|
||||
await NodeDataManager().preloadOfflineNodes();
|
||||
|
||||
// Start uploader if conditions are met
|
||||
_startUploader();
|
||||
|
||||
_isInitialized = true;
|
||||
|
||||
// Check for initial deep link after a small delay to let navigation settle
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
DeepLinkService().checkInitialLink();
|
||||
});
|
||||
|
||||
// Start periodic message checking
|
||||
_startMessageCheckTimer();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _startMessageCheckTimer() {
|
||||
_messageCheckTimer?.cancel();
|
||||
|
||||
// Check messages every 10 minutes when logged in
|
||||
_messageCheckTimer = Timer.periodic(
|
||||
const Duration(minutes: 10),
|
||||
(timer) {
|
||||
if (isLoggedIn) {
|
||||
checkMessages();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Auth Methods ----------
|
||||
Future<void> login() async {
|
||||
await _authState.login();
|
||||
// Check for messages after successful login
|
||||
if (isLoggedIn) {
|
||||
checkMessages();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _authState.logout();
|
||||
// Clear message state when logging out
|
||||
clearMessages();
|
||||
}
|
||||
|
||||
Future<void> refreshAuthState() async {
|
||||
@@ -223,11 +296,104 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
Future<void> forceLogin() async {
|
||||
await _authState.forceLogin();
|
||||
// Check for messages after successful login
|
||||
if (isLoggedIn) {
|
||||
checkMessages();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> validateToken() async {
|
||||
return await _authState.validateToken();
|
||||
}
|
||||
|
||||
// ---------- Messages Methods ----------
|
||||
Future<void> checkMessages({bool forceRefresh = false}) async {
|
||||
final accessToken = await _authState.getAccessToken();
|
||||
await _messagesState.checkMessages(
|
||||
accessToken: accessToken,
|
||||
uploadMode: uploadMode,
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
}
|
||||
|
||||
String getMessagesUrl() {
|
||||
return _messagesState.getMessagesUrl(uploadMode);
|
||||
}
|
||||
|
||||
void clearMessages() {
|
||||
_messagesState.clearMessages();
|
||||
}
|
||||
|
||||
/// Check if the current OAuth token has required scopes for message notifications
|
||||
/// Returns true if re-authentication is needed
|
||||
Future<bool> needsReauthForMessages() async {
|
||||
// Only check if logged in and not in simulate mode
|
||||
if (!isLoggedIn || uploadMode == UploadMode.simulate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final accessToken = await _authState.getAccessToken();
|
||||
if (accessToken == null) return false;
|
||||
|
||||
try {
|
||||
// Try to fetch user details - this should include message data if scope is correct
|
||||
final response = await http.get(
|
||||
Uri.parse('${_getApiHost()}/api/0.6/user/details.json'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
|
||||
if (response.statusCode == 403) {
|
||||
// Forbidden - likely missing scope
|
||||
return true;
|
||||
}
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
final messages = data['user']?['messages'];
|
||||
// If messages field is missing, we might not have the right scope
|
||||
return messages == null;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
// On error, assume no re-auth needed to avoid annoying users
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Show re-authentication dialog if needed
|
||||
Future<void> checkAndPromptReauthForMessages(BuildContext context) async {
|
||||
if (await needsReauthForMessages()) {
|
||||
if (!context.mounted) return;
|
||||
_showReauthDialog(context);
|
||||
}
|
||||
}
|
||||
|
||||
void _showReauthDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ReauthMessagesDialog(
|
||||
onReauth: () {
|
||||
// Navigate to OSM account page where user can re-authenticate
|
||||
Navigator.of(context).pushNamed('/settings/osm-account');
|
||||
},
|
||||
onDismiss: () {
|
||||
// Just dismiss - will show again on next app start or mode change
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getApiHost() {
|
||||
switch (uploadMode) {
|
||||
case UploadMode.production:
|
||||
return 'https://api.openstreetmap.org';
|
||||
case UploadMode.sandbox:
|
||||
return 'https://api06.dev.openstreetmap.org';
|
||||
case UploadMode.simulate:
|
||||
return 'https://api.openstreetmap.org';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Profile Methods ----------
|
||||
void toggleProfile(NodeProfile p, bool e) {
|
||||
@@ -241,6 +407,19 @@ class AppState extends ChangeNotifier {
|
||||
void deleteProfile(NodeProfile p) {
|
||||
_profileState.deleteProfile(p);
|
||||
}
|
||||
|
||||
// Callback when a profile is deleted - clear any stale session references
|
||||
void _onProfileDeleted(NodeProfile deletedProfile) {
|
||||
// Clear add session if it references the deleted profile
|
||||
if (_sessionState.session?.profile?.id == deletedProfile.id) {
|
||||
cancelSession();
|
||||
}
|
||||
|
||||
// Clear edit session if it references the deleted profile
|
||||
if (_sessionState.editSession?.profile?.id == deletedProfile.id) {
|
||||
cancelEditSession();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Operator Profile Methods ----------
|
||||
void addOrUpdateOperatorProfile(OperatorProfile p) {
|
||||
@@ -257,7 +436,7 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void startEditSession(OsmNode node) {
|
||||
_sessionState.startEditSession(node, enabledProfiles);
|
||||
_sessionState.startEditSession(node, enabledProfiles, operatorProfiles);
|
||||
}
|
||||
|
||||
void updateSession({
|
||||
@@ -265,13 +444,26 @@ class AppState extends ChangeNotifier {
|
||||
NodeProfile? profile,
|
||||
OperatorProfile? operatorProfile,
|
||||
LatLng? target,
|
||||
Map<String, String>? refinedTags,
|
||||
Map<String, String>? additionalExistingTags,
|
||||
String? changesetComment,
|
||||
bool updateOperatorProfile = false,
|
||||
}) {
|
||||
_sessionState.updateSession(
|
||||
directionDeg: directionDeg,
|
||||
profile: profile,
|
||||
operatorProfile: operatorProfile,
|
||||
target: target,
|
||||
refinedTags: refinedTags,
|
||||
additionalExistingTags: additionalExistingTags,
|
||||
changesetComment: changesetComment,
|
||||
updateOperatorProfile: updateOperatorProfile,
|
||||
);
|
||||
|
||||
// Check tutorial completion if position changed
|
||||
if (target != null) {
|
||||
_checkTutorialCompletion(target);
|
||||
}
|
||||
}
|
||||
|
||||
void updateEditSession({
|
||||
@@ -280,6 +472,10 @@ class AppState extends ChangeNotifier {
|
||||
OperatorProfile? operatorProfile,
|
||||
LatLng? target,
|
||||
bool? extractFromWay,
|
||||
Map<String, String>? refinedTags,
|
||||
Map<String, String>? additionalExistingTags,
|
||||
String? changesetComment,
|
||||
bool updateOperatorProfile = false,
|
||||
}) {
|
||||
_sessionState.updateEditSession(
|
||||
directionDeg: directionDeg,
|
||||
@@ -287,7 +483,16 @@ class AppState extends ChangeNotifier {
|
||||
operatorProfile: operatorProfile,
|
||||
target: target,
|
||||
extractFromWay: extractFromWay,
|
||||
refinedTags: refinedTags,
|
||||
additionalExistingTags: additionalExistingTags,
|
||||
changesetComment: changesetComment,
|
||||
updateOperatorProfile: updateOperatorProfile,
|
||||
);
|
||||
|
||||
// Check tutorial completion if position changed
|
||||
if (target != null) {
|
||||
_checkTutorialCompletion(target);
|
||||
}
|
||||
}
|
||||
|
||||
// For map view to check for pending snap backs
|
||||
@@ -295,6 +500,40 @@ class AppState extends ChangeNotifier {
|
||||
return _sessionState.consumePendingSnapBack();
|
||||
}
|
||||
|
||||
// Positioning tutorial methods
|
||||
void registerTutorialCallback(VoidCallback onComplete) {
|
||||
_tutorialCompletionCallback = onComplete;
|
||||
// Record the starting position when tutorial begins
|
||||
if (session?.target != null) {
|
||||
_tutorialStartPosition = session!.target;
|
||||
} else if (editSession?.target != null) {
|
||||
_tutorialStartPosition = editSession!.target;
|
||||
}
|
||||
}
|
||||
|
||||
void clearTutorialCallback() {
|
||||
_tutorialCompletionCallback = null;
|
||||
_tutorialStartPosition = null;
|
||||
}
|
||||
|
||||
void _checkTutorialCompletion(LatLng newPosition) {
|
||||
if (_tutorialCompletionCallback == null || _tutorialStartPosition == null) return;
|
||||
|
||||
// Calculate distance moved
|
||||
final distance = Distance();
|
||||
final distanceMoved = distance.as(LengthUnit.Meter, _tutorialStartPosition!, newPosition);
|
||||
|
||||
if (distanceMoved >= kPositioningTutorialMinMovementMeters) {
|
||||
// Tutorial completed! Mark as complete and notify callback immediately
|
||||
final callback = _tutorialCompletionCallback;
|
||||
clearTutorialCallback();
|
||||
callback?.call();
|
||||
|
||||
// Mark as complete in background (don't await to avoid delays)
|
||||
ChangelogService().markPositioningTutorialCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
void addDirection() {
|
||||
_sessionState.addDirection();
|
||||
}
|
||||
@@ -303,6 +542,8 @@ class AppState extends ChangeNotifier {
|
||||
_sessionState.removeDirection();
|
||||
}
|
||||
|
||||
bool get canRemoveDirection => _sessionState.canRemoveDirection;
|
||||
|
||||
void cycleDirection() {
|
||||
_sessionState.cycleDirection();
|
||||
}
|
||||
@@ -348,8 +589,8 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// ---------- Navigation Methods - Simplified ----------
|
||||
void enterSearchMode(LatLng mapCenter) {
|
||||
_navigationState.enterSearchMode(mapCenter);
|
||||
void enterSearchMode(LatLng mapCenter, {LatLngBounds? viewbox}) {
|
||||
_navigationState.enterSearchMode(mapCenter, viewbox: viewbox);
|
||||
}
|
||||
|
||||
void cancelNavigation() {
|
||||
@@ -430,18 +671,27 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
set maxCameras(int n) {
|
||||
_settingsState.maxCameras = n;
|
||||
set maxNodes(int n) {
|
||||
_settingsState.maxNodes = n;
|
||||
}
|
||||
|
||||
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();
|
||||
MapDataProvider().clearCache();
|
||||
debugPrint('[AppState] Cleared node cache due to upload mode change');
|
||||
|
||||
await _settingsState.setUploadMode(mode);
|
||||
await _authState.onUploadModeChanged(mode);
|
||||
|
||||
// Clear and re-check messages for new mode
|
||||
clearMessages();
|
||||
if (isLoggedIn) {
|
||||
// Don't await - let it run in background
|
||||
checkMessages();
|
||||
|
||||
// Note: Re-auth check will be triggered from the settings screen after mode change
|
||||
}
|
||||
|
||||
_startUploader(); // Restart uploader with new mode
|
||||
}
|
||||
|
||||
@@ -480,11 +730,22 @@ class AppState extends ChangeNotifier {
|
||||
await _settingsState.setNetworkStatusIndicatorEnabled(enabled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Set suspected location minimum distance from real nodes
|
||||
Future<void> setSuspectedLocationMinDistance(int distance) async {
|
||||
await _settingsState.setSuspectedLocationMinDistance(distance);
|
||||
}
|
||||
|
||||
/// Set navigation avoidance distance
|
||||
Future<void> setNavigationAvoidanceDistance(int distance) async {
|
||||
await _settingsState.setNavigationAvoidanceDistance(distance);
|
||||
}
|
||||
|
||||
Future<void> setDistanceUnit(DistanceUnit unit) async {
|
||||
await _settingsState.setDistanceUnit(unit);
|
||||
}
|
||||
|
||||
// ---------- Queue Methods ----------
|
||||
void clearQueue() {
|
||||
_uploadQueueState.clearQueue();
|
||||
@@ -499,6 +760,11 @@ class AppState extends ChangeNotifier {
|
||||
_startUploader(); // resume uploader if not busy
|
||||
}
|
||||
|
||||
/// Reload upload queue from storage (for migration purposes)
|
||||
Future<void> reloadUploadQueue() async {
|
||||
await _uploadQueueState.reloadQueue();
|
||||
}
|
||||
|
||||
// ---------- Suspected Location Methods ----------
|
||||
Future<void> setSuspectedLocationsEnabled(bool enabled) async {
|
||||
await _suspectedLocationState.setEnabled(enabled);
|
||||
@@ -508,6 +774,10 @@ class AppState extends ChangeNotifier {
|
||||
return await _suspectedLocationState.refreshData();
|
||||
}
|
||||
|
||||
Future<void> reinitSuspectedLocations() async {
|
||||
await _suspectedLocationState.init(offlineMode: _settingsState.offlineMode);
|
||||
}
|
||||
|
||||
void selectSuspectedLocation(SuspectedLocation location) {
|
||||
_suspectedLocationState.selectLocation(location);
|
||||
}
|
||||
@@ -516,19 +786,58 @@ class AppState extends ChangeNotifier {
|
||||
_suspectedLocationState.clearSelection();
|
||||
}
|
||||
|
||||
List<SuspectedLocation> getSuspectedLocationsInBounds({
|
||||
Future<List<SuspectedLocation>> getSuspectedLocationsInBounds({
|
||||
required double north,
|
||||
required double south,
|
||||
required double east,
|
||||
required double west,
|
||||
}) {
|
||||
return _suspectedLocationState.getLocationsInBounds(
|
||||
}) async {
|
||||
return await _suspectedLocationState.getLocationsInBounds(
|
||||
north: north,
|
||||
south: south,
|
||||
east: east,
|
||||
west: west,
|
||||
);
|
||||
}
|
||||
|
||||
List<SuspectedLocation> getSuspectedLocationsInBoundsSync({
|
||||
required double north,
|
||||
required double south,
|
||||
required double east,
|
||||
required double west,
|
||||
}) {
|
||||
return _suspectedLocationState.getLocationsInBoundsSync(
|
||||
north: north,
|
||||
south: south,
|
||||
east: east,
|
||||
west: west,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Utility Methods ----------
|
||||
|
||||
/// Generate a default changeset comment for a submission
|
||||
/// Handles special case of `<Existing tags>` profile by using "a" instead
|
||||
static String generateDefaultChangesetComment({
|
||||
required NodeProfile? profile,
|
||||
required UploadOperation operation,
|
||||
}) {
|
||||
// Handle temp profiles with brackets by using "a"
|
||||
final profileName = profile?.name.startsWith('<') == true && profile?.name.endsWith('>') == true
|
||||
? 'a'
|
||||
: profile?.name ?? 'surveillance';
|
||||
|
||||
switch (operation) {
|
||||
case UploadOperation.create:
|
||||
return 'Add $profileName surveillance node';
|
||||
case UploadOperation.modify:
|
||||
return 'Update $profileName surveillance node';
|
||||
case UploadOperation.delete:
|
||||
return 'Delete $profileName surveillance node';
|
||||
case UploadOperation.extract:
|
||||
return 'Extract $profileName surveillance node';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Private Methods ----------
|
||||
/// Attempts to fetch missing tile preview images in the background (fire and forget)
|
||||
@@ -551,7 +860,9 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageCheckTimer?.cancel();
|
||||
_authState.removeListener(_onStateChanged);
|
||||
_messagesState.removeListener(_onStateChanged);
|
||||
_navigationState.removeListener(_onStateChanged);
|
||||
_operatorProfileState.removeListener(_onStateChanged);
|
||||
_profileState.removeListener(_onStateChanged);
|
||||
|
||||
@@ -53,14 +53,29 @@ double topPositionWithSafeArea(double baseTop, EdgeInsets safeArea) {
|
||||
const String kClientName = 'DeFlock';
|
||||
// Note: Version is now dynamically retrieved from VersionService
|
||||
|
||||
// Upload and changeset configuration
|
||||
const Duration kUploadHttpTimeout = Duration(seconds: 30); // HTTP request timeout for uploads
|
||||
const Duration kUploadQueueProcessingInterval = Duration(seconds: 5); // How often to check for new uploads to start
|
||||
const int kMaxConcurrentUploads = 5; // Maximum number of uploads processing simultaneously
|
||||
const Duration kChangesetCloseInitialRetryDelay = Duration(seconds: 10);
|
||||
const Duration kChangesetCloseMaxRetryDelay = Duration(minutes: 5); // Cap at 5 minutes
|
||||
const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close
|
||||
const double kChangesetCloseBackoffMultiplier = 2.0;
|
||||
|
||||
// Navigation routing configuration
|
||||
const Duration kNavigationRoutingTimeout = Duration(seconds: 90); // HTTP timeout for routing requests
|
||||
|
||||
// Overpass API configuration
|
||||
const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded)
|
||||
|
||||
// Suspected locations CSV URL
|
||||
const String kSuspectedLocationsCsvUrl = 'https://stopflock.com/app/flock_utilities_mini_latest.csv';
|
||||
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-latest.csv';
|
||||
|
||||
// Development/testing features - set to false for production builds
|
||||
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
|
||||
const bool kEnableDevelopmentModes = true; // Set to false to hide sandbox/simulate modes and force production mode
|
||||
|
||||
// Navigation features - set to false to hide navigation UI elements while in development
|
||||
const bool kEnableNavigationFeatures = kEnableDevelopmentModes; // Hide navigation until fully implemented
|
||||
const bool kEnableNavigationFeatures = true; // Hide navigation until fully implemented
|
||||
|
||||
// Node editing features - set to false to temporarily disable editing
|
||||
const bool kEnableNodeEdits = true; // Set to false to temporarily disable node editing
|
||||
@@ -70,21 +85,22 @@ const bool kEnableNodeExtraction = false; // Set to true to enable extract from
|
||||
|
||||
/// Navigation availability: only dev builds, and only when online
|
||||
bool enableNavigationFeatures({required bool offlineMode}) {
|
||||
if (!kEnableDevelopmentModes) {
|
||||
return false; // Release builds: never allow navigation
|
||||
} else {
|
||||
return !offlineMode; // Dev builds: only when online
|
||||
}
|
||||
return kEnableNavigationFeatures && !offlineMode;
|
||||
}
|
||||
|
||||
// Marker/node interaction
|
||||
const int kNodeMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass)
|
||||
const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode)
|
||||
const int kMinZoomForNodeEditingSheets = 15; // Minimum zoom to open add/edit node sheets
|
||||
const int kMinZoomForOfflineDownload = 10; // Minimum zoom to download offline areas (prevents large area crashes)
|
||||
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
|
||||
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
|
||||
|
||||
// Pre-fetch area configuration
|
||||
const double kPreFetchAreaExpansionMultiplier = 3.0; // Expand visible bounds by this factor for pre-fetching
|
||||
const double kNodeRenderingBoundsExpansion = 1.3; // Expand visible bounds by this factor for node rendering to prevent edge blinking
|
||||
const double kRouteProximityThresholdMeters = 500.0; // Distance threshold for determining if user is near route when resuming navigation
|
||||
const double kResumeNavigationZoomLevel = 16.0; // Zoom level when resuming navigation
|
||||
const int kPreFetchZoomLevel = 10; // Always pre-fetch at this zoom level for consistent area sizes
|
||||
const int kMaxPreFetchSplitDepth = 3; // Maximum recursive splits when hitting Overpass node limit
|
||||
|
||||
@@ -115,6 +131,21 @@ const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown betw
|
||||
// Node proximity warning configuration (for new/edited nodes that are too close to existing ones)
|
||||
const double kNodeProximityWarningDistance = 15.0; // meters - distance threshold to show warning
|
||||
|
||||
// Positioning tutorial configuration
|
||||
const double kPositioningTutorialBlurSigma = 3.0; // Blur strength for sheet overlay
|
||||
const double kPositioningTutorialMinMovementMeters = 1.0; // Minimum map movement to complete tutorial
|
||||
|
||||
// Navigation route planning configuration
|
||||
const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance between start and end points
|
||||
const double kNavigationDistanceWarningThreshold = 20000.0; // meters - distance threshold for timeout warning (30km)
|
||||
|
||||
// Node display configuration
|
||||
const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render on the map at once
|
||||
|
||||
// NSI (Name Suggestion Index) configuration
|
||||
const int kNSIMinimumHitCount = 500; // Minimum hit count for NSI suggestions to be considered useful
|
||||
const int kNSIMaxSuggestions = 10; // Maximum number of tag value suggestions to fetch and display
|
||||
|
||||
// Map interaction configuration
|
||||
const double kNodeDoubleTapZoomDelta = 1.0; // How much to zoom in when double-tapping nodes (was 1.0)
|
||||
const double kScrollWheelVelocity = 0.01; // Mouse scroll wheel zoom speed (default 0.005)
|
||||
@@ -122,12 +153,14 @@ const double kPinchZoomThreshold = 0.2; // How much pinch required to start zoom
|
||||
const double kPinchMoveThreshold = 30.0; // How much drag required for two-finger pan (default 40.0)
|
||||
const double kRotationThreshold = 6.0; // Degrees of rotation required before map actually rotates (Google Maps style)
|
||||
|
||||
// Tile fetch retry parameters (configurable backoff system)
|
||||
const int kTileFetchMaxAttempts = 16; // Number of retry attempts before giving up
|
||||
const int kTileFetchInitialDelayMs = 500; // Base delay for first retry (1 second)
|
||||
const double kTileFetchBackoffMultiplier = 1.5; // Multiply delay by this each attempt
|
||||
const int kTileFetchMaxDelayMs = 10000; // Cap delays at this value (8 seconds max)
|
||||
const int kTileFetchRandomJitterMs = 250; // Random fuzz to add (0 to 500ms)
|
||||
// Tile fetch configuration (brutalist approach: simple, configurable, unlimited retries)
|
||||
const int kTileFetchConcurrentThreads = 8; // Reduced from 10 to 8 for better cross-platform performance
|
||||
const int kTileFetchInitialDelayMs = 150; // Reduced from 200ms for faster retries
|
||||
const double kTileFetchBackoffMultiplier = 1.4; // Slightly reduced for faster recovery
|
||||
const int kTileFetchMaxDelayMs = 4000; // Reduced from 5000ms for faster max retry
|
||||
const int kTileFetchRandomJitterMs = 50; // Reduced jitter for more predictable timing
|
||||
const int kTileFetchMaxQueueSize = 100; // Reasonable queue size to prevent memory bloat
|
||||
// Note: Removed max attempts - tiles retry indefinitely until they succeed or are canceled
|
||||
|
||||
// User download max zoom span (user can download up to kMaxUserDownloadZoomSpan zooms above min)
|
||||
const int kMaxUserDownloadZoomSpan = 7;
|
||||
@@ -162,3 +195,4 @@ double getNodeRingThickness(BuildContext context) {
|
||||
// return _kNodeRingThicknessBase * MediaQuery.of(context).devicePixelRatio;
|
||||
return _kNodeRingThicknessBase;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"ok": "OK",
|
||||
"close": "Schließen",
|
||||
"submit": "Senden",
|
||||
"logIn": "Anmelden",
|
||||
"saveEdit": "Bearbeitung Speichern",
|
||||
"clear": "Löschen",
|
||||
"viewOnOSM": "Auf OSM anzeigen",
|
||||
@@ -46,14 +47,18 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"language": "Sprache",
|
||||
"language": "Sprache & Region",
|
||||
"systemDefault": "Systemstandard",
|
||||
"aboutInfo": "Über / Informationen",
|
||||
"aboutThisApp": "Über Diese App",
|
||||
"aboutSubtitle": "App-Informationen und Credits",
|
||||
"languageSubtitle": "Wählen Sie Ihre bevorzugte Sprache",
|
||||
"languageSubtitle": "Wählen Sie Ihre bevorzugte Sprache und Einheiten",
|
||||
"distanceUnit": "Entfernungseinheiten",
|
||||
"distanceUnitSubtitle": "Wählen Sie zwischen metrischen (km/m) oder imperialen (mi/ft) Einheiten",
|
||||
"metricUnits": "Metrisch (km, m)",
|
||||
"imperialUnits": "Imperial (mi, ft)",
|
||||
"maxNodes": "Max. angezeigte Knoten",
|
||||
"maxNodesSubtitle": "Obergrenze für die Anzahl der Knoten auf der Karte festlegen (Standard: 250).",
|
||||
"maxNodesSubtitle": "Obergrenze für die Anzahl der Knoten auf der Karte festlegen.",
|
||||
"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.",
|
||||
@@ -81,8 +86,7 @@
|
||||
"enableNotifications": "Benachrichtigungen Aktivieren",
|
||||
"checkingPermissions": "Berechtigungen prüfen...",
|
||||
"alertDistance": "Warnentfernung: ",
|
||||
"meters": "Meter",
|
||||
"rangeInfo": "Bereich: {}-{} Meter (Standard: {})"
|
||||
"rangeInfo": "Bereich: {}-{} {} (Standard: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Knoten #{}",
|
||||
@@ -102,8 +106,8 @@
|
||||
"mustBeLoggedIn": "Sie müssen angemeldet sein, um neue Knoten zu übertragen. Bitte melden Sie sich über die Einstellungen an.",
|
||||
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um neue Knoten zu übertragen.",
|
||||
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um neue Knoten zu übertragen.",
|
||||
"refineTags": "Tags Verfeinern",
|
||||
"refineTagsWithProfile": "Tags Verfeinern ({})"
|
||||
"loadingAreaData": "Lade Bereichsdaten... Bitte warten Sie vor dem Übertragen.",
|
||||
"refineTags": "Tags Verfeinern"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Knoten #{} Bearbeiten",
|
||||
@@ -117,11 +121,16 @@
|
||||
"sandboxModeWarning": "Bearbeitungen von Produktionsknoten können nicht an die Sandbox übertragen werden. Wechseln Sie in den Produktionsmodus in den Einstellungen, um Knoten zu bearbeiten.",
|
||||
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um Knoten zu bearbeiten.",
|
||||
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um Knoten zu bearbeiten.",
|
||||
"loadingAreaData": "Lade Bereichsdaten... Bitte warten Sie vor dem Übertragen.",
|
||||
"cannotMoveConstrainedNode": "Kann diese Kamera nicht verschieben - sie ist mit einem anderen Kartenelement verbunden (OSM-Weg/Relation). Sie können trotzdem ihre Tags und Richtung bearbeiten.",
|
||||
"zoomInRequiredMessage": "Zoomen Sie auf mindestens Stufe {} heran, um Überwachungsknoten hinzuzufügen oder zu bearbeiten. Dies gewährleistet eine präzise Positionierung für genaues Kartieren.",
|
||||
"extractFromWay": "Knoten aus Weg/Relation extrahieren",
|
||||
"extractFromWaySubtitle": "Neuen Knoten mit gleichen Tags erstellen, Verschieben an neuen Ort ermöglichen",
|
||||
"refineTags": "Tags Verfeinern",
|
||||
"refineTagsWithProfile": "Tags Verfeinern ({})"
|
||||
"existingTags": "<Vorhandene Tags>",
|
||||
"noChangesDetected": "Keine Änderungen erkannt - nichts zu übertragen",
|
||||
"noChangesTitle": "Keine Änderungen zu Übertragen",
|
||||
"noChangesMessage": "Sie haben keine Änderungen an diesem Knoten vorgenommen. Um eine Bearbeitung zu übertragen, müssen Sie den Standort, das Profil, die Richtungen oder die Tags ändern."
|
||||
},
|
||||
"download": {
|
||||
"title": "Kartenbereich Herunterladen",
|
||||
@@ -133,9 +142,16 @@
|
||||
"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.",
|
||||
"areaTooBigMessage": "Zoomen Sie auf mindestens Stufe {} heran, um Offline-Bereiche herunterzuladen. Downloads großer Gebiete können die App zum Absturz bringen.",
|
||||
"downloadStarted": "Download gestartet! Lade Kacheln und Knoten...",
|
||||
"downloadFailed": "Download konnte nicht gestartet werden: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download gestartet",
|
||||
"message": "Download gestartet! Lade Kacheln und Knoten...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Fortschritt in Einstellungen anzeigen"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Upload-Ziel",
|
||||
"subtitle": "Wählen Sie, wohin Kameras hochgeladen werden",
|
||||
@@ -144,7 +160,8 @@
|
||||
"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).",
|
||||
"simulateDescription": "Uploads simulieren (kontaktiert OSM-Server nicht)"
|
||||
"simulateDescription": "Uploads simulieren (kontaktiert OSM-Server nicht)",
|
||||
"cannotChangeWithQueue": "Upload-Ziel kann nicht geändert werden, während {} Elemente in der Warteschlange sind. Warteschlange zuerst leeren."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "OpenStreetMap-Konto",
|
||||
@@ -167,7 +184,20 @@
|
||||
"deleteAccountSubtitle": "Ihr OpenStreetMap-Konto verwalten",
|
||||
"deleteAccountExplanation": "Um Ihr OpenStreetMap-Konto zu löschen, müssen Sie die OpenStreetMap-Website besuchen. Dies entfernt dauerhaft Ihr OSM-Konto und alle zugehörigen Daten.",
|
||||
"deleteAccountWarning": "Warnung: Diese Aktion kann nicht rückgängig gemacht werden und löscht Ihr OSM-Konto dauerhaft.",
|
||||
"goToOSM": "Zu OpenStreetMap gehen"
|
||||
"goToOSM": "Zu OpenStreetMap gehen",
|
||||
"accountManagement": "Kontoverwaltung",
|
||||
"accountManagementDescription": "Um Ihr OpenStreetMap-Konto zu löschen, müssen Sie die entsprechende OpenStreetMap-Website besuchen. Dadurch werden Ihr Konto und alle zugehörigen Daten dauerhaft gelöscht.",
|
||||
"currentDestinationProduction": "Derzeit verbunden mit: Produktions-OpenStreetMap",
|
||||
"currentDestinationSandbox": "Derzeit verbunden mit: Sandbox-OpenStreetMap",
|
||||
"currentDestinationSimulate": "Derzeit im: Simulationsmodus (kein echtes Konto)",
|
||||
"viewMessages": "Nachrichten auf OSM anzeigen",
|
||||
"unreadMessagesCount": "Sie haben {} ungelesene Nachrichten",
|
||||
"noUnreadMessages": "Keine ungelesenen Nachrichten",
|
||||
"reauthRequired": "Authentifizierung aktualisieren",
|
||||
"reauthExplanation": "Sie müssen Ihre Authentifizierung aktualisieren, um OSM-Nachrichtenbenachrichtigungen über die App zu erhalten.",
|
||||
"reauthBenefit": "Dies ermöglicht Benachrichtigungspunkte, wenn Sie ungelesene Nachrichten auf OpenStreetMap haben.",
|
||||
"reauthNow": "Jetzt machen",
|
||||
"reauthLater": "Später"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Upload-Warteschlange",
|
||||
@@ -185,7 +215,7 @@
|
||||
"queueCleared": "Warteschlange geleert",
|
||||
"uploadQueueTitle": "Upload-Warteschlange ({} Elemente)",
|
||||
"queueIsEmpty": "Warteschlange ist leer",
|
||||
"cameraWithIndex": "Kamera {}",
|
||||
"itemWithIndex": "Objekt {}",
|
||||
"error": " (Fehler)",
|
||||
"completing": " (Wird abgeschlossen...)",
|
||||
"destination": "Ziel: {}",
|
||||
@@ -195,7 +225,14 @@
|
||||
"attempts": "Versuche: {}",
|
||||
"uploadFailedRetry": "Upload fehlgeschlagen. Zum Wiederholen antippen.",
|
||||
"retryUpload": "Upload wiederholen",
|
||||
"clearAll": "Alle Löschen"
|
||||
"clearAll": "Alle Löschen",
|
||||
"errorDetails": "Fehlerdetails",
|
||||
"creatingChangeset": " (Changeset erstellen...)",
|
||||
"uploading": " (Uploading...)",
|
||||
"closingChangeset": " (Changeset schließen...)",
|
||||
"processingPaused": "Warteschlangenverarbeitung pausiert",
|
||||
"pausedDueToOffline": "Upload-Verarbeitung ist pausiert, da der Offline-Modus aktiviert ist.",
|
||||
"pausedByUser": "Upload-Verarbeitung ist manuell pausiert."
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Kachel-Anbieter",
|
||||
@@ -227,7 +264,7 @@
|
||||
"urlTemplate": "URL-Vorlage",
|
||||
"urlTemplateHint": "https://beispiel.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "URL-Vorlage ist erforderlich",
|
||||
"urlTemplatePlaceholders": "URL muss {z}, {x} und {y} Platzhalter enthalten",
|
||||
"urlTemplatePlaceholders": "URL muss entweder {quadkey} oder {z}, {x} und {y} Platzhalter enthalten",
|
||||
"attribution": "Zuschreibung",
|
||||
"attributionHint": "© Karten-Anbieter",
|
||||
"attributionRequired": "Zuschreibung ist erforderlich",
|
||||
@@ -249,7 +286,14 @@
|
||||
"view": "Anzeigen",
|
||||
"deleteProfile": "Profil Löschen",
|
||||
"deleteProfileConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
|
||||
"profileDeleted": "Profil gelöscht"
|
||||
"profileDeleted": "Profil gelöscht",
|
||||
"getMore": "Weitere anzeigen...",
|
||||
"addProfileChoice": "Profil Hinzufügen",
|
||||
"addProfileChoiceMessage": "Wie möchten Sie ein Profil hinzufügen?",
|
||||
"createCustomProfile": "Benutzerdefiniertes Profil Erstellen",
|
||||
"createCustomProfileDescription": "Erstellen Sie ein Profil von Grund auf mit Ihren eigenen Tags",
|
||||
"importFromWebsite": "Von Webseite Importieren",
|
||||
"importFromWebsiteDescription": "Profile von deflock.me/identify durchsuchen und importieren"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Karten-Kacheln",
|
||||
@@ -265,6 +309,10 @@
|
||||
"profileNameRequired": "Profil-Name ist erforderlich",
|
||||
"requiresDirection": "Benötigt Richtung",
|
||||
"requiresDirectionSubtitle": "Ob Kameras dieses Typs ein Richtungs-Tag benötigen",
|
||||
"fov": "Sichtfeld",
|
||||
"fovHint": "Sichtfeld in Grad (leer lassen für Standard)",
|
||||
"fovSubtitle": "Kamera-Sichtfeld - verwendet für Kegelbreite und Bereichsübertragungsformat",
|
||||
"fovInvalid": "Sichtfeld muss zwischen 1 und 360 Grad liegen",
|
||||
"submittable": "Übertragbar",
|
||||
"submittableSubtitle": "Ob dieses Profil für Kamera-Übertragungen verwendet werden kann",
|
||||
"osmTags": "OSM-Tags",
|
||||
@@ -335,7 +383,16 @@
|
||||
"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."
|
||||
"noOperatorProfilesMessage": "Erstellen Sie Betreiber-Profile in den Einstellungen, um zusätzliche Tags auf Ihre Knoten-Übertragungen anzuwenden.",
|
||||
"profileTags": "Profil-Tags",
|
||||
"profileTagsDescription": "Geben Sie Werte für Tags an, die verfeinert werden müssen:",
|
||||
"selectValue": "Wert auswählen...",
|
||||
"noValue": "(Kein Wert)",
|
||||
"noSuggestions": "Keine Vorschläge verfügbar",
|
||||
"existingTagsTitle": "Vorhandene Tags",
|
||||
"existingTagsDescription": "Bearbeiten Sie die vorhandenen Tags auf diesem Gerät. Hinzufügen, entfernen oder ändern Sie beliebige Tags:",
|
||||
"existingOperator": "<Vorhandener Betreiber>",
|
||||
"existingOperatorTags": "vorhandene Betreiber-Tags"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Kachel-Typen können während des Herunterladens von Offline-Bereichen nicht geändert werden",
|
||||
@@ -365,22 +422,28 @@
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Netzwerkstatus-Anzeige anzeigen",
|
||||
"showIndicatorSubtitle": "Netzwerk-Ladestatus und Fehlerstatus auf der Karte anzeigen",
|
||||
"loading": "Lädt...",
|
||||
"timedOut": "Zeitüberschreitung",
|
||||
"noData": "Keine Kacheln hier",
|
||||
"success": "Fertig",
|
||||
"nodeLimitReached": "Limit erreicht - in Einstellungen erhöhen",
|
||||
"tileProviderSlow": "Kartenanbieter langsam",
|
||||
"nodeDataSlow": "Knotendaten langsam",
|
||||
"networkIssues": "Netzwerkprobleme"
|
||||
"showIndicatorSubtitle": "Ladestatus und Fehler für Überwachungsdaten anzeigen",
|
||||
"loading": "Lade Überwachungsdaten...",
|
||||
"timedOut": "Anfrage Zeitüberschreitung",
|
||||
"noData": "Keine Offline-Daten",
|
||||
"success": "Überwachungsdaten geladen",
|
||||
"nodeDataSlow": "Überwachungsdaten langsam",
|
||||
"rateLimited": "Server-Limitierung",
|
||||
"networkError": "Netzwerkfehler"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "Zeige {rendered} von {total} Geräten",
|
||||
"editingDisabledMessage": "Zu viele Geräte sichtbar für sicheres Bearbeiten. Vergrößern Sie die Ansicht, um die Anzahl sichtbarer Geräte zu reduzieren, und versuchen Sie es erneut."
|
||||
},
|
||||
"about": {
|
||||
"title": "DeFlock - Überwachungs-Transparenz",
|
||||
"description": "DeFlock ist eine datenschutzorientierte mobile App zur Kartierung öffentlicher Überwachungsinfrastruktür mit OpenStreetMap. Dokumentieren Sie Kameras, ALPRs, Schussdetektoren und andere Überwachungsgeräte in Ihrer Gemeinde, um diese Infrastruktur sichtbar und durchsuchbar zu machen.",
|
||||
"features": "• Offline-fähige Kartierung mit herunterladbaren Bereichen\n• Direkter Upload zu OpenStreetMap mit OAuth2\n• Integrierte Profile für große Hersteller\n• Datenschutzfreundlich - keine Nutzerdaten gesammelt\n• Multiple Kartenanbieter (OSM, Satellitenbilder)",
|
||||
"initiative": "Teil der breiteren DeFlock-Initiative zur Förderung von Überwachungstransparenz.",
|
||||
"footer": "Besuchen Sie: deflock.me\nGebaut mit Flutter • Open Source"
|
||||
"footer": "Besuchen Sie: deflock.me\nGebaut mit Flutter • Open Source",
|
||||
"showWelcome": "Willkommensnachricht anzeigen",
|
||||
"showSubmissionGuide": "Einreichungsleitfaden anzeigen",
|
||||
"viewReleaseNotes": "Release-Notizen anzeigen"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Willkommen bei DeFlock",
|
||||
@@ -393,6 +456,22 @@
|
||||
"dontShowAgain": "Diese Willkommensnachricht nicht mehr anzeigen",
|
||||
"getStarted": "Los geht's mit DeFlocking!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Einreichungs-Richtlinien",
|
||||
"description": "Bevor Sie Ihr erstes Überwachungsgerät einreichen, lesen Sie bitte diese wichtigen Richtlinien für qualitativ hochwertige Beiträge zu OpenStreetMap.",
|
||||
"bestPractices": "• Nur Geräte erfassen, die Sie persönlich beobachtet haben\n• Zeit nehmen für genaue Identifikation von Typ und Hersteller\n• Präzise Positionierung - nah heranzoomen vor Markierung\n• Richtungsinformationen angeben, falls zutreffend\n• Tag-Auswahl vor dem Senden überprüfen",
|
||||
"placementNote": "Denken Sie daran: Genaue, persönlich verifizierte Daten sind essentiell für die DeFlock-Community und das OpenStreetMap-Projekt.",
|
||||
"moreInfo": "Für detaillierte Anleitungen zur Geräteerkennung und Kartierung:",
|
||||
"identificationGuide": "Identifikationsleitfaden",
|
||||
"osmWiki": "OpenStreetMap Wiki",
|
||||
"dontShowAgain": "Diese Anleitung nicht mehr anzeigen",
|
||||
"gotIt": "Verstanden!"
|
||||
},
|
||||
"positioningTutorial": {
|
||||
"title": "Position verfeinern",
|
||||
"instructions": "Ziehen Sie die Karte, um die Geräte-Markierung präzise über dem Standort des Überwachungsgeräts zu positionieren.",
|
||||
"hint": "Sie können für bessere Genauigkeit vor der Positionierung hineinzoomen."
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Ort suchen",
|
||||
"searchPlaceholder": "Orte oder Koordinaten suchen...",
|
||||
@@ -416,18 +495,13 @@
|
||||
"endSelect": "Ende (auswählen)",
|
||||
"distance": "Entfernung: {} km",
|
||||
"routeActive": "Route aktiv",
|
||||
"locationsTooClose": "Start- und Endpositionen sind zu nah beieinander",
|
||||
"navigationSettings": "Navigation",
|
||||
"navigationSettingsSubtitle": "Routenplanung und Vermeidungseinstellungen",
|
||||
"avoidanceDistance": "Vermeidungsabstand",
|
||||
"avoidanceDistanceSubtitle": "Mindestabstand zu Überwachungsgeräten",
|
||||
"searchHistory": "Max. Suchverlauf",
|
||||
"searchHistorySubtitle": "Maximale Anzahl kürzlicher Suchen zum Merken",
|
||||
"units": "Einheiten",
|
||||
"unitsSubtitle": "Anzeigeeinheiten für Entfernungen und Messungen",
|
||||
"metric": "Metrisch (km, m)",
|
||||
"imperial": "Britisch (mi, ft)",
|
||||
"meters": "Meter",
|
||||
"feet": "Fuß"
|
||||
"searchHistorySubtitle": "Maximale Anzahl kürzlicher Suchen zum Merken"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Verdächtige Standorte",
|
||||
@@ -463,5 +537,19 @@
|
||||
"url": "URL",
|
||||
"coordinates": "Koordinaten",
|
||||
"noAddressAvailable": "Keine Adresse verfügbar"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mi",
|
||||
"metersLong": "Meter",
|
||||
"feetLong": "Fuß",
|
||||
"kilometersLong": "Kilometer",
|
||||
"milesLong": "Meilen",
|
||||
"metric": "Metrisch",
|
||||
"imperial": "Imperial",
|
||||
"metricDescription": "Metrisch (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,10 @@
|
||||
"description": "DeFlock is a privacy-focused mobile app for mapping public surveillance infrastructure using OpenStreetMap. Document cameras, ALPRs, gunshot detectors, and other surveillance devices in your community to make this infrastructure visible and searchable.",
|
||||
"features": "• Offline-capable mapping with downloadable areas\n• Upload directly to OpenStreetMap with OAuth2\n• Built-in profiles for major manufacturers\n• Privacy-respecting - no user data collected\n• Multiple map tile providers (OSM, satellite imagery)",
|
||||
"initiative": "Part of the broader DeFlock initiative to promote surveillance transparency.",
|
||||
"footer": "Visit: deflock.me\nBuilt with Flutter • Open Source"
|
||||
"footer": "Visit: deflock.me\nBuilt with Flutter • Open Source",
|
||||
"showWelcome": "Show Welcome Message",
|
||||
"showSubmissionGuide": "Show Submission Guide",
|
||||
"viewReleaseNotes": "View Release Notes"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Welcome to DeFlock",
|
||||
@@ -23,6 +26,22 @@
|
||||
"dontShowAgain": "Don't show this welcome message again",
|
||||
"getStarted": "Let's Get DeFlocking!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Submission Best Practices",
|
||||
"description": "Before submitting your first surveillance device, please take a moment to review these important guidelines to ensure high-quality contributions to OpenStreetMap.",
|
||||
"bestPractices": "• Only map devices you've personally observed firsthand\n• Take time to accurately identify the device type and manufacturer\n• Use precise positioning - zoom in close before placing the marker\n• Include direction information when applicable\n• Double-check your tag selections before submitting",
|
||||
"placementNote": "Remember: Accurate, first-hand data is essential for the DeFlock community and OpenStreetMap project.",
|
||||
"moreInfo": "For detailed guidance on device identification and mapping best practices:",
|
||||
"identificationGuide": "Identification Guide",
|
||||
"osmWiki": "OpenStreetMap Wiki",
|
||||
"dontShowAgain": "Don't show this guide again",
|
||||
"gotIt": "Got It!"
|
||||
},
|
||||
"positioningTutorial": {
|
||||
"title": "Refine Your Location",
|
||||
"instructions": "Drag the map to position the device marker precisely over the surveillance device's location.",
|
||||
"hint": "You can zoom in for better accuracy before positioning."
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "New Node",
|
||||
"download": "Download",
|
||||
@@ -33,6 +52,7 @@
|
||||
"ok": "OK",
|
||||
"close": "Close",
|
||||
"submit": "Submit",
|
||||
"logIn": "Log In",
|
||||
"saveEdit": "Save Edit",
|
||||
"clear": "Clear",
|
||||
"viewOnOSM": "View on OSM",
|
||||
@@ -64,14 +84,18 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
"language": "Language & Region",
|
||||
"systemDefault": "System Default",
|
||||
"aboutInfo": "About / Info",
|
||||
"aboutThisApp": "About This App",
|
||||
"aboutSubtitle": "App information and credits",
|
||||
"languageSubtitle": "Choose your preferred language",
|
||||
"languageSubtitle": "Choose your preferred language and units",
|
||||
"distanceUnit": "Distance Units",
|
||||
"distanceUnitSubtitle": "Choose between metric (km/m) or imperial (mi/ft) units",
|
||||
"metricUnits": "Metric (km, m)",
|
||||
"imperialUnits": "Imperial (mi, ft)",
|
||||
"maxNodes": "Max nodes drawn",
|
||||
"maxNodesSubtitle": "Set an upper limit for the number of nodes on the map (default: 250).",
|
||||
"maxNodesSubtitle": "Set an upper limit for the number of nodes on the map.",
|
||||
"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.",
|
||||
@@ -99,8 +123,7 @@
|
||||
"enableNotifications": "Enable Notifications",
|
||||
"checkingPermissions": "Checking permissions...",
|
||||
"alertDistance": "Alert distance: ",
|
||||
"meters": "meters",
|
||||
"rangeInfo": "Range: {}-{} meters (default: {})"
|
||||
"rangeInfo": "Range: {}-{} {} (default: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Node #{}",
|
||||
@@ -120,8 +143,8 @@
|
||||
"mustBeLoggedIn": "You must be logged in to submit new nodes. Please log in via Settings.",
|
||||
"enableSubmittableProfile": "Enable a submittable profile in Settings to submit new nodes.",
|
||||
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to submit new nodes.",
|
||||
"refineTags": "Refine Tags",
|
||||
"refineTagsWithProfile": "Refine Tags ({})"
|
||||
"loadingAreaData": "Loading area data... Please wait before submitting.",
|
||||
"refineTags": "Refine Tags"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Edit Node #{}",
|
||||
@@ -135,11 +158,16 @@
|
||||
"sandboxModeWarning": "Cannot submit edits on production nodes to sandbox. Switch to Production mode in Settings to edit nodes.",
|
||||
"enableSubmittableProfile": "Enable a submittable profile in Settings to edit nodes.",
|
||||
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to edit nodes.",
|
||||
"loadingAreaData": "Loading area data... Please wait before submitting.",
|
||||
"cannotMoveConstrainedNode": "Cannot move this camera - it's connected to another map element (OSM way/relation). You can still edit its tags and direction.",
|
||||
"zoomInRequiredMessage": "Zoom in to at least level {} to add or edit surveillance nodes. This ensures precise positioning for accurate mapping.",
|
||||
"extractFromWay": "Extract node from way/relation",
|
||||
"extractFromWaySubtitle": "Create new node with same tags, allow moving to new location",
|
||||
"refineTags": "Refine Tags",
|
||||
"refineTagsWithProfile": "Refine Tags ({})"
|
||||
"existingTags": "<Existing tags>",
|
||||
"noChangesDetected": "No changes detected - nothing to submit",
|
||||
"noChangesTitle": "No Changes to Submit",
|
||||
"noChangesMessage": "You haven't made any changes to this node. To submit an edit, you need to change the location, profile, directions, or tags."
|
||||
},
|
||||
"download": {
|
||||
"title": "Download Map Area",
|
||||
@@ -151,9 +179,16 @@
|
||||
"withinTileLimit": "Within {} tile limit",
|
||||
"exceedsTileLimit": "Current selection exceeds {} tile limit",
|
||||
"offlineModeWarning": "Downloads disabled while in offline mode. Disable offline mode to download new areas.",
|
||||
"areaTooBigMessage": "Zoom in to at least level {} to download offline areas. Large area downloads can cause the app to become unresponsive.",
|
||||
"downloadStarted": "Download started! Fetching tiles and nodes...",
|
||||
"downloadFailed": "Failed to start download: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Started",
|
||||
"message": "Download started! Fetching tiles and nodes...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "View Progress in Settings"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Upload Destination",
|
||||
"subtitle": "Choose where cameras are uploaded",
|
||||
@@ -162,7 +197,8 @@
|
||||
"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).",
|
||||
"simulateDescription": "Simulate uploads (does not contact OSM servers)"
|
||||
"simulateDescription": "Simulate uploads (does not contact OSM servers)",
|
||||
"cannotChangeWithQueue": "Cannot change upload destination while {} items are in queue. Clear queue first."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "OpenStreetMap Account",
|
||||
@@ -185,7 +221,20 @@
|
||||
"deleteAccountSubtitle": "Manage your OpenStreetMap account",
|
||||
"deleteAccountExplanation": "To delete your OpenStreetMap account, you'll need to visit the OpenStreetMap website. This will permanently remove your OSM account and all associated data.",
|
||||
"deleteAccountWarning": "Warning: This action cannot be undone and will permanently delete your OSM account.",
|
||||
"goToOSM": "Go to OpenStreetMap"
|
||||
"goToOSM": "Go to OpenStreetMap",
|
||||
"accountManagement": "Account Management",
|
||||
"accountManagementDescription": "To delete your OpenStreetMap account, you'll need to visit the appropriate OpenStreetMap website. This will permanently remove your account and all associated data.",
|
||||
"currentDestinationProduction": "Currently connected to: Production OpenStreetMap",
|
||||
"currentDestinationSandbox": "Currently connected to: Sandbox OpenStreetMap",
|
||||
"currentDestinationSimulate": "Currently in: Simulate mode (no real account)",
|
||||
"viewMessages": "View Messages on OSM",
|
||||
"unreadMessagesCount": "You have {} unread messages",
|
||||
"noUnreadMessages": "No unread messages",
|
||||
"reauthRequired": "Refresh Authentication",
|
||||
"reauthExplanation": "You must refresh your authentication to receive OSM message notifications through the app.",
|
||||
"reauthBenefit": "This will enable notification dots when you have unread messages on OpenStreetMap.",
|
||||
"reauthNow": "Do That Now",
|
||||
"reauthLater": "Later"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Upload Queue",
|
||||
@@ -203,7 +252,7 @@
|
||||
"queueCleared": "Queue cleared",
|
||||
"uploadQueueTitle": "Upload Queue ({} items)",
|
||||
"queueIsEmpty": "Queue is empty",
|
||||
"cameraWithIndex": "Camera {}",
|
||||
"itemWithIndex": "Item {}",
|
||||
"error": " (Error)",
|
||||
"completing": " (Completing...)",
|
||||
"destination": "Dest: {}",
|
||||
@@ -213,7 +262,14 @@
|
||||
"attempts": "Attempts: {}",
|
||||
"uploadFailedRetry": "Upload failed. Tap retry to try again.",
|
||||
"retryUpload": "Retry upload",
|
||||
"clearAll": "Clear All"
|
||||
"clearAll": "Clear All",
|
||||
"errorDetails": "Error Details",
|
||||
"creatingChangeset": " (Creating changeset...)",
|
||||
"uploading": " (Uploading...)",
|
||||
"closingChangeset": " (Closing changeset...)",
|
||||
"processingPaused": "Queue Processing Paused",
|
||||
"pausedDueToOffline": "Upload processing is paused because offline mode is enabled.",
|
||||
"pausedByUser": "Upload processing is manually paused."
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Tile Providers",
|
||||
@@ -245,7 +301,7 @@
|
||||
"urlTemplate": "URL Template",
|
||||
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "URL template is required",
|
||||
"urlTemplatePlaceholders": "URL must contain {z}, {x}, and {y} placeholders",
|
||||
"urlTemplatePlaceholders": "URL must contain either {quadkey} or {z}, {x}, and {y} placeholders",
|
||||
"attribution": "Attribution",
|
||||
"attributionHint": "© Map Provider",
|
||||
"attributionRequired": "Attribution is required",
|
||||
@@ -267,7 +323,14 @@
|
||||
"view": "View",
|
||||
"deleteProfile": "Delete Profile",
|
||||
"deleteProfileConfirm": "Are you sure you want to delete \"{}\"?",
|
||||
"profileDeleted": "Profile deleted"
|
||||
"profileDeleted": "Profile deleted",
|
||||
"getMore": "Get more...",
|
||||
"addProfileChoice": "Add Profile",
|
||||
"addProfileChoiceMessage": "How would you like to add a profile?",
|
||||
"createCustomProfile": "Create Custom Profile",
|
||||
"createCustomProfileDescription": "Build a profile from scratch with your own tags",
|
||||
"importFromWebsite": "Import from Website",
|
||||
"importFromWebsiteDescription": "Browse and import profiles from deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Map Tiles",
|
||||
@@ -283,6 +346,10 @@
|
||||
"profileNameRequired": "Profile name is required",
|
||||
"requiresDirection": "Requires Direction",
|
||||
"requiresDirectionSubtitle": "Whether cameras of this type need a direction tag",
|
||||
"fov": "Field of View",
|
||||
"fovHint": "FOV in degrees (leave empty for default)",
|
||||
"fovSubtitle": "Camera field of view - used for cone width and range submission format",
|
||||
"fovInvalid": "FOV must be between 1 and 360 degrees",
|
||||
"submittable": "Submittable",
|
||||
"submittableSubtitle": "Whether this profile can be used for camera submissions",
|
||||
"osmTags": "OSM Tags",
|
||||
@@ -353,7 +420,16 @@
|
||||
"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."
|
||||
"noOperatorProfilesMessage": "Create operator profiles in Settings to apply additional tags to your node submissions.",
|
||||
"profileTags": "Profile Tags",
|
||||
"profileTagsDescription": "Complete these optional tag values for more detailed submissions:",
|
||||
"selectValue": "Select value...",
|
||||
"noValue": "(leave empty)",
|
||||
"noSuggestions": "No suggestions available",
|
||||
"existingTagsTitle": "Existing Tags",
|
||||
"existingTagsDescription": "Edit the existing tags on this device. Add, remove, or modify any tag:",
|
||||
"existingOperator": "<Existing operator>",
|
||||
"existingOperatorTags": "existing operator tags"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Cannot change tile types while downloading offline areas",
|
||||
@@ -383,15 +459,18 @@
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Show network status indicator",
|
||||
"showIndicatorSubtitle": "Display network loading and error status on the map",
|
||||
"loading": "Loading...",
|
||||
"timedOut": "Timed out",
|
||||
"noData": "No tiles here",
|
||||
"success": "Done",
|
||||
"nodeLimitReached": "Showing limit - increase in settings",
|
||||
"tileProviderSlow": "Tile provider slow",
|
||||
"nodeDataSlow": "Node data slow",
|
||||
"networkIssues": "Network issues"
|
||||
"showIndicatorSubtitle": "Display surveillance data loading and error status",
|
||||
"loading": "Loading surveillance data...",
|
||||
"timedOut": "Request timed out",
|
||||
"noData": "No offline data",
|
||||
"success": "Surveillance data loaded",
|
||||
"nodeDataSlow": "Surveillance data slow",
|
||||
"rateLimited": "Rate limited by server",
|
||||
"networkError": "Network error"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "Showing {rendered} of {total} devices",
|
||||
"editingDisabledMessage": "Too many devices shown to safely edit. Zoom in further to reduce the number of visible devices, then try again."
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Search Location",
|
||||
@@ -416,18 +495,13 @@
|
||||
"endSelect": "End (select)",
|
||||
"distance": "Distance: {} km",
|
||||
"routeActive": "Route active",
|
||||
"locationsTooClose": "Start and end locations are too close together",
|
||||
"navigationSettings": "Navigation",
|
||||
"navigationSettingsSubtitle": "Route planning and avoidance settings",
|
||||
"avoidanceDistance": "Avoidance Distance",
|
||||
"avoidanceDistanceSubtitle": "Minimum distance to stay away from surveillance devices",
|
||||
"searchHistory": "Max Search History",
|
||||
"searchHistorySubtitle": "Maximum number of recent searches to remember",
|
||||
"units": "Units",
|
||||
"unitsSubtitle": "Display units for distances and measurements",
|
||||
"metric": "Metric (km, m)",
|
||||
"imperial": "Imperial (mi, ft)",
|
||||
"meters": "meters",
|
||||
"feet": "feet"
|
||||
"searchHistorySubtitle": "Maximum number of recent searches to remember"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Suspected Locations",
|
||||
@@ -463,5 +537,19 @@
|
||||
"url": "URL",
|
||||
"coordinates": "Coordinates",
|
||||
"noAddressAvailable": "No address available"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mi",
|
||||
"metersLong": "meters",
|
||||
"feetLong": "feet",
|
||||
"kilometersLong": "kilometers",
|
||||
"milesLong": "miles",
|
||||
"metric": "Metric",
|
||||
"imperial": "Imperial",
|
||||
"metricDescription": "Metric (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,10 @@
|
||||
"description": "DeFlock es una aplicación móvil enfocada en la privacidad para mapear infraestructura de vigilancia pública usando OpenStreetMap. Documenta cámaras, ALPRs, detectores de disparos y otros dispositivos de vigilancia en tu comunidad para hacer visible y consultable esta infraestructura.",
|
||||
"features": "• Mapeo con capacidad offline con áreas descargables\n• Subida directa a OpenStreetMap con OAuth2\n• Perfiles integrados para fabricantes principales\n• Respeta la privacidad - no se recopilan datos del usuario\n• Múltiples proveedores de mapas (OSM, imágenes satelitales)",
|
||||
"initiative": "Parte de la iniciativa más amplia DeFlock para promover la transparencia en vigilancia.",
|
||||
"footer": "Visita: deflock.me\nConstruido con Flutter • Código Abierto"
|
||||
"footer": "Visita: deflock.me\nConstruido con Flutter • Código Abierto",
|
||||
"showWelcome": "Mostrar Mensaje de Bienvenida",
|
||||
"showSubmissionGuide": "Mostrar Guía de Envío",
|
||||
"viewReleaseNotes": "Ver Notas de Lanzamiento"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Bienvenido a DeFlock",
|
||||
@@ -23,6 +26,22 @@
|
||||
"dontShowAgain": "No mostrar este mensaje de bienvenida otra vez",
|
||||
"getStarted": "¡Comencemos con DeFlock!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Mejores Prácticas de Envío",
|
||||
"description": "Antes de enviar su primer dispositivo de vigilancia, tómese un momento para revisar estas pautas importantes para contribuciones de alta calidad a OpenStreetMap.",
|
||||
"bestPractices": "• Solo mapee dispositivos que haya observado personalmente\n• Tómese tiempo para identificar con precisión el tipo y fabricante\n• Use posicionamiento preciso - acerque antes de colocar el marcador\n• Incluya información de dirección cuando sea aplicable\n• Verifique sus selecciones de etiquetas antes de enviar",
|
||||
"placementNote": "Recuerde: Los datos precisos y de primera mano son esenciales para la comunidad DeFlock y el proyecto OpenStreetMap.",
|
||||
"moreInfo": "Para orientación detallada sobre identificación de dispositivos y mejores prácticas de mapeo:",
|
||||
"identificationGuide": "Guía de Identificación",
|
||||
"osmWiki": "Wiki de OpenStreetMap",
|
||||
"dontShowAgain": "No mostrar esta guía otra vez",
|
||||
"gotIt": "¡Entendido!"
|
||||
},
|
||||
"positioningTutorial": {
|
||||
"title": "Refinar Ubicación",
|
||||
"instructions": "Arrastra el mapa para posicionar el marcador del dispositivo con precisión sobre la ubicación del dispositivo de vigilancia.",
|
||||
"hint": "Puedes acercar el zoom para obtener mejor precisión antes de posicionar."
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Nuevo Nodo",
|
||||
"download": "Descargar",
|
||||
@@ -33,6 +52,7 @@
|
||||
"ok": "Aceptar",
|
||||
"close": "Cerrar",
|
||||
"submit": "Enviar",
|
||||
"logIn": "Iniciar Sesión",
|
||||
"saveEdit": "Guardar Edición",
|
||||
"clear": "Limpiar",
|
||||
"viewOnOSM": "Ver en OSM",
|
||||
@@ -64,14 +84,18 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configuración",
|
||||
"language": "Idioma",
|
||||
"language": "Idioma y Región",
|
||||
"systemDefault": "Sistema por Defecto",
|
||||
"aboutInfo": "Acerca de / Información",
|
||||
"aboutThisApp": "Acerca de Esta App",
|
||||
"aboutSubtitle": "Información de la aplicación y créditos",
|
||||
"languageSubtitle": "Elige tu idioma preferido",
|
||||
"languageSubtitle": "Elige tu idioma preferido y unidades",
|
||||
"distanceUnit": "Unidades de Distancia",
|
||||
"distanceUnitSubtitle": "Elige entre unidades métricas (km/m) o imperiales (mi/ft)",
|
||||
"metricUnits": "Métrico (km, m)",
|
||||
"imperialUnits": "Imperial (mi, ft)",
|
||||
"maxNodes": "Máx. nodos dibujados",
|
||||
"maxNodesSubtitle": "Establecer un límite superior para el número de nodos en el mapa (predeterminado: 250).",
|
||||
"maxNodesSubtitle": "Establecer un límite superior para el número de nodos en el mapa.",
|
||||
"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.",
|
||||
@@ -99,8 +123,7 @@
|
||||
"enableNotifications": "Habilitar Notificaciones",
|
||||
"checkingPermissions": "Verificando permisos...",
|
||||
"alertDistance": "Distancia de alerta: ",
|
||||
"meters": "metros",
|
||||
"rangeInfo": "Rango: {}-{} metros (predeterminado: {})"
|
||||
"rangeInfo": "Rango: {}-{} {} (predeterminado: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nodo #{}",
|
||||
@@ -120,8 +143,8 @@
|
||||
"mustBeLoggedIn": "Debe estar conectado para enviar nuevos nodos. Por favor, inicie sesión a través de Configuración.",
|
||||
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para enviar nuevos nodos.",
|
||||
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para enviar nuevos nodos.",
|
||||
"refineTags": "Refinar Etiquetas",
|
||||
"refineTagsWithProfile": "Refinar Etiquetas ({})"
|
||||
"loadingAreaData": "Cargando datos del área... Por favor espere antes de enviar.",
|
||||
"refineTags": "Refinar Etiquetas"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Editar Nodo #{}",
|
||||
@@ -135,11 +158,16 @@
|
||||
"sandboxModeWarning": "No se pueden enviar ediciones de nodos de producción al sandbox. Cambie al modo Producción en Configuración para editar nodos.",
|
||||
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para editar nodos.",
|
||||
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para editar nodos.",
|
||||
"loadingAreaData": "Cargando datos del área... Por favor espere antes de enviar.",
|
||||
"cannotMoveConstrainedNode": "No se puede mover esta cámara - está conectada a otro elemento del mapa (OSM way/relation). Aún puede editar sus etiquetas y dirección.",
|
||||
"zoomInRequiredMessage": "Amplíe al menos al nivel {} para agregar o editar nodos de vigilancia. Esto garantiza un posicionamiento preciso para un mapeo exacto.",
|
||||
"extractFromWay": "Extraer nodo de way/relation",
|
||||
"extractFromWaySubtitle": "Crear nuevo nodo con las mismas etiquetas, permitir mover a nueva ubicación",
|
||||
"refineTags": "Refinar Etiquetas",
|
||||
"refineTagsWithProfile": "Refinar Etiquetas ({})"
|
||||
"existingTags": "<Etiquetas existentes>",
|
||||
"noChangesDetected": "No se detectaron cambios - nada que enviar",
|
||||
"noChangesTitle": "No Hay Cambios que Enviar",
|
||||
"noChangesMessage": "No ha realizado ningún cambio en este nodo. Para enviar una edición, necesita cambiar la ubicación, el perfil, las direcciones o las etiquetas."
|
||||
},
|
||||
"download": {
|
||||
"title": "Descargar Área del Mapa",
|
||||
@@ -151,9 +179,16 @@
|
||||
"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.",
|
||||
"areaTooBigMessage": "Amplíe al menos al nivel {} para descargar áreas sin conexión. Las descargas de áreas grandes pueden hacer que la aplicación deje de responder.",
|
||||
"downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y nodos...",
|
||||
"downloadFailed": "Error al iniciar la descarga: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Descarga Iniciada",
|
||||
"message": "¡Descarga iniciada! Obteniendo mosaicos y nodos...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Ver Progreso en Configuración"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destino de Subida",
|
||||
"subtitle": "Elige dónde se suben las cámaras",
|
||||
@@ -162,7 +197,8 @@
|
||||
"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).",
|
||||
"simulateDescription": "Simular subidas (no contacta servidores OSM)"
|
||||
"simulateDescription": "Simular subidas (no contacta servidores OSM)",
|
||||
"cannotChangeWithQueue": "No se puede cambiar el destino de subida mientras hay {} elementos en cola. Limpie la cola primero."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "Cuenta de OpenStreetMap",
|
||||
@@ -185,7 +221,20 @@
|
||||
"deleteAccountSubtitle": "Gestiona tu cuenta de OpenStreetMap",
|
||||
"deleteAccountExplanation": "Para eliminar tu cuenta de OpenStreetMap, necesitarás visitar el sitio web de OpenStreetMap. Esto eliminará permanentemente tu cuenta OSM y todos los datos asociados.",
|
||||
"deleteAccountWarning": "Advertencia: Esta acción no se puede deshacer y eliminará permanentemente tu cuenta OSM.",
|
||||
"goToOSM": "Ir a OpenStreetMap"
|
||||
"goToOSM": "Ir a OpenStreetMap",
|
||||
"accountManagement": "Gestión de Cuenta",
|
||||
"accountManagementDescription": "Para eliminar su cuenta de OpenStreetMap, debe visitar el sitio web de OpenStreetMap correspondiente. Esto eliminará permanentemente su cuenta y todos los datos asociados.",
|
||||
"currentDestinationProduction": "Actualmente conectado a: OpenStreetMap de Producción",
|
||||
"currentDestinationSandbox": "Actualmente conectado a: OpenStreetMap Sandbox",
|
||||
"currentDestinationSimulate": "Actualmente en: Modo de simulación (sin cuenta real)",
|
||||
"viewMessages": "Ver Mensajes en OSM",
|
||||
"unreadMessagesCount": "Tienes {} mensajes sin leer",
|
||||
"noUnreadMessages": "No hay mensajes sin leer",
|
||||
"reauthRequired": "Actualizar Autenticación",
|
||||
"reauthExplanation": "Debes actualizar tu autenticación para recibir notificaciones de mensajes OSM a través de la aplicación.",
|
||||
"reauthBenefit": "Esto habilitará puntos de notificación cuando tengas mensajes sin leer en OpenStreetMap.",
|
||||
"reauthNow": "Hazlo Ahora",
|
||||
"reauthLater": "Más Tarde"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Cola de Subida",
|
||||
@@ -203,7 +252,7 @@
|
||||
"queueCleared": "Cola limpiada",
|
||||
"uploadQueueTitle": "Cola de Subida ({} elementos)",
|
||||
"queueIsEmpty": "La cola está vacía",
|
||||
"cameraWithIndex": "Cámara {}",
|
||||
"itemWithIndex": "Elemento {}",
|
||||
"error": " (Error)",
|
||||
"completing": " (Completando...)",
|
||||
"destination": "Dest: {}",
|
||||
@@ -213,7 +262,14 @@
|
||||
"attempts": "Intentos: {}",
|
||||
"uploadFailedRetry": "Subida falló. Toque reintentar para intentar de nuevo.",
|
||||
"retryUpload": "Reintentar subida",
|
||||
"clearAll": "Limpiar Todo"
|
||||
"clearAll": "Limpiar Todo",
|
||||
"errorDetails": "Detalles del Error",
|
||||
"creatingChangeset": " (Creando changeset...)",
|
||||
"uploading": " (Subiendo...)",
|
||||
"closingChangeset": " (Cerrando changeset...)",
|
||||
"processingPaused": "Procesamiento de Cola Pausado",
|
||||
"pausedDueToOffline": "El procesamiento de subida está pausado porque el modo sin conexión está habilitado.",
|
||||
"pausedByUser": "El procesamiento de subida está pausado manualmente."
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Proveedores de Tiles",
|
||||
@@ -245,7 +301,7 @@
|
||||
"urlTemplate": "Plantilla de URL",
|
||||
"urlTemplateHint": "https://ejemplo.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "La plantilla de URL es requerida",
|
||||
"urlTemplatePlaceholders": "La URL debe contener marcadores {z}, {x} y {y}",
|
||||
"urlTemplatePlaceholders": "La URL debe contener marcadores {quadkey} o {z}, {x} y {y}",
|
||||
"attribution": "Atribución",
|
||||
"attributionHint": "© Proveedor de Mapas",
|
||||
"attributionRequired": "La atribución es requerida",
|
||||
@@ -267,7 +323,14 @@
|
||||
"view": "Ver",
|
||||
"deleteProfile": "Eliminar Perfil",
|
||||
"deleteProfileConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
|
||||
"profileDeleted": "Perfil eliminado"
|
||||
"profileDeleted": "Perfil eliminado",
|
||||
"getMore": "Obtener más...",
|
||||
"addProfileChoice": "Añadir Perfil",
|
||||
"addProfileChoiceMessage": "¿Cómo desea añadir un perfil?",
|
||||
"createCustomProfile": "Crear Perfil Personalizado",
|
||||
"createCustomProfileDescription": "Crear un perfil desde cero con sus propias etiquetas",
|
||||
"importFromWebsite": "Importar desde Sitio Web",
|
||||
"importFromWebsiteDescription": "Explorar e importar perfiles desde deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tiles de Mapa",
|
||||
@@ -283,6 +346,10 @@
|
||||
"profileNameRequired": "El nombre del perfil es requerido",
|
||||
"requiresDirection": "Requiere Dirección",
|
||||
"requiresDirectionSubtitle": "Si las cámaras de este tipo necesitan una etiqueta de dirección",
|
||||
"fov": "Campo de Visión",
|
||||
"fovHint": "Campo de visión en grados (dejar vacío para el predeterminado)",
|
||||
"fovSubtitle": "Campo de visión de la cámara - usado para el ancho del cono y formato de envío por rango",
|
||||
"fovInvalid": "El campo de visión debe estar entre 1 y 360 grados",
|
||||
"submittable": "Envíable",
|
||||
"submittableSubtitle": "Si este perfil puede usarse para envíos de cámaras",
|
||||
"osmTags": "Etiquetas OSM",
|
||||
@@ -353,7 +420,16 @@
|
||||
"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."
|
||||
"noOperatorProfilesMessage": "Cree perfiles de operador en Configuración para aplicar etiquetas adicionales a sus envíos de nodos.",
|
||||
"profileTags": "Etiquetas de Perfil",
|
||||
"profileTagsDescription": "Especifique valores para etiquetas que necesitan refinamiento:",
|
||||
"selectValue": "Seleccionar un valor...",
|
||||
"noValue": "(Sin valor)",
|
||||
"noSuggestions": "No hay sugerencias disponibles",
|
||||
"existingTagsTitle": "Etiquetas Existentes",
|
||||
"existingTagsDescription": "Edite las etiquetas existentes en este dispositivo. Agregue, elimine o modifique cualquier etiqueta:",
|
||||
"existingOperator": "<Operador existente>",
|
||||
"existingOperatorTags": "etiquetas de operador existentes"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "No se pueden cambiar los tipos de teselas mientras se descargan áreas sin conexión",
|
||||
@@ -383,15 +459,18 @@
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Mostrar indicador de estado de red",
|
||||
"showIndicatorSubtitle": "Mostrar estado de carga y errores de red en el mapa",
|
||||
"loading": "Cargando...",
|
||||
"timedOut": "Tiempo agotado",
|
||||
"noData": "Sin mosaicos aquí",
|
||||
"success": "Hecho",
|
||||
"nodeLimitReached": "Mostrando límite - aumentar en ajustes",
|
||||
"tileProviderSlow": "Proveedor de mosaicos lento",
|
||||
"nodeDataSlow": "Datos de nodo lentos",
|
||||
"networkIssues": "Problemas de red"
|
||||
"showIndicatorSubtitle": "Mostrar estado de carga y errores de datos de vigilancia",
|
||||
"loading": "Cargando datos de vigilancia...",
|
||||
"timedOut": "Solicitud agotada",
|
||||
"noData": "Sin datos sin conexión",
|
||||
"success": "Datos de vigilancia cargados",
|
||||
"nodeDataSlow": "Datos de vigilancia lentos",
|
||||
"rateLimited": "Limitado por el servidor",
|
||||
"networkError": "Error de red"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "Mostrando {rendered} de {total} dispositivos",
|
||||
"editingDisabledMessage": "Demasiados dispositivos visibles para editar con seguridad. Acerque más para reducir el número de dispositivos visibles, luego inténtelo de nuevo."
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Buscar ubicación",
|
||||
@@ -416,18 +495,13 @@
|
||||
"endSelect": "Fin (seleccionar)",
|
||||
"distance": "Distancia: {} km",
|
||||
"routeActive": "Ruta activa",
|
||||
"locationsTooClose": "Las ubicaciones de inicio y fin están demasiado cerca",
|
||||
"navigationSettings": "Navegación",
|
||||
"navigationSettingsSubtitle": "Configuración de planificación de rutas y evitación",
|
||||
"avoidanceDistance": "Distancia de evitación",
|
||||
"avoidanceDistanceSubtitle": "Distancia mínima para mantenerse alejado de dispositivos de vigilancia",
|
||||
"searchHistory": "Historial máximo de búsqueda",
|
||||
"searchHistorySubtitle": "Número máximo de búsquedas recientes para recordar",
|
||||
"units": "Unidades",
|
||||
"unitsSubtitle": "Unidades de visualización para distancias y medidas",
|
||||
"metric": "Métrico (km, m)",
|
||||
"imperial": "Imperial (mi, ft)",
|
||||
"meters": "metros",
|
||||
"feet": "pies"
|
||||
"searchHistorySubtitle": "Número máximo de búsquedas recientes para recordar"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Ubicaciones Sospechosas",
|
||||
@@ -463,5 +537,19 @@
|
||||
"url": "URL",
|
||||
"coordinates": "Coordenadas",
|
||||
"noAddressAvailable": "No hay dirección disponible"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mi",
|
||||
"metersLong": "metros",
|
||||
"feetLong": "pies",
|
||||
"kilometersLong": "kilómetros",
|
||||
"milesLong": "millas",
|
||||
"metric": "Métrico",
|
||||
"imperial": "Imperial",
|
||||
"metricDescription": "Métrico (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,10 @@
|
||||
"description": "DeFlock est une application mobile axée sur la confidentialité pour cartographier l'infrastructure de surveillance publique en utilisant OpenStreetMap. Documentez les caméras, ALPRs, détecteurs de coups de feu et autres dispositifs de surveillance dans votre communauté pour rendre cette infrastructure visible et consultable.",
|
||||
"features": "• Cartographie hors ligne avec zones téléchargeables\n• Upload direct vers OpenStreetMap avec OAuth2\n• Profils intégrés pour les principaux fabricants\n• Respectueux de la confidentialité - aucune donnée utilisateur collectée\n• Multiples fournisseurs de cartes (OSM, imagerie satellite)",
|
||||
"initiative": "Partie de l'initiative plus large DeFlock pour promouvoir la transparence de la surveillance.",
|
||||
"footer": "Visitez : deflock.me\nConstruit avec Flutter • Source Ouverte"
|
||||
"footer": "Visitez : deflock.me\nConstruit avec Flutter • Source Ouverte",
|
||||
"showWelcome": "Afficher le Message de Bienvenue",
|
||||
"showSubmissionGuide": "Afficher le Guide de Soumission",
|
||||
"viewReleaseNotes": "Voir les Notes de Version"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Bienvenue dans DeFlock",
|
||||
@@ -23,6 +26,22 @@
|
||||
"dontShowAgain": "Ne plus afficher ce message de bienvenue",
|
||||
"getStarted": "Commençons le DeFlock !"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Meilleures Pratiques de Soumission",
|
||||
"description": "Avant de soumettre votre premier dispositif de surveillance, prenez un moment pour examiner ces directives importantes pour des contributions de haute qualité à OpenStreetMap.",
|
||||
"bestPractices": "• Ne cartographiez que les dispositifs que vous avez observés personnellement\n• Prenez le temps d'identifier avec précision le type et le fabricant\n• Utilisez un positionnement précis - zoomez avant de placer le marqueur\n• Incluez les informations de direction quand c'est applicable\n• Vérifiez vos sélections d'étiquettes avant de soumettre",
|
||||
"placementNote": "Rappelez-vous : Des données précises et de première main sont essentielles pour la communauté DeFlock et le projet OpenStreetMap.",
|
||||
"moreInfo": "Pour des conseils détaillés sur l'identification des dispositifs et les meilleures pratiques de cartographie :",
|
||||
"identificationGuide": "Guide d'Identification",
|
||||
"osmWiki": "Wiki OpenStreetMap",
|
||||
"dontShowAgain": "Ne plus afficher ce guide",
|
||||
"gotIt": "Compris !"
|
||||
},
|
||||
"positioningTutorial": {
|
||||
"title": "Affiner la Position",
|
||||
"instructions": "Faites glisser la carte pour positionner le marqueur de l'appareil précisément au-dessus de l'emplacement du dispositif de surveillance.",
|
||||
"hint": "Vous pouvez zoomer pour une meilleure précision avant de positionner."
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Nouveau Nœud",
|
||||
"download": "Télécharger",
|
||||
@@ -33,6 +52,7 @@
|
||||
"ok": "OK",
|
||||
"close": "Fermer",
|
||||
"submit": "Soumettre",
|
||||
"logIn": "Se Connecter",
|
||||
"saveEdit": "Sauvegarder Modification",
|
||||
"clear": "Effacer",
|
||||
"viewOnOSM": "Voir sur OSM",
|
||||
@@ -69,9 +89,13 @@
|
||||
"aboutInfo": "À Propos / Informations",
|
||||
"aboutThisApp": "À Propos de Cette App",
|
||||
"aboutSubtitle": "Informations sur l'application et crédits",
|
||||
"languageSubtitle": "Choisissez votre langue préférée",
|
||||
"languageSubtitle": "Choisissez votre langue préférée et unités",
|
||||
"distanceUnit": "Unités de Distance",
|
||||
"distanceUnitSubtitle": "Choisir entre unités métriques (km/m) ou impériales (mi/ft)",
|
||||
"metricUnits": "Métrique (km, m)",
|
||||
"imperialUnits": "Impérial (mi, ft)",
|
||||
"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).",
|
||||
"maxNodesSubtitle": "Définir une limite supérieure pour le nombre de nœuds sur la carte.",
|
||||
"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.",
|
||||
@@ -99,8 +123,7 @@
|
||||
"enableNotifications": "Activer les Notifications",
|
||||
"checkingPermissions": "Vérification des autorisations...",
|
||||
"alertDistance": "Distance d'alerte : ",
|
||||
"meters": "mètres",
|
||||
"rangeInfo": "Plage : {}-{} mètres (par défaut : {})"
|
||||
"rangeInfo": "Plage : {}-{} {} (par défaut : {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nœud #{}",
|
||||
@@ -120,8 +143,8 @@
|
||||
"mustBeLoggedIn": "Vous devez être connecté pour soumettre de nouveaux nœuds. Veuillez vous connecter via les Paramètres.",
|
||||
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour soumettre de nouveaux nœuds.",
|
||||
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour soumettre de nouveaux nœuds.",
|
||||
"refineTags": "Affiner Balises",
|
||||
"refineTagsWithProfile": "Affiner Balises ({})"
|
||||
"loadingAreaData": "Chargement des données de zone... Veuillez patienter avant de soumettre.",
|
||||
"refineTags": "Affiner Balises"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Modifier Nœud #{}",
|
||||
@@ -135,11 +158,16 @@
|
||||
"sandboxModeWarning": "Impossible de soumettre des modifications de nœuds de production au sandbox. Passez au mode Production dans les Paramètres pour modifier les nœuds.",
|
||||
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour modifier les nœuds.",
|
||||
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour modifier les nœuds.",
|
||||
"loadingAreaData": "Chargement des données de zone... Veuillez patienter avant de soumettre.",
|
||||
"cannotMoveConstrainedNode": "Impossible de déplacer cette caméra - elle est connectée à un autre élément de carte (OSM way/relation). Vous pouvez toujours modifier ses balises et sa direction.",
|
||||
"zoomInRequiredMessage": "Zoomez au moins au niveau {} pour ajouter ou modifier des nœuds de surveillance. Cela garantit un positionnement précis pour une cartographie exacte.",
|
||||
"extractFromWay": "Extraire le nœud du way/relation",
|
||||
"extractFromWaySubtitle": "Créer un nouveau nœud avec les mêmes balises, permettre le déplacement vers un nouvel emplacement",
|
||||
"refineTags": "Affiner Balises",
|
||||
"refineTagsWithProfile": "Affiner Balises ({})"
|
||||
"existingTags": "<Balises existantes>",
|
||||
"noChangesDetected": "Aucun changement détecté - rien à soumettre",
|
||||
"noChangesTitle": "Aucun Changement à Soumettre",
|
||||
"noChangesMessage": "Vous n'avez apporté aucun changement à ce nœud. Pour soumettre une modification, vous devez changer l'emplacement, le profil, les directions ou les balises."
|
||||
},
|
||||
"download": {
|
||||
"title": "Télécharger Zone de Carte",
|
||||
@@ -151,9 +179,16 @@
|
||||
"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 nœuds...",
|
||||
"areaTooBigMessage": "Zoomez au moins au niveau {} pour télécharger des zones hors ligne. Les téléchargements de grandes zones peuvent rendre l'application non réactive.",
|
||||
"downloadStarted": "Téléchargement démarré ! Récupération des tuiles et nœuds...",
|
||||
"downloadFailed": "Échec du démarrage du téléchargement: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Téléchargement Démarré",
|
||||
"message": "Téléchargement démarré! Récupération des tuiles et nœuds...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Voir le Progrès dans Paramètres"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destination de Téléchargement",
|
||||
"subtitle": "Choisir où les caméras sont téléchargées",
|
||||
@@ -162,7 +197,8 @@
|
||||
"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).",
|
||||
"simulateDescription": "Simuler les téléchargements (ne contacte pas les serveurs OSM)"
|
||||
"simulateDescription": "Simuler les téléchargements (ne contacte pas les serveurs OSM)",
|
||||
"cannotChangeWithQueue": "Impossible de changer la destination de téléversement tant que {} éléments sont en file d'attente. Videz d'abord la file d'attente."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "Compte OpenStreetMap",
|
||||
@@ -185,7 +221,20 @@
|
||||
"deleteAccountSubtitle": "Gérez votre compte OpenStreetMap",
|
||||
"deleteAccountExplanation": "Pour supprimer votre compte OpenStreetMap, vous devrez visiter le site web OpenStreetMap. Cela supprimera définitivement votre compte OSM et toutes les données associées.",
|
||||
"deleteAccountWarning": "Attention : Cette action ne peut pas être annulée et supprimera définitivement votre compte OSM.",
|
||||
"goToOSM": "Aller à OpenStreetMap"
|
||||
"goToOSM": "Aller à OpenStreetMap",
|
||||
"accountManagement": "Gestion de Compte",
|
||||
"accountManagementDescription": "Pour supprimer votre compte OpenStreetMap, vous devez visiter le site Web OpenStreetMap approprié. Cela supprimera définitivement votre compte et toutes les données associées.",
|
||||
"currentDestinationProduction": "Actuellement connecté à : OpenStreetMap de Production",
|
||||
"currentDestinationSandbox": "Actuellement connecté à : OpenStreetMap Sandbox",
|
||||
"currentDestinationSimulate": "Actuellement en : Mode simulation (pas de compte réel)",
|
||||
"viewMessages": "Voir les Messages sur OSM",
|
||||
"unreadMessagesCount": "Vous avez {} messages non lus",
|
||||
"noUnreadMessages": "Aucun message non lu",
|
||||
"reauthRequired": "Actualiser l'Authentification",
|
||||
"reauthExplanation": "Vous devez actualiser votre authentification pour recevoir des notifications de messages OSM via l'application.",
|
||||
"reauthBenefit": "Cela activera les points de notification lorsque vous avez des messages non lus sur OpenStreetMap.",
|
||||
"reauthNow": "Le Faire Maintenant",
|
||||
"reauthLater": "Plus Tard"
|
||||
},
|
||||
"queue": {
|
||||
"title": "File de Téléchargement",
|
||||
@@ -203,7 +252,7 @@
|
||||
"queueCleared": "File vidée",
|
||||
"uploadQueueTitle": "File de Téléchargement ({} éléments)",
|
||||
"queueIsEmpty": "La file est vide",
|
||||
"cameraWithIndex": "Caméra {}",
|
||||
"itemWithIndex": "Élément {}",
|
||||
"error": " (Erreur)",
|
||||
"completing": " (Finalisation...)",
|
||||
"destination": "Dest: {}",
|
||||
@@ -213,7 +262,14 @@
|
||||
"attempts": "Tentatives: {}",
|
||||
"uploadFailedRetry": "Téléchargement échoué. Appuyer pour réessayer.",
|
||||
"retryUpload": "Réessayer téléchargement",
|
||||
"clearAll": "Tout Vider"
|
||||
"clearAll": "Tout Vider",
|
||||
"errorDetails": "Détails de l'Erreur",
|
||||
"creatingChangeset": " (Création du changeset...)",
|
||||
"uploading": " (Téléchargement...)",
|
||||
"closingChangeset": " (Fermeture du changeset...)",
|
||||
"processingPaused": "Traitement de la File d'Attente Interrompu",
|
||||
"pausedDueToOffline": "Le traitement des téléversements est interrompu car le mode hors ligne est activé.",
|
||||
"pausedByUser": "Le traitement des téléversements est interrompu manuellement."
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Fournisseurs de Tuiles",
|
||||
@@ -245,7 +301,7 @@
|
||||
"urlTemplate": "Modèle d'URL",
|
||||
"urlTemplateHint": "https://exemple.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "Le modèle d'URL est requis",
|
||||
"urlTemplatePlaceholders": "L'URL doit contenir les marqueurs {z}, {x} et {y}",
|
||||
"urlTemplatePlaceholders": "L'URL doit contenir soit {quadkey} soit les marqueurs {z}, {x} et {y}",
|
||||
"attribution": "Attribution",
|
||||
"attributionHint": "© Fournisseur de Cartes",
|
||||
"attributionRequired": "L'attribution est requise",
|
||||
@@ -267,7 +323,14 @@
|
||||
"view": "Voir",
|
||||
"deleteProfile": "Supprimer Profil",
|
||||
"deleteProfileConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
|
||||
"profileDeleted": "Profil supprimé"
|
||||
"profileDeleted": "Profil supprimé",
|
||||
"getMore": "En obtenir plus...",
|
||||
"addProfileChoice": "Ajouter Profil",
|
||||
"addProfileChoiceMessage": "Comment souhaitez-vous ajouter un profil?",
|
||||
"createCustomProfile": "Créer Profil Personnalisé",
|
||||
"createCustomProfileDescription": "Créer un profil à partir de zéro avec vos propres balises",
|
||||
"importFromWebsite": "Importer depuis Site Web",
|
||||
"importFromWebsiteDescription": "Parcourir et importer des profils depuis deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tuiles de Carte",
|
||||
@@ -283,6 +346,10 @@
|
||||
"profileNameRequired": "Le nom du profil est requis",
|
||||
"requiresDirection": "Nécessite Direction",
|
||||
"requiresDirectionSubtitle": "Si les caméras de ce type ont besoin d'une balise de direction",
|
||||
"fov": "Champ de Vision",
|
||||
"fovHint": "Champ de vision en degrés (laisser vide pour la valeur par défaut)",
|
||||
"fovSubtitle": "Champ de vision de la caméra - utilisé pour la largeur du cône et le format de soumission par plage",
|
||||
"fovInvalid": "Le champ de vision doit être entre 1 et 360 degrés",
|
||||
"submittable": "Soumissible",
|
||||
"submittableSubtitle": "Si ce profil peut être utilisé pour les soumissions de caméras",
|
||||
"osmTags": "Balises OSM",
|
||||
@@ -353,7 +420,16 @@
|
||||
"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."
|
||||
"noOperatorProfilesMessage": "Créez des profils d'opérateur dans les Paramètres pour appliquer des étiquettes supplémentaires à vos soumissions de nœuds.",
|
||||
"profileTags": "Étiquettes de Profil",
|
||||
"profileTagsDescription": "Spécifiez des valeurs pour les étiquettes qui nécessitent un raffinement :",
|
||||
"selectValue": "Sélectionner une valeur...",
|
||||
"noValue": "(Aucune valeur)",
|
||||
"noSuggestions": "Aucune suggestion disponible",
|
||||
"existingTagsTitle": "Balises Existantes",
|
||||
"existingTagsDescription": "Modifiez les balises existantes sur cet appareil. Ajoutez, supprimez ou modifiez n'importe quelle balise :",
|
||||
"existingOperator": "<Opérateur existant>",
|
||||
"existingOperatorTags": "balises d'opérateur existantes"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Impossible de changer les types de tuiles pendant le téléchargement des zones hors ligne",
|
||||
@@ -383,15 +459,18 @@
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Afficher l'indicateur de statut réseau",
|
||||
"showIndicatorSubtitle": "Afficher l'état de chargement et d'erreur réseau sur la carte",
|
||||
"loading": "Chargement...",
|
||||
"timedOut": "Temps dépassé",
|
||||
"noData": "Aucune tuile ici",
|
||||
"success": "Terminé",
|
||||
"nodeLimitReached": "Limite affichée - augmenter dans les paramètres",
|
||||
"tileProviderSlow": "Fournisseur de tuiles lent",
|
||||
"nodeDataSlow": "Données de nœud lentes",
|
||||
"networkIssues": "Problèmes réseau"
|
||||
"showIndicatorSubtitle": "Afficher l'état de chargement et d'erreur des données de surveillance",
|
||||
"loading": "Chargement des données de surveillance...",
|
||||
"timedOut": "Demande expirée",
|
||||
"noData": "Aucune donnée hors ligne",
|
||||
"success": "Données de surveillance chargées",
|
||||
"nodeDataSlow": "Données de surveillance lentes",
|
||||
"rateLimited": "Limité par le serveur",
|
||||
"networkError": "Erreur réseau"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "Affichage de {rendered} sur {total} appareils",
|
||||
"editingDisabledMessage": "Trop d'appareils visibles pour éditer en toute sécurité. Zoomez davantage pour réduire le nombre d'appareils visibles, puis réessayez."
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Rechercher lieu",
|
||||
@@ -416,18 +495,13 @@
|
||||
"endSelect": "Fin (sélectionner)",
|
||||
"distance": "Distance: {} km",
|
||||
"routeActive": "Itinéraire actif",
|
||||
"locationsTooClose": "Les emplacements de départ et d'arrivée sont trop proches",
|
||||
"navigationSettings": "Navigation",
|
||||
"navigationSettingsSubtitle": "Paramètres de planification d'itinéraire et d'évitement",
|
||||
"avoidanceDistance": "Distance d'évitement",
|
||||
"avoidanceDistanceSubtitle": "Distance minimale pour éviter les dispositifs de surveillance",
|
||||
"searchHistory": "Historique de recherche max",
|
||||
"searchHistorySubtitle": "Nombre maximum de recherches récentes à retenir",
|
||||
"units": "Unités",
|
||||
"unitsSubtitle": "Unités d'affichage pour distances et mesures",
|
||||
"metric": "Métrique (km, m)",
|
||||
"imperial": "Impérial (mi, ft)",
|
||||
"meters": "mètres",
|
||||
"feet": "pieds"
|
||||
"searchHistorySubtitle": "Nombre maximum de recherches récentes à retenir"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Emplacements Suspects",
|
||||
@@ -463,5 +537,19 @@
|
||||
"url": "URL",
|
||||
"coordinates": "Coordonnées",
|
||||
"noAddressAvailable": "Aucune adresse disponible"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mi",
|
||||
"metersLong": "mètres",
|
||||
"feetLong": "pieds",
|
||||
"kilometersLong": "kilomètres",
|
||||
"milesLong": "milles",
|
||||
"metric": "Métrique",
|
||||
"imperial": "Impérial",
|
||||
"metricDescription": "Métrique (km, m)",
|
||||
"imperialDescription": "Impérial (mi, ft)"
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,10 @@
|
||||
"description": "DeFlock è un'app mobile orientata alla privacy per mappare l'infrastruttura di sorveglianza pubblica utilizzando OpenStreetMap. Documenta telecamere, ALPR, rilevatori di spari e altri dispositivi di sorveglianza nella tua comunità per rendere questa infrastruttura visibile e ricercabile.",
|
||||
"features": "• Mappatura con capacità offline con aree scaricabili\n• Upload diretto su OpenStreetMap con OAuth2\n• Profili integrati per i principali produttori\n• Rispettoso della privacy - nessun dato utente raccolto\n• Multipli fornitori di mappe (OSM, immagini satellitari)",
|
||||
"initiative": "Parte della più ampia iniziativa DeFlock per promuovere la trasparenza della sorveglianza.",
|
||||
"footer": "Visita: deflock.me\nCostruito con Flutter • Open Source"
|
||||
"footer": "Visita: deflock.me\nCostruito con Flutter • Open Source",
|
||||
"showWelcome": "Mostra Messaggio di Benvenuto",
|
||||
"showSubmissionGuide": "Mostra Guida di Invio",
|
||||
"viewReleaseNotes": "Visualizza Note di Rilascio"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Benvenuto in DeFlock",
|
||||
@@ -23,6 +26,22 @@
|
||||
"dontShowAgain": "Non mostrare più questo messaggio di benvenuto",
|
||||
"getStarted": "Iniziamo con DeFlock!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Migliori Pratiche di Invio",
|
||||
"description": "Prima di inviare il tuo primo dispositivo di sorveglianza, prenditi un momento per rivedere queste linee guida importanti per contributi di alta qualità a OpenStreetMap.",
|
||||
"bestPractices": "• Mappa solo dispositivi che hai osservato personalmente\n• Prenditi tempo per identificare accuratamente tipo e produttore\n• Usa posizionamento preciso - ingrandisci prima di piazzare il marcatore\n• Includi informazioni sulla direzione quando applicabile\n• Controlla le tue selezioni di tag prima di inviare",
|
||||
"placementNote": "Ricorda: Dati accurati e di prima mano sono essenziali per la comunità DeFlock e il progetto OpenStreetMap.",
|
||||
"moreInfo": "Per una guida dettagliata sull'identificazione dei dispositivi e le migliori pratiche di mappatura:",
|
||||
"identificationGuide": "Guida di Identificazione",
|
||||
"osmWiki": "Wiki OpenStreetMap",
|
||||
"dontShowAgain": "Non mostrare più questa guida",
|
||||
"gotIt": "Capito!"
|
||||
},
|
||||
"positioningTutorial": {
|
||||
"title": "Affinare la Posizione",
|
||||
"instructions": "Trascina la mappa per posizionare il marcatore del dispositivo precisamente sopra la posizione del dispositivo di sorveglianza.",
|
||||
"hint": "Puoi ingrandire per una maggiore precisione prima di posizionare."
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Nuovo Nodo",
|
||||
"download": "Scarica",
|
||||
@@ -33,6 +52,7 @@
|
||||
"ok": "OK",
|
||||
"close": "Chiudi",
|
||||
"submit": "Invia",
|
||||
"logIn": "Accedi",
|
||||
"saveEdit": "Salva Modifica",
|
||||
"clear": "Pulisci",
|
||||
"viewOnOSM": "Visualizza su OSM",
|
||||
@@ -69,9 +89,13 @@
|
||||
"aboutInfo": "Informazioni",
|
||||
"aboutThisApp": "Informazioni su questa App",
|
||||
"aboutSubtitle": "Informazioni sull'applicazione e crediti",
|
||||
"languageSubtitle": "Scegli la tua lingua preferita",
|
||||
"languageSubtitle": "Scegli la tua lingua preferita e unità",
|
||||
"distanceUnit": "Unità di Distanza",
|
||||
"distanceUnitSubtitle": "Scegli tra unità metriche (km/m) o imperiali (mi/ft)",
|
||||
"metricUnits": "Metrico (km, m)",
|
||||
"imperialUnits": "Imperiale (mi, ft)",
|
||||
"maxNodes": "Max nodi disegnati",
|
||||
"maxNodesSubtitle": "Imposta un limite superiore per il numero di nodi sulla mappa (predefinito: 250).",
|
||||
"maxNodesSubtitle": "Imposta un limite superiore per il numero di nodi sulla mappa.",
|
||||
"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.",
|
||||
@@ -99,8 +123,7 @@
|
||||
"enableNotifications": "Abilita Notifiche",
|
||||
"checkingPermissions": "Controllo autorizzazioni...",
|
||||
"alertDistance": "Distanza di avviso: ",
|
||||
"meters": "metri",
|
||||
"rangeInfo": "Intervallo: {}-{} metri (predefinito: {})"
|
||||
"rangeInfo": "Intervallo: {}-{} {} (predefinito: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nodo #{}",
|
||||
@@ -120,8 +143,8 @@
|
||||
"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 ({})"
|
||||
"loadingAreaData": "Caricamento dati area... Per favore attendi prima di inviare.",
|
||||
"refineTags": "Affina Tag"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Modifica Nodo #{}",
|
||||
@@ -135,11 +158,16 @@
|
||||
"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.",
|
||||
"loadingAreaData": "Caricamento dati area... Per favore attendi prima di inviare.",
|
||||
"cannotMoveConstrainedNode": "Impossibile spostare questa telecamera - è collegata a un altro elemento della mappa (OSM way/relation). Puoi ancora modificare i suoi tag e direzione.",
|
||||
"zoomInRequiredMessage": "Ingrandisci almeno al livello {} per aggiungere o modificare nodi di sorveglianza. Questo garantisce un posizionamento preciso per una mappatura accurata.",
|
||||
"extractFromWay": "Estrai nodo da way/relation",
|
||||
"extractFromWaySubtitle": "Crea nuovo nodo con gli stessi tag, consenti spostamento in nuova posizione",
|
||||
"refineTags": "Affina Tag",
|
||||
"refineTagsWithProfile": "Affina Tag ({})"
|
||||
"existingTags": "<Tag esistenti>",
|
||||
"noChangesDetected": "Nessuna modifica rilevata - niente da inviare",
|
||||
"noChangesTitle": "Nessuna Modifica da Inviare",
|
||||
"noChangesMessage": "Non hai apportato modifiche a questo nodo. Per inviare una modifica, devi cambiare la posizione, il profilo, le direzioni o i tag."
|
||||
},
|
||||
"download": {
|
||||
"title": "Scarica Area Mappa",
|
||||
@@ -151,9 +179,16 @@
|
||||
"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.",
|
||||
"areaTooBigMessage": "Ingrandisci almeno al livello {} per scaricare aree offline. I download di aree grandi possono rendere l'app non reattiva.",
|
||||
"downloadStarted": "Download avviato! Recupero tile e nodi...",
|
||||
"downloadFailed": "Impossibile avviare il download: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Avviato",
|
||||
"message": "Download avviato! Recupero tile e nodi...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Visualizza Progresso in Impostazioni"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destinazione Upload",
|
||||
"subtitle": "Scegli dove vengono caricate le telecamere",
|
||||
@@ -162,7 +197,8 @@
|
||||
"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)"
|
||||
"simulateDescription": "Simula upload (non contatta i server OSM)",
|
||||
"cannotChangeWithQueue": "Impossibile cambiare la destinazione di upload mentre ci sono {} elementi in coda. Svuota prima la coda."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "Account OpenStreetMap",
|
||||
@@ -185,7 +221,20 @@
|
||||
"deleteAccountSubtitle": "Gestisci il tuo account OpenStreetMap",
|
||||
"deleteAccountExplanation": "Per eliminare il tuo account OpenStreetMap, dovrai visitare il sito web di OpenStreetMap. Questo rimuoverà permanentemente il tuo account OSM e tutti i dati associati.",
|
||||
"deleteAccountWarning": "Attenzione: Questa azione non può essere annullata e eliminerà permanentemente il tuo account OSM.",
|
||||
"goToOSM": "Vai a OpenStreetMap"
|
||||
"goToOSM": "Vai a OpenStreetMap",
|
||||
"accountManagement": "Gestione Account",
|
||||
"accountManagementDescription": "Per eliminare il tuo account OpenStreetMap, devi visitare il sito web OpenStreetMap appropriato. Questo rimuoverà permanentemente il tuo account e tutti i dati associati.",
|
||||
"currentDestinationProduction": "Attualmente connesso a: OpenStreetMap di Produzione",
|
||||
"currentDestinationSandbox": "Attualmente connesso a: OpenStreetMap Sandbox",
|
||||
"currentDestinationSimulate": "Attualmente in: Modalità simulazione (nessun account reale)",
|
||||
"viewMessages": "Visualizza Messaggi su OSM",
|
||||
"unreadMessagesCount": "Hai {} messaggi non letti",
|
||||
"noUnreadMessages": "Nessun messaggio non letto",
|
||||
"reauthRequired": "Aggiorna Autenticazione",
|
||||
"reauthExplanation": "Devi aggiornare la tua autenticazione per ricevere notifiche di messaggi OSM tramite l'app.",
|
||||
"reauthBenefit": "Questo abiliterà i punti di notifica quando hai messaggi non letti su OpenStreetMap.",
|
||||
"reauthNow": "Fallo Ora",
|
||||
"reauthLater": "Più Tardi"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Coda di Upload",
|
||||
@@ -203,7 +252,7 @@
|
||||
"queueCleared": "Coda pulita",
|
||||
"uploadQueueTitle": "Coda Upload ({} elementi)",
|
||||
"queueIsEmpty": "La coda è vuota",
|
||||
"cameraWithIndex": "Telecamera {}",
|
||||
"itemWithIndex": "Elemento {}",
|
||||
"error": " (Errore)",
|
||||
"completing": " (Completamento...)",
|
||||
"destination": "Dest: {}",
|
||||
@@ -213,7 +262,14 @@
|
||||
"attempts": "Tentativi: {}",
|
||||
"uploadFailedRetry": "Upload fallito. Tocca riprova per tentare di nuovo.",
|
||||
"retryUpload": "Riprova upload",
|
||||
"clearAll": "Pulisci Tutto"
|
||||
"clearAll": "Pulisci Tutto",
|
||||
"errorDetails": "Dettagli dell'Errore",
|
||||
"creatingChangeset": " (Creazione changeset...)",
|
||||
"uploading": " (Caricamento...)",
|
||||
"closingChangeset": " (Chiusura changeset...)",
|
||||
"processingPaused": "Elaborazione Coda Sospesa",
|
||||
"pausedDueToOffline": "L'elaborazione dei caricamenti è sospesa perché la modalità offline è abilitata.",
|
||||
"pausedByUser": "L'elaborazione dei caricamenti è sospesa manualmente."
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Fornitori di Tile",
|
||||
@@ -245,7 +301,7 @@
|
||||
"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}",
|
||||
"urlTemplatePlaceholders": "L'URL deve contenere o {quadkey} o i segnaposto {z}, {x} e {y}",
|
||||
"attribution": "Attribuzione",
|
||||
"attributionHint": "© Fornitore Mappe",
|
||||
"attributionRequired": "L'attribuzione è obbligatoria",
|
||||
@@ -267,7 +323,14 @@
|
||||
"view": "Visualizza",
|
||||
"deleteProfile": "Elimina Profilo",
|
||||
"deleteProfileConfirm": "Sei sicuro di voler eliminare \"{}\"?",
|
||||
"profileDeleted": "Profilo eliminato"
|
||||
"profileDeleted": "Profilo eliminato",
|
||||
"getMore": "Ottieni altri...",
|
||||
"addProfileChoice": "Aggiungi Profilo",
|
||||
"addProfileChoiceMessage": "Come desideri aggiungere un profilo?",
|
||||
"createCustomProfile": "Crea Profilo Personalizzato",
|
||||
"createCustomProfileDescription": "Crea un profilo da zero con i tuoi tag",
|
||||
"importFromWebsite": "Importa da Sito Web",
|
||||
"importFromWebsiteDescription": "Sfoglia e importa profili da deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tile Mappa",
|
||||
@@ -283,6 +346,10 @@
|
||||
"profileNameRequired": "Il nome del profilo è obbligatorio",
|
||||
"requiresDirection": "Richiede Direzione",
|
||||
"requiresDirectionSubtitle": "Se le telecamere di questo tipo necessitano di un tag direzione",
|
||||
"fov": "Campo Visivo",
|
||||
"fovHint": "Campo visivo in gradi (lasciare vuoto per il valore predefinito)",
|
||||
"fovSubtitle": "Campo visivo della telecamera - utilizzato per la larghezza del cono e il formato di invio per intervallo",
|
||||
"fovInvalid": "Il campo visivo deve essere tra 1 e 360 gradi",
|
||||
"submittable": "Inviabile",
|
||||
"submittableSubtitle": "Se questo profilo può essere usato per invii di telecamere",
|
||||
"osmTags": "Tag OSM",
|
||||
@@ -353,7 +420,16 @@
|
||||
"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."
|
||||
"noOperatorProfilesMessage": "Crea profili operatore nelle Impostazioni per applicare tag aggiuntivi ai tuoi invii di nodi.",
|
||||
"profileTags": "Tag del Profilo",
|
||||
"profileTagsDescription": "Specificare valori per i tag che necessitano di raffinamento:",
|
||||
"selectValue": "Seleziona un valore...",
|
||||
"noValue": "(Nessun valore)",
|
||||
"noSuggestions": "Nessun suggerimento disponibile",
|
||||
"existingTagsTitle": "Tag Esistenti",
|
||||
"existingTagsDescription": "Modifica i tag esistenti su questo dispositivo. Aggiungi, rimuovi o modifica qualsiasi tag:",
|
||||
"existingOperator": "<Operatore esistente>",
|
||||
"existingOperatorTags": "tag operatore esistenti"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Impossibile cambiare tipi di tile durante il download di aree offline",
|
||||
@@ -383,15 +459,18 @@
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Mostra indicatore di stato di rete",
|
||||
"showIndicatorSubtitle": "Visualizza lo stato di caricamento e errori di rete sulla mappa",
|
||||
"loading": "Caricamento...",
|
||||
"timedOut": "Tempo scaduto",
|
||||
"noData": "Nessuna tessera qui",
|
||||
"success": "Fatto",
|
||||
"nodeLimitReached": "Limite visualizzato - aumentare nelle impostazioni",
|
||||
"tileProviderSlow": "Provider di tessere lento",
|
||||
"nodeDataSlow": "Dati del nodo lenti",
|
||||
"networkIssues": "Problemi di rete"
|
||||
"showIndicatorSubtitle": "Visualizza lo stato di caricamento e errori dei dati di sorveglianza",
|
||||
"loading": "Caricamento dati di sorveglianza...",
|
||||
"timedOut": "Richiesta scaduta",
|
||||
"noData": "Nessun dato offline",
|
||||
"success": "Dati di sorveglianza caricati",
|
||||
"nodeDataSlow": "Dati di sorveglianza lenti",
|
||||
"rateLimited": "Limitato dal server",
|
||||
"networkError": "Errore di rete"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "Mostra {rendered} di {total} dispositivi",
|
||||
"editingDisabledMessage": "Troppi dispositivi visibili per modificare in sicurezza. Ingrandisci per ridurre il numero di dispositivi visibili, poi riprova."
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Cerca posizione",
|
||||
@@ -416,18 +495,13 @@
|
||||
"endSelect": "Fine (seleziona)",
|
||||
"distance": "Distanza: {} km",
|
||||
"routeActive": "Percorso attivo",
|
||||
"locationsTooClose": "Le posizioni di partenza e arrivo sono troppo vicine",
|
||||
"navigationSettings": "Navigazione",
|
||||
"navigationSettingsSubtitle": "Impostazioni pianificazione percorso ed evitamento",
|
||||
"avoidanceDistance": "Distanza di evitamento",
|
||||
"avoidanceDistanceSubtitle": "Distanza minima da mantenere dai dispositivi di sorveglianza",
|
||||
"searchHistory": "Cronologia ricerca max",
|
||||
"searchHistorySubtitle": "Numero massimo di ricerche recenti da ricordare",
|
||||
"units": "Unità",
|
||||
"unitsSubtitle": "Unità di visualizzazione per distanze e misure",
|
||||
"metric": "Metrico (km, m)",
|
||||
"imperial": "Imperiale (mi, ft)",
|
||||
"meters": "metri",
|
||||
"feet": "piedi"
|
||||
"searchHistorySubtitle": "Numero massimo di ricerche recenti da ricordare"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Posizioni Sospette",
|
||||
@@ -463,5 +537,19 @@
|
||||
"url": "URL",
|
||||
"coordinates": "Coordinate",
|
||||
"noAddressAvailable": "Nessun indirizzo disponibile"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mi",
|
||||
"metersLong": "metri",
|
||||
"feetLong": "piedi",
|
||||
"kilometersLong": "chilometri",
|
||||
"milesLong": "miglia",
|
||||
"metric": "Metrico",
|
||||
"imperial": "Imperiale",
|
||||
"metricDescription": "Metrico (km, m)",
|
||||
"imperialDescription": "Imperiale (mi, ft)"
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,10 @@
|
||||
"description": "DeFlock é um aplicativo móvel focado na privacidade para mapear infraestrutura de vigilância pública usando OpenStreetMap. Documente câmeras, ALPRs, detectores de tiros e outros dispositivos de vigilância em sua comunidade para tornar essa infraestrutura visível e pesquisável.",
|
||||
"features": "• Mapeamento com capacidade offline com áreas para download\n• Upload direto para OpenStreetMap com OAuth2\n• Perfis integrados para principais fabricantes\n• Respeitoso à privacidade - nenhum dado do usuário coletado\n• Múltiplos provedores de mapas (OSM, imagens de satélite)",
|
||||
"initiative": "Parte da iniciativa mais ampla DeFlock para promover transparência na vigilância.",
|
||||
"footer": "Visite: deflock.me\nConstruído com Flutter • Código Aberto"
|
||||
"footer": "Visite: deflock.me\nConstruído com Flutter • Código Aberto",
|
||||
"showWelcome": "Mostrar Mensagem de Boas-vindas",
|
||||
"showSubmissionGuide": "Mostrar Guia de Submissão",
|
||||
"viewReleaseNotes": "Ver Notas de Lançamento"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Bem-vindo ao DeFlock",
|
||||
@@ -23,6 +26,22 @@
|
||||
"dontShowAgain": "Não mostrar esta mensagem de boas-vindas novamente",
|
||||
"getStarted": "Vamos começar com o DeFlock!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Melhores Práticas de Submissão",
|
||||
"description": "Antes de submeter seu primeiro dispositivo de vigilância, dedique um momento para revisar estas diretrizes importantes para contribuições de alta qualidade ao OpenStreetMap.",
|
||||
"bestPractices": "• Mapear apenas dispositivos que você observou pessoalmente\n• Dedicar tempo para identificar com precisão tipo e fabricante\n• Usar posicionamento preciso - aproximar antes de colocar o marcador\n• Incluir informações de direção quando aplicável\n• Verificar suas seleções de tags antes de submeter",
|
||||
"placementNote": "Lembre-se: Dados precisos e de primeira mão são essenciais para a comunidade DeFlock e o projeto OpenStreetMap.",
|
||||
"moreInfo": "Para orientação detalhada sobre identificação de dispositivos e melhores práticas de mapeamento:",
|
||||
"identificationGuide": "Guia de Identificação",
|
||||
"osmWiki": "Wiki OpenStreetMap",
|
||||
"dontShowAgain": "Não mostrar este guia novamente",
|
||||
"gotIt": "Entendi!"
|
||||
},
|
||||
"positioningTutorial": {
|
||||
"title": "Refinar Posição",
|
||||
"instructions": "Arraste o mapa para posicionar o marcador do dispositivo precisamente sobre a localização do dispositivo de vigilância.",
|
||||
"hint": "Você pode aumentar o zoom para melhor precisão antes de posicionar."
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Novo Nó",
|
||||
"download": "Baixar",
|
||||
@@ -33,6 +52,7 @@
|
||||
"ok": "OK",
|
||||
"close": "Fechar",
|
||||
"submit": "Enviar",
|
||||
"logIn": "Entrar",
|
||||
"saveEdit": "Salvar Edição",
|
||||
"clear": "Limpar",
|
||||
"viewOnOSM": "Ver no OSM",
|
||||
@@ -69,9 +89,13 @@
|
||||
"aboutInfo": "Sobre / Informações",
|
||||
"aboutThisApp": "Sobre este App",
|
||||
"aboutSubtitle": "Informações do aplicativo e créditos",
|
||||
"languageSubtitle": "Escolha seu idioma preferido",
|
||||
"languageSubtitle": "Escolha seu idioma preferido e unidades",
|
||||
"distanceUnit": "Unidades de Distância",
|
||||
"distanceUnitSubtitle": "Escolha entre unidades métricas (km/m) ou imperiais (mi/ft)",
|
||||
"metricUnits": "Métrico (km, m)",
|
||||
"imperialUnits": "Imperial (mi, ft)",
|
||||
"maxNodes": "Máx. de nós desenhados",
|
||||
"maxNodesSubtitle": "Definir um limite superior para o número de nós no mapa (padrão: 250).",
|
||||
"maxNodesSubtitle": "Definir um limite superior para o número de nós no mapa.",
|
||||
"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.",
|
||||
@@ -99,8 +123,7 @@
|
||||
"enableNotifications": "Habilitar Notificações",
|
||||
"checkingPermissions": "Verificando permissões...",
|
||||
"alertDistance": "Distância de alerta: ",
|
||||
"meters": "metros",
|
||||
"rangeInfo": "Faixa: {}-{} metros (padrão: {})"
|
||||
"rangeInfo": "Faixa: {}-{} {} (padrão: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nó #{}",
|
||||
@@ -120,8 +143,8 @@
|
||||
"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 ({})"
|
||||
"loadingAreaData": "Carregando dados da área... Por favor aguarde antes de enviar.",
|
||||
"refineTags": "Refinar Tags"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Editar Nó #{}",
|
||||
@@ -135,11 +158,16 @@
|
||||
"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.",
|
||||
"loadingAreaData": "Carregando dados da área... Por favor aguarde antes de enviar.",
|
||||
"cannotMoveConstrainedNode": "Não é possível mover esta câmera - ela está conectada a outro elemento do mapa (OSM way/relation). Você ainda pode editar suas tags e direção.",
|
||||
"zoomInRequiredMessage": "Amplie para pelo menos o nível {} para adicionar ou editar nós de vigilância. Isto garante um posicionamento preciso para mapeamento exato.",
|
||||
"extractFromWay": "Extrair nó do way/relation",
|
||||
"extractFromWaySubtitle": "Criar novo nó com as mesmas tags, permitir mover para nova localização",
|
||||
"refineTags": "Refinar Tags",
|
||||
"refineTagsWithProfile": "Refinar Tags ({})"
|
||||
"existingTags": "<Tags existentes>",
|
||||
"noChangesDetected": "Nenhuma alteração detectada - nada para enviar",
|
||||
"noChangesTitle": "Nenhuma Alteração para Enviar",
|
||||
"noChangesMessage": "Você não fez nenhuma alteração neste nó. Para enviar uma edição, você precisa alterar a localização, o perfil, as direções ou as tags."
|
||||
},
|
||||
"download": {
|
||||
"title": "Baixar Área do Mapa",
|
||||
@@ -151,9 +179,16 @@
|
||||
"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.",
|
||||
"areaTooBigMessage": "Amplie para pelo menos o nível {} para baixar áreas offline. Downloads de áreas grandes podem tornar o aplicativo não responsivo.",
|
||||
"downloadStarted": "Download iniciado! Buscando tiles e nós...",
|
||||
"downloadFailed": "Falha ao iniciar o download: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Iniciado",
|
||||
"message": "Download iniciado! Buscando tiles e nós...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Ver Progresso nas Configurações"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destino do Upload",
|
||||
"subtitle": "Escolha onde as câmeras são enviadas",
|
||||
@@ -162,7 +197,8 @@
|
||||
"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)"
|
||||
"simulateDescription": "Simular uploads (não contacta servidores OSM)",
|
||||
"cannotChangeWithQueue": "Não é possível alterar o destino de upload enquanto {} itens estão na fila. Limpe a fila primeiro."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "Conta OpenStreetMap",
|
||||
@@ -185,7 +221,20 @@
|
||||
"deleteAccountSubtitle": "Gerencie sua conta OpenStreetMap",
|
||||
"deleteAccountExplanation": "Para excluir sua conta OpenStreetMap, você precisará visitar o site do OpenStreetMap. Isso removerá permanentemente sua conta OSM e todos os dados associados.",
|
||||
"deleteAccountWarning": "Aviso: Esta ação não pode ser desfeita e excluirá permanentemente sua conta OSM.",
|
||||
"goToOSM": "Ir para OpenStreetMap"
|
||||
"goToOSM": "Ir para OpenStreetMap",
|
||||
"accountManagement": "Gerenciamento de Conta",
|
||||
"accountManagementDescription": "Para excluir sua conta do OpenStreetMap, você deve visitar o site do OpenStreetMap apropriado. Isso removerá permanentemente sua conta e todos os dados associados.",
|
||||
"currentDestinationProduction": "Atualmente conectado a: OpenStreetMap de Produção",
|
||||
"currentDestinationSandbox": "Atualmente conectado a: OpenStreetMap Sandbox",
|
||||
"currentDestinationSimulate": "Atualmente em: Modo de simulação (sem conta real)",
|
||||
"viewMessages": "Ver Mensagens no OSM",
|
||||
"unreadMessagesCount": "Você tem {} mensagens não lidas",
|
||||
"noUnreadMessages": "Nenhuma mensagem não lida",
|
||||
"reauthRequired": "Atualizar Autenticação",
|
||||
"reauthExplanation": "Você deve atualizar sua autenticação para receber notificações de mensagens OSM através do aplicativo.",
|
||||
"reauthBenefit": "Isso habilitará pontos de notificação quando você tiver mensagens não lidas no OpenStreetMap.",
|
||||
"reauthNow": "Fazer Agora",
|
||||
"reauthLater": "Mais Tarde"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Fila de Upload",
|
||||
@@ -203,7 +252,7 @@
|
||||
"queueCleared": "Fila limpa",
|
||||
"uploadQueueTitle": "Fila de Upload ({} itens)",
|
||||
"queueIsEmpty": "A fila está vazia",
|
||||
"cameraWithIndex": "Câmera {}",
|
||||
"itemWithIndex": "Item {}",
|
||||
"error": " (Erro)",
|
||||
"completing": " (Completando...)",
|
||||
"destination": "Dest: {}",
|
||||
@@ -213,7 +262,14 @@
|
||||
"attempts": "Tentativas: {}",
|
||||
"uploadFailedRetry": "Upload falhou. Toque em tentar novamente para tentar novamente.",
|
||||
"retryUpload": "Tentar upload novamente",
|
||||
"clearAll": "Limpar Tudo"
|
||||
"clearAll": "Limpar Tudo",
|
||||
"errorDetails": "Detalhes do Erro",
|
||||
"creatingChangeset": " (Criando changeset...)",
|
||||
"uploading": " (Enviando...)",
|
||||
"closingChangeset": " (Fechando changeset...)",
|
||||
"processingPaused": "Processamento da Fila Pausado",
|
||||
"pausedDueToOffline": "O processamento de upload está pausado porque o modo offline está habilitado.",
|
||||
"pausedByUser": "O processamento de upload está pausado manualmente."
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Provedores de Tiles",
|
||||
@@ -245,7 +301,7 @@
|
||||
"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}",
|
||||
"urlTemplatePlaceholders": "URL deve conter {quadkey} ou os marcadores {z}, {x} e {y}",
|
||||
"attribution": "Atribuição",
|
||||
"attributionHint": "© Provedor de Mapas",
|
||||
"attributionRequired": "Atribuição é obrigatória",
|
||||
@@ -267,7 +323,14 @@
|
||||
"view": "Ver",
|
||||
"deleteProfile": "Excluir Perfil",
|
||||
"deleteProfileConfirm": "Tem certeza de que deseja excluir \"{}\"?",
|
||||
"profileDeleted": "Perfil excluído"
|
||||
"profileDeleted": "Perfil excluído",
|
||||
"getMore": "Obter mais...",
|
||||
"addProfileChoice": "Adicionar Perfil",
|
||||
"addProfileChoiceMessage": "Como gostaria de adicionar um perfil?",
|
||||
"createCustomProfile": "Criar Perfil Personalizado",
|
||||
"createCustomProfileDescription": "Construir um perfil do zero com suas próprias tags",
|
||||
"importFromWebsite": "Importar do Site",
|
||||
"importFromWebsiteDescription": "Navegar e importar perfis do deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tiles do Mapa",
|
||||
@@ -283,6 +346,10 @@
|
||||
"profileNameRequired": "Nome do perfil é obrigatório",
|
||||
"requiresDirection": "Requer Direção",
|
||||
"requiresDirectionSubtitle": "Se câmeras deste tipo precisam de uma tag de direção",
|
||||
"fov": "Campo de Visão",
|
||||
"fovHint": "Campo de visão em graus (deixar vazio para o padrão)",
|
||||
"fovSubtitle": "Campo de visão da câmera - usado para largura do cone e formato de envio por intervalo",
|
||||
"fovInvalid": "Campo de visão deve estar entre 1 e 360 graus",
|
||||
"submittable": "Enviável",
|
||||
"submittableSubtitle": "Se este perfil pode ser usado para envios de câmeras",
|
||||
"osmTags": "Tags OSM",
|
||||
@@ -353,7 +420,16 @@
|
||||
"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."
|
||||
"noOperatorProfilesMessage": "Crie perfis de operador nas Configurações para aplicar tags adicionais aos seus envios de nós.",
|
||||
"profileTags": "Tags do Perfil",
|
||||
"profileTagsDescription": "Especifique valores para tags que precisam de refinamento:",
|
||||
"selectValue": "Selecionar um valor...",
|
||||
"noValue": "(Sem valor)",
|
||||
"noSuggestions": "Nenhuma sugestão disponível",
|
||||
"existingTagsTitle": "Tags Existentes",
|
||||
"existingTagsDescription": "Edite as tags existentes neste dispositivo. Adicione, remova ou modifique qualquer tag:",
|
||||
"existingOperator": "<Operador existente>",
|
||||
"existingOperatorTags": "tags de operador existentes"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Não é possível alterar tipos de tiles durante o download de áreas offline",
|
||||
@@ -383,15 +459,18 @@
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Exibir indicador de status de rede",
|
||||
"showIndicatorSubtitle": "Mostrar status de carregamento e erro de rede no mapa",
|
||||
"loading": "Carregando...",
|
||||
"timedOut": "Tempo esgotado",
|
||||
"noData": "Nenhum tile aqui",
|
||||
"success": "Concluído",
|
||||
"nodeLimitReached": "Limite exibido - aumentar nas configurações",
|
||||
"tileProviderSlow": "Provedor de tiles lento",
|
||||
"nodeDataSlow": "Dados do nó lentos",
|
||||
"networkIssues": "Problemas de rede"
|
||||
"showIndicatorSubtitle": "Mostrar status de carregamento e erro de dados de vigilância",
|
||||
"loading": "Carregando dados de vigilância...",
|
||||
"timedOut": "Solicitação expirada",
|
||||
"noData": "Nenhum dado offline",
|
||||
"success": "Dados de vigilância carregados",
|
||||
"nodeDataSlow": "Dados de vigilância lentos",
|
||||
"rateLimited": "Limitado pelo servidor",
|
||||
"networkError": "Erro de rede"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "Mostrando {rendered} de {total} dispositivos",
|
||||
"editingDisabledMessage": "Muitos dispositivos visíveis para editar com segurança. Aproxime mais para reduzir o número de dispositivos visíveis, e tente novamente."
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Buscar localização",
|
||||
@@ -416,18 +495,13 @@
|
||||
"endSelect": "Fim (selecionar)",
|
||||
"distance": "Distância: {} km",
|
||||
"routeActive": "Rota ativa",
|
||||
"locationsTooClose": "Os locais de início e fim estão muito próximos",
|
||||
"navigationSettings": "Navegação",
|
||||
"navigationSettingsSubtitle": "Configurações de planejamento de rota e evasão",
|
||||
"avoidanceDistance": "Distância de evasão",
|
||||
"avoidanceDistanceSubtitle": "Distância mínima para ficar longe de dispositivos de vigilância",
|
||||
"searchHistory": "Histórico máximo de busca",
|
||||
"searchHistorySubtitle": "Número máximo de buscas recentes para lembrar",
|
||||
"units": "Unidades",
|
||||
"unitsSubtitle": "Unidades de exibição para distâncias e medidas",
|
||||
"metric": "Métrico (km, m)",
|
||||
"imperial": "Imperial (mi, ft)",
|
||||
"meters": "metros",
|
||||
"feet": "pés"
|
||||
"searchHistorySubtitle": "Número máximo de buscas recentes para lembrar"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Localizações Suspeitas",
|
||||
@@ -463,5 +537,19 @@
|
||||
"url": "URL",
|
||||
"coordinates": "Coordenadas",
|
||||
"noAddressAvailable": "Nenhum endereço disponível"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mi",
|
||||
"metersLong": "metros",
|
||||
"feetLong": "pés",
|
||||
"kilometersLong": "quilômetros",
|
||||
"milesLong": "milhas",
|
||||
"metric": "Métrico",
|
||||
"imperial": "Imperial",
|
||||
"metricDescription": "Métrico (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,10 @@
|
||||
"description": "DeFlock 是一款注重隐私的移动应用,使用 OpenStreetMap 绘制公共监控基础设施。记录您社区中的摄像头、车牌识别系统、枪击探测器和其他监控设备,使这些基础设施可见且可搜索。",
|
||||
"features": "• 具有可下载区域的离线映射功能\n• 使用 OAuth2 直接上传到 OpenStreetMap\n• 主要制造商的内置配置文件\n• 尊重隐私 - 不收集用户数据\n• 多个地图提供商(OSM、卫星图像)",
|
||||
"initiative": "DeFlock 更广泛倡议的一部分,旨在促进监控透明化。",
|
||||
"footer": "访问:deflock.me\n使用 Flutter 构建 • 开源"
|
||||
"footer": "访问:deflock.me\n使用 Flutter 构建 • 开源",
|
||||
"showWelcome": "显示欢迎消息",
|
||||
"showSubmissionGuide": "显示提交指南",
|
||||
"viewReleaseNotes": "查看发布说明"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "欢迎使用 DeFlock",
|
||||
@@ -23,6 +26,22 @@
|
||||
"dontShowAgain": "不再显示此欢迎消息",
|
||||
"getStarted": "开始使用 DeFlock!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "提交最佳实践",
|
||||
"description": "在提交您的第一个监控设备之前,请花点时间查看这些重要指南,以确保对 OpenStreetMap 的高质量贡献。",
|
||||
"bestPractices": "• 只映射您亲自观察到的设备\n• 花时间准确识别设备类型和制造商\n• 使用精确定位 - 放置标记前请放大\n• 在适用时包含方向信息\n• 提交前请检查您的标签选择",
|
||||
"placementNote": "请记住:准确的第一手数据对 DeFlock 社区和 OpenStreetMap 项目至关重要。",
|
||||
"moreInfo": "有关设备识别和映射最佳实践的详细指导:",
|
||||
"identificationGuide": "识别指南",
|
||||
"osmWiki": "OpenStreetMap Wiki",
|
||||
"dontShowAgain": "不再显示此指南",
|
||||
"gotIt": "明白了!"
|
||||
},
|
||||
"positioningTutorial": {
|
||||
"title": "精确定位",
|
||||
"instructions": "拖动地图将设备标记精确定位在监控设备的位置上。",
|
||||
"hint": "您可以在定位前放大地图以获得更高的精度。"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "新建节点",
|
||||
"download": "下载",
|
||||
@@ -33,6 +52,7 @@
|
||||
"ok": "确定",
|
||||
"close": "关闭",
|
||||
"submit": "提交",
|
||||
"logIn": "登录",
|
||||
"saveEdit": "保存编辑",
|
||||
"clear": "清空",
|
||||
"viewOnOSM": "在OSM上查看",
|
||||
@@ -69,9 +89,13 @@
|
||||
"aboutInfo": "关于 / 信息",
|
||||
"aboutThisApp": "关于此应用",
|
||||
"aboutSubtitle": "应用程序信息和鸣谢",
|
||||
"languageSubtitle": "选择您的首选语言",
|
||||
"languageSubtitle": "选择您的首选语言和单位",
|
||||
"distanceUnit": "距离单位",
|
||||
"distanceUnitSubtitle": "选择公制 (公里/米) 或英制 (英里/英尺) 单位",
|
||||
"metricUnits": "公制 (公里, 米)",
|
||||
"imperialUnits": "英制 (英里, 英尺)",
|
||||
"maxNodes": "最大节点绘制数",
|
||||
"maxNodesSubtitle": "设置地图上节点数量的上限(默认:250)。",
|
||||
"maxNodesSubtitle": "设置地图上节点数量的上限。",
|
||||
"maxNodesWarning": "除非您确定有充分的理由,否则您可能不想这样做。",
|
||||
"offlineMode": "离线模式",
|
||||
"offlineModeSubtitle": "禁用除本地/离线区域外的所有网络请求。",
|
||||
@@ -99,8 +123,7 @@
|
||||
"enableNotifications": "启用通知",
|
||||
"checkingPermissions": "检查权限中...",
|
||||
"alertDistance": "警报距离:",
|
||||
"meters": "米",
|
||||
"rangeInfo": "范围:{}-{} 米(默认:{})"
|
||||
"rangeInfo": "范围:{}-{} {}(默认:{})"
|
||||
},
|
||||
"node": {
|
||||
"title": "节点 #{}",
|
||||
@@ -120,8 +143,8 @@
|
||||
"mustBeLoggedIn": "您必须登录才能提交新节点。请通过设置登录。",
|
||||
"enableSubmittableProfile": "在设置中启用可提交的配置文件以提交新节点。",
|
||||
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来提交新节点。",
|
||||
"refineTags": "细化标签",
|
||||
"refineTagsWithProfile": "细化标签({})"
|
||||
"loadingAreaData": "正在加载区域数据...提交前请稍候。",
|
||||
"refineTags": "细化标签"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "编辑节点 #{}",
|
||||
@@ -135,11 +158,16 @@
|
||||
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
|
||||
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
|
||||
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
|
||||
"loadingAreaData": "正在加载区域数据...提交前请稍候。",
|
||||
"cannotMoveConstrainedNode": "无法移动此相机 - 它连接到另一个地图元素(OSM way/relation)。您仍可以编辑其标签和方向。",
|
||||
"zoomInRequiredMessage": "请放大至至少第{}级来添加或编辑监控节点。这确保精确定位以便准确制图。",
|
||||
"extractFromWay": "从way/relation中提取节点",
|
||||
"extractFromWaySubtitle": "创建具有相同标签的新节点,允许移动到新位置",
|
||||
"refineTags": "细化标签",
|
||||
"refineTagsWithProfile": "细化标签({})"
|
||||
"existingTags": "<现有标签>",
|
||||
"noChangesDetected": "未检测到更改 - 无需提交",
|
||||
"noChangesTitle": "无更改可提交",
|
||||
"noChangesMessage": "您尚未对此节点进行任何更改。要提交编辑,您需要更改位置、配置文件、方向或标签。"
|
||||
},
|
||||
"download": {
|
||||
"title": "下载地图区域",
|
||||
@@ -151,9 +179,16 @@
|
||||
"withinTileLimit": "在 {} 瓦片限制内",
|
||||
"exceedsTileLimit": "当前选择超出 {} 瓦片限制",
|
||||
"offlineModeWarning": "离线模式下禁用下载。禁用离线模式以下载新区域。",
|
||||
"areaTooBigMessage": "请放大至至少第{}级来下载离线区域。下载大区域可能导致应用程序无响应。",
|
||||
"downloadStarted": "下载已开始!正在获取瓦片和节点...",
|
||||
"downloadFailed": "启动下载失败:{}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "下载已开始",
|
||||
"message": "下载已开始!正在获取瓦片和节点...",
|
||||
"ok": "确定",
|
||||
"viewProgress": "在设置中查看进度"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "上传目标",
|
||||
"subtitle": "选择摄像头上传位置",
|
||||
@@ -162,7 +197,8 @@
|
||||
"simulate": "模拟",
|
||||
"productionDescription": "上传到实时 OSM 数据库(对所有用户可见)",
|
||||
"sandboxDescription": "上传到 OSM 沙盒(测试安全,定期重置)。",
|
||||
"simulateDescription": "模拟上传(不联系 OSM 服务器)"
|
||||
"simulateDescription": "模拟上传(不联系 OSM 服务器)",
|
||||
"cannotChangeWithQueue": "队列中有 {} 个项目时无法更改上传目标。请先清空队列。"
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "OpenStreetMap 账户",
|
||||
@@ -185,7 +221,20 @@
|
||||
"deleteAccountSubtitle": "管理您的 OpenStreetMap 账户",
|
||||
"deleteAccountExplanation": "要删除您的 OpenStreetMap 账户,您需要访问 OpenStreetMap 网站。这将永久删除您的 OSM 账户和所有相关数据。",
|
||||
"deleteAccountWarning": "警告:此操作无法撤销,将永久删除您的 OSM 账户。",
|
||||
"goToOSM": "前往 OpenStreetMap"
|
||||
"goToOSM": "前往 OpenStreetMap",
|
||||
"accountManagement": "账户管理",
|
||||
"accountManagementDescription": "要删除您的 OpenStreetMap 账户,您需要访问相应的 OpenStreetMap 网站。这将永久删除您的账户和所有相关数据。",
|
||||
"currentDestinationProduction": "当前连接到:生产环境 OpenStreetMap",
|
||||
"currentDestinationSandbox": "当前连接到:沙盒环境 OpenStreetMap",
|
||||
"currentDestinationSimulate": "当前处于:模拟模式(无真实账户)",
|
||||
"viewMessages": "在 OSM 上查看消息",
|
||||
"unreadMessagesCount": "您有 {} 条未读消息",
|
||||
"noUnreadMessages": "没有未读消息",
|
||||
"reauthRequired": "刷新身份验证",
|
||||
"reauthExplanation": "您必须刷新身份验证才能通过应用接收 OSM 消息通知。",
|
||||
"reauthBenefit": "这将在您在 OpenStreetMap 上有未读消息时启用通知点。",
|
||||
"reauthNow": "现在执行",
|
||||
"reauthLater": "稍后"
|
||||
},
|
||||
"queue": {
|
||||
"title": "上传队列",
|
||||
@@ -203,7 +252,7 @@
|
||||
"queueCleared": "队列已清空",
|
||||
"uploadQueueTitle": "上传队列({} 项)",
|
||||
"queueIsEmpty": "队列为空",
|
||||
"cameraWithIndex": "摄像头 {}",
|
||||
"itemWithIndex": "项目 {}",
|
||||
"error": "(错误)",
|
||||
"completing": "(完成中...)",
|
||||
"destination": "目标:{}",
|
||||
@@ -213,7 +262,14 @@
|
||||
"attempts": "尝试次数:{}",
|
||||
"uploadFailedRetry": "上传失败。点击重试再次尝试。",
|
||||
"retryUpload": "重试上传",
|
||||
"clearAll": "全部清空"
|
||||
"clearAll": "全部清空",
|
||||
"errorDetails": "错误详情",
|
||||
"creatingChangeset": " (创建变更集...)",
|
||||
"uploading": " (上传中...)",
|
||||
"closingChangeset": " (关闭变更集...)",
|
||||
"processingPaused": "队列处理已暂停",
|
||||
"pausedDueToOffline": "因为离线模式已启用,上传处理已暂停。",
|
||||
"pausedByUser": "上传处理已手动暂停。"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "瓦片提供商",
|
||||
@@ -245,7 +301,7 @@
|
||||
"urlTemplate": "URL 模板",
|
||||
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "URL 模板为必填项",
|
||||
"urlTemplatePlaceholders": "URL 必须包含 {z}、{x} 和 {y} 占位符",
|
||||
"urlTemplatePlaceholders": "URL 必须包含 {quadkey} 或 {z}、{x} 和 {y} 占位符",
|
||||
"attribution": "归属",
|
||||
"attributionHint": "© 地图提供商",
|
||||
"attributionRequired": "归属为必填项",
|
||||
@@ -267,7 +323,14 @@
|
||||
"view": "查看",
|
||||
"deleteProfile": "删除配置文件",
|
||||
"deleteProfileConfirm": "您确定要删除 \"{}\" 吗?",
|
||||
"profileDeleted": "配置文件已删除"
|
||||
"profileDeleted": "配置文件已删除",
|
||||
"getMore": "获取更多...",
|
||||
"addProfileChoice": "添加配置文件",
|
||||
"addProfileChoiceMessage": "您希望如何添加配置文件?",
|
||||
"createCustomProfile": "创建自定义配置文件",
|
||||
"createCustomProfileDescription": "从头开始构建带有您自己标签的配置文件",
|
||||
"importFromWebsite": "从网站导入",
|
||||
"importFromWebsiteDescription": "浏览并从 deflock.me/identify 导入配置文件"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "地图瓦片",
|
||||
@@ -283,6 +346,10 @@
|
||||
"profileNameRequired": "配置文件名称为必填项",
|
||||
"requiresDirection": "需要方向",
|
||||
"requiresDirectionSubtitle": "此类型的摄像头是否需要方向标签",
|
||||
"fov": "视场角",
|
||||
"fovHint": "视场角度数(留空使用默认值)",
|
||||
"fovSubtitle": "摄像头视场角 - 用于锥体宽度和范围提交格式",
|
||||
"fovInvalid": "视场角必须在1到360度之间",
|
||||
"submittable": "可提交",
|
||||
"submittableSubtitle": "此配置文件是否可用于摄像头提交",
|
||||
"osmTags": "OSM 标签",
|
||||
@@ -353,7 +420,16 @@
|
||||
"additionalTagsTitle": "额外标签",
|
||||
"noTagsDefinedForProfile": "此运营商配置文件未定义标签。",
|
||||
"noOperatorProfiles": "未定义运营商配置文件",
|
||||
"noOperatorProfilesMessage": "在设置中创建运营商配置文件,以将额外标签应用于您的节点提交。"
|
||||
"noOperatorProfilesMessage": "在设置中创建运营商配置文件,以将额外标签应用于您的节点提交。",
|
||||
"profileTags": "配置文件标签",
|
||||
"profileTagsDescription": "为需要细化的标签指定值:",
|
||||
"selectValue": "选择值...",
|
||||
"noValue": "(无值)",
|
||||
"noSuggestions": "无建议可用",
|
||||
"existingTagsTitle": "现有标签",
|
||||
"existingTagsDescription": "编辑此设备上的现有标签。添加、删除或修改任何标签:",
|
||||
"existingOperator": "<现有运营商>",
|
||||
"existingOperatorTags": "现有运营商标签"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "在下载离线区域时无法更改瓦片类型",
|
||||
@@ -383,15 +459,18 @@
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "显示网络状态指示器",
|
||||
"showIndicatorSubtitle": "在地图上显示网络加载和错误状态",
|
||||
"loading": "加载中...",
|
||||
"timedOut": "超时",
|
||||
"noData": "这里没有瓦片",
|
||||
"success": "完成",
|
||||
"nodeLimitReached": "显示限制 - 在设置中增加",
|
||||
"tileProviderSlow": "瓦片提供商缓慢",
|
||||
"nodeDataSlow": "节点数据缓慢",
|
||||
"networkIssues": "网络问题"
|
||||
"showIndicatorSubtitle": "显示监控数据加载和错误状态",
|
||||
"loading": "加载监控数据...",
|
||||
"timedOut": "请求超时",
|
||||
"noData": "无离线数据",
|
||||
"success": "监控数据已加载",
|
||||
"nodeDataSlow": "监控数据缓慢",
|
||||
"rateLimited": "服务器限流",
|
||||
"networkError": "网络错误"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "显示 {rendered} / {total} 设备",
|
||||
"editingDisabledMessage": "可见设备过多,无法安全编辑。请放大地图以减少可见设备数量,然后重试。"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "搜索位置",
|
||||
@@ -416,18 +495,13 @@
|
||||
"endSelect": "终点(选择)",
|
||||
"distance": "距离:{} 公里",
|
||||
"routeActive": "路线活跃",
|
||||
"locationsTooClose": "起点和终点位置过于接近",
|
||||
"navigationSettings": "导航",
|
||||
"navigationSettingsSubtitle": "路线规划和回避设置",
|
||||
"avoidanceDistance": "回避距离",
|
||||
"avoidanceDistanceSubtitle": "与监控设备保持的最小距离",
|
||||
"searchHistory": "最大搜索历史",
|
||||
"searchHistorySubtitle": "要记住的最近搜索次数",
|
||||
"units": "单位",
|
||||
"unitsSubtitle": "距离和测量的显示单位",
|
||||
"metric": "公制(公里,米)",
|
||||
"imperial": "英制(英里,英尺)",
|
||||
"meters": "米",
|
||||
"feet": "英尺"
|
||||
"searchHistorySubtitle": "要记住的最近搜索次数"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "疑似位置",
|
||||
@@ -463,5 +537,19 @@
|
||||
"url": "网址",
|
||||
"coordinates": "坐标",
|
||||
"noAddressAvailable": "无可用地址"
|
||||
},
|
||||
"units": {
|
||||
"meters": "米",
|
||||
"feet": "英尺",
|
||||
"kilometers": "公里",
|
||||
"miles": "英里",
|
||||
"metersLong": "米",
|
||||
"feetLong": "英尺",
|
||||
"kilometersLong": "公里",
|
||||
"milesLong": "英里",
|
||||
"metric": "公制",
|
||||
"imperial": "英制",
|
||||
"metricDescription": "公制 (公里, 米)",
|
||||
"imperialDescription": "英制 (英里, 英尺)"
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import 'screens/osm_account_screen.dart';
|
||||
import 'screens/upload_queue_screen.dart';
|
||||
import 'services/localization_service.dart';
|
||||
import 'services/version_service.dart';
|
||||
import 'services/deep_link_service.dart';
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +28,10 @@ Future<void> main() async {
|
||||
// Initialize localization service
|
||||
await LocalizationService.instance.init();
|
||||
|
||||
// Initialize deep link service
|
||||
await DeepLinkService().init();
|
||||
DeepLinkService().setNavigatorKey(_navigatorKey);
|
||||
|
||||
runApp(
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => AppState(),
|
||||
@@ -68,6 +73,7 @@ class DeFlockApp extends StatelessWidget {
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
navigatorKey: _navigatorKey,
|
||||
routes: {
|
||||
'/': (context) => const HomeScreen(),
|
||||
'/settings': (context) => const SettingsScreen(),
|
||||
@@ -82,7 +88,11 @@ class DeFlockApp extends StatelessWidget {
|
||||
'/settings/release-notes': (context) => const ReleaseNotesScreen(),
|
||||
},
|
||||
initialRoute: '/',
|
||||
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Global navigator key for deep link navigation
|
||||
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
|
||||
157
lib/migrations.dart
Normal file
157
lib/migrations.dart
Normal file
@@ -0,0 +1,157 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'app_state.dart';
|
||||
import 'services/profile_service.dart';
|
||||
import 'services/suspected_location_cache.dart';
|
||||
import 'widgets/nuclear_reset_dialog.dart';
|
||||
|
||||
/// One-time migrations that run when users upgrade to specific versions.
|
||||
/// Each migration function is named after the version where it should run.
|
||||
class OneTimeMigrations {
|
||||
/// Enable network status indicator for all existing users (v1.3.1)
|
||||
static Future<void> migrate_1_3_1(AppState appState) async {
|
||||
await appState.setNetworkStatusIndicatorEnabled(true);
|
||||
debugPrint('[Migration] 1.3.1 completed: enabled network status indicator');
|
||||
}
|
||||
|
||||
/// Migrate upload queue to new two-stage changeset system (v1.5.3)
|
||||
static Future<void> migrate_1_5_3(AppState appState) async {
|
||||
// Migration is handled automatically in PendingUpload.fromJson via _migrateFromLegacyFields
|
||||
// This triggers a queue reload to apply migrations
|
||||
await appState.reloadUploadQueue();
|
||||
debugPrint('[Migration] 1.5.3 completed: migrated upload queue to two-stage system');
|
||||
}
|
||||
|
||||
/// Clear FOV values from built-in profiles only (v1.6.3)
|
||||
static Future<void> migrate_1_6_3(AppState appState) async {
|
||||
// Load all custom profiles from storage (includes any customized built-in profiles)
|
||||
final profiles = await ProfileService().load();
|
||||
|
||||
// Find profiles with built-in IDs and clear their FOV values
|
||||
final updatedProfiles = profiles.map((profile) {
|
||||
if (profile.id.startsWith('builtin-') && profile.fov != null) {
|
||||
debugPrint('[Migration] Clearing FOV from profile: ${profile.id}');
|
||||
return profile.copyWith(fov: null);
|
||||
}
|
||||
return profile;
|
||||
}).toList();
|
||||
|
||||
// Save updated profiles back to storage
|
||||
await ProfileService().save(updatedProfiles);
|
||||
|
||||
debugPrint('[Migration] 1.6.3 completed: cleared FOV values from built-in profiles');
|
||||
}
|
||||
|
||||
/// Migrate suspected locations from SharedPreferences to SQLite (v1.8.0)
|
||||
static Future<void> migrate_1_8_0(AppState appState) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Legacy SharedPreferences keys
|
||||
const legacyProcessedDataKey = 'suspected_locations_processed_data';
|
||||
const legacyLastFetchKey = 'suspected_locations_last_fetch';
|
||||
|
||||
// Check if we have legacy data
|
||||
final legacyData = prefs.getString(legacyProcessedDataKey);
|
||||
final legacyLastFetch = prefs.getInt(legacyLastFetchKey);
|
||||
|
||||
if (legacyData != null && legacyLastFetch != null) {
|
||||
debugPrint('[Migration] 1.8.0: Found legacy suspected location data, migrating to database...');
|
||||
|
||||
// Parse legacy processed data format
|
||||
final List<dynamic> legacyProcessedList = jsonDecode(legacyData);
|
||||
final List<Map<String, dynamic>> rawDataList = [];
|
||||
|
||||
for (final entry in legacyProcessedList) {
|
||||
if (entry is Map<String, dynamic> && entry['rawData'] != null) {
|
||||
rawDataList.add(Map<String, dynamic>.from(entry['rawData']));
|
||||
}
|
||||
}
|
||||
|
||||
if (rawDataList.isNotEmpty) {
|
||||
final fetchTime = DateTime.fromMillisecondsSinceEpoch(legacyLastFetch);
|
||||
|
||||
// Get the cache instance and migrate data
|
||||
final cache = SuspectedLocationCache();
|
||||
await cache.loadFromStorage(); // Initialize database
|
||||
await cache.processAndSave(rawDataList, fetchTime);
|
||||
|
||||
debugPrint('[Migration] 1.8.0: Migrated ${rawDataList.length} entries from legacy storage');
|
||||
}
|
||||
|
||||
// Clean up legacy data after successful migration
|
||||
await prefs.remove(legacyProcessedDataKey);
|
||||
await prefs.remove(legacyLastFetchKey);
|
||||
|
||||
debugPrint('[Migration] 1.8.0: Legacy data cleanup completed');
|
||||
}
|
||||
|
||||
// Ensure suspected locations are reinitialized with new system
|
||||
await appState.reinitSuspectedLocations();
|
||||
|
||||
debugPrint('[Migration] 1.8.0 completed: migrated suspected locations to SQLite database');
|
||||
} catch (e) {
|
||||
debugPrint('[Migration] 1.8.0 ERROR: Failed to migrate suspected locations: $e');
|
||||
// Don't rethrow - migration failure shouldn't break the app
|
||||
// The new system will work fine, users just lose their cached data
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear any active sessions to reset refined tags system (v2.1.0)
|
||||
static Future<void> migrate_2_1_0(AppState appState) async {
|
||||
try {
|
||||
// Clear any existing sessions since they won't have refinedTags field
|
||||
// This is simpler and safer than trying to migrate session data
|
||||
appState.cancelSession();
|
||||
appState.cancelEditSession();
|
||||
|
||||
debugPrint('[Migration] 2.1.0 completed: cleared sessions for refined tags system');
|
||||
} catch (e) {
|
||||
debugPrint('[Migration] 2.1.0 ERROR: Failed to clear sessions: $e');
|
||||
// Don't rethrow - this is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the migration function for a specific version
|
||||
static Future<void> Function(AppState)? getMigrationForVersion(String version) {
|
||||
switch (version) {
|
||||
case '1.3.1':
|
||||
return migrate_1_3_1;
|
||||
case '1.5.3':
|
||||
return migrate_1_5_3;
|
||||
case '1.6.3':
|
||||
return migrate_1_6_3;
|
||||
case '1.8.0':
|
||||
return migrate_1_8_0;
|
||||
case '2.1.0':
|
||||
return migrate_2_1_0;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Run migration for a specific version with nuclear reset on failure
|
||||
static Future<void> runMigration(String version, AppState appState, BuildContext? context) async {
|
||||
try {
|
||||
final migration = getMigrationForVersion(version);
|
||||
if (migration != null) {
|
||||
await migration(appState);
|
||||
} else {
|
||||
debugPrint('[Migration] Unknown migration version: $version');
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('[Migration] CRITICAL: Migration $version failed: $error');
|
||||
debugPrint('[Migration] Stack trace: $stackTrace');
|
||||
|
||||
// Nuclear option: clear everything and show non-dismissible error dialog
|
||||
if (context != null && context.mounted) {
|
||||
NuclearResetDialog.show(context, error, stackTrace);
|
||||
} else {
|
||||
// If no context available, just log and hope for the best
|
||||
debugPrint('[Migration] No context available for error dialog, migration failure unhandled');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
lib/models/direction_fov.dart
Normal file
24
lib/models/direction_fov.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
/// Represents a direction with its associated field-of-view (FOV) cone.
|
||||
class DirectionFov {
|
||||
/// The center direction in degrees (0-359, where 0 is north)
|
||||
final double centerDegrees;
|
||||
|
||||
/// The field-of-view width in degrees (e.g., 35, 90, 180, 360)
|
||||
final double fovDegrees;
|
||||
|
||||
DirectionFov(this.centerDegrees, this.fovDegrees);
|
||||
|
||||
@override
|
||||
String toString() => 'DirectionFov(center: $centerDegrees°, fov: $fovDegrees°)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is DirectionFov &&
|
||||
runtimeType == other.runtimeType &&
|
||||
centerDegrees == other.centerDegrees &&
|
||||
fovDegrees == other.fovDegrees;
|
||||
|
||||
@override
|
||||
int get hashCode => centerDegrees.hashCode ^ fovDegrees.hashCode;
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'osm_node.dart';
|
||||
|
||||
/// Sentinel value for copyWith methods to distinguish between null and not provided
|
||||
const Object _notProvided = Object();
|
||||
|
||||
/// A bundle of preset OSM tags that describe a particular surveillance node model/type.
|
||||
class NodeProfile {
|
||||
@@ -9,6 +12,7 @@ class NodeProfile {
|
||||
final bool requiresDirection;
|
||||
final bool submittable;
|
||||
final bool editable;
|
||||
final double? fov; // Field-of-view in degrees (null means use dev_config default)
|
||||
|
||||
NodeProfile({
|
||||
required this.id,
|
||||
@@ -18,6 +22,7 @@ class NodeProfile {
|
||||
this.requiresDirection = true,
|
||||
this.submittable = true,
|
||||
this.editable = true,
|
||||
this.fov,
|
||||
});
|
||||
|
||||
/// Get all built-in default node profiles
|
||||
@@ -43,6 +48,7 @@ class NodeProfile {
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'camera:mount': '', // Empty value for refinement
|
||||
'manufacturer': 'Flock Safety',
|
||||
'manufacturer:wikidata': 'Q108485435',
|
||||
},
|
||||
@@ -60,6 +66,7 @@ class NodeProfile {
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'camera:mount': '', // Empty value for refinement
|
||||
'manufacturer': 'Motorola Solutions',
|
||||
'manufacturer:wikidata': 'Q634815',
|
||||
},
|
||||
@@ -77,6 +84,7 @@ class NodeProfile {
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'camera:mount': '', // Empty value for refinement
|
||||
'manufacturer': 'Genetec',
|
||||
'manufacturer:wikidata': 'Q30295174',
|
||||
},
|
||||
@@ -94,6 +102,7 @@ class NodeProfile {
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'camera:mount': '', // Empty value for refinement
|
||||
'manufacturer': 'Leonardo',
|
||||
'manufacturer:wikidata': 'Q910379',
|
||||
},
|
||||
@@ -111,6 +120,7 @@ class NodeProfile {
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'camera:mount': '', // Empty value for refinement
|
||||
'manufacturer': 'Neology, Inc.',
|
||||
},
|
||||
builtin: true,
|
||||
@@ -127,6 +137,7 @@ class NodeProfile {
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'camera:mount': '', // Empty value for refinement
|
||||
'manufacturer': 'Rekor',
|
||||
},
|
||||
builtin: true,
|
||||
@@ -143,6 +154,7 @@ class NodeProfile {
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'camera:mount': '', // Empty value for refinement
|
||||
'manufacturer': 'Axis Communications',
|
||||
'manufacturer:wikidata': 'Q2347731',
|
||||
},
|
||||
@@ -208,6 +220,7 @@ class NodeProfile {
|
||||
bool? requiresDirection,
|
||||
bool? submittable,
|
||||
bool? editable,
|
||||
Object? fov = _notProvided,
|
||||
}) =>
|
||||
NodeProfile(
|
||||
id: id ?? this.id,
|
||||
@@ -217,6 +230,7 @@ class NodeProfile {
|
||||
requiresDirection: requiresDirection ?? this.requiresDirection,
|
||||
submittable: submittable ?? this.submittable,
|
||||
editable: editable ?? this.editable,
|
||||
fov: fov == _notProvided ? this.fov : fov as double?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -227,6 +241,7 @@ class NodeProfile {
|
||||
'requiresDirection': requiresDirection,
|
||||
'submittable': submittable,
|
||||
'editable': editable,
|
||||
'fov': fov,
|
||||
};
|
||||
|
||||
factory NodeProfile.fromJson(Map<String, dynamic> j) => NodeProfile(
|
||||
@@ -237,6 +252,7 @@ class NodeProfile {
|
||||
requiresDirection: j['requiresDirection'] ?? true, // Default to true for backward compatibility
|
||||
submittable: j['submittable'] ?? true, // Default to true for backward compatibility
|
||||
editable: j['editable'] ?? true, // Default to true for backward compatibility
|
||||
fov: j['fov']?.toDouble(), // Can be null for backward compatibility
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -248,5 +264,36 @@ class NodeProfile {
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
|
||||
/// Create a temporary empty profile for editing existing nodes
|
||||
/// Used as the default `<Existing tags>` option when editing nodes
|
||||
/// All existing tags will flow through as additionalExistingTags
|
||||
static NodeProfile createExistingTagsProfile(OsmNode node) {
|
||||
// Calculate FOV from existing direction ranges if applicable
|
||||
double? calculatedFov;
|
||||
|
||||
// If node has direction/FOV pairs, check if they all have the same FOV
|
||||
if (node.directionFovPairs.isNotEmpty) {
|
||||
final firstFov = node.directionFovPairs.first.fovDegrees;
|
||||
|
||||
// If all directions have the same FOV, use it for the profile
|
||||
if (node.directionFovPairs.every((df) => df.fovDegrees == firstFov)) {
|
||||
calculatedFov = firstFov;
|
||||
}
|
||||
}
|
||||
|
||||
return NodeProfile(
|
||||
id: 'temp-empty-${node.id}',
|
||||
name: '<Existing tags>', // Will be localized in UI
|
||||
tags: {}, // Completely empty - all existing tags become additional
|
||||
builtin: false,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: false,
|
||||
fov: calculatedFov, // Use calculated FOV from existing direction ranges
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'osm_node.dart';
|
||||
|
||||
/// A bundle of OSM tags that describe a particular surveillance operator.
|
||||
/// These are applied on top of camera profile tags during submissions.
|
||||
@@ -76,4 +76,56 @@ class OperatorProfile {
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
|
||||
/// Create a temporary operator profile from existing operator tags on a node
|
||||
/// First tries to match against saved operator profiles, otherwise creates temporary one
|
||||
/// Used as the default operator profile when editing nodes
|
||||
static OperatorProfile? createExistingOperatorProfile(OsmNode node, List<OperatorProfile> savedProfiles) {
|
||||
final operatorTags = _extractOperatorTags(node.tags);
|
||||
if (operatorTags.isEmpty) return null;
|
||||
|
||||
// First, try to find a perfect match among saved profiles
|
||||
for (final savedProfile in savedProfiles) {
|
||||
if (_tagsMatch(savedProfile.tags, operatorTags)) {
|
||||
return savedProfile;
|
||||
}
|
||||
}
|
||||
|
||||
// No perfect match found, create temporary profile
|
||||
final operatorName = operatorTags['operator'] ?? '<existing>';
|
||||
|
||||
return OperatorProfile(
|
||||
id: 'temp-existing-operator-${node.id}',
|
||||
name: operatorName,
|
||||
tags: operatorTags,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if two tag maps are identical
|
||||
static bool _tagsMatch(Map<String, String> tags1, Map<String, String> tags2) {
|
||||
if (tags1.length != tags2.length) return false;
|
||||
|
||||
for (final entry in tags1.entries) {
|
||||
if (tags2[entry.key] != entry.value) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Extract all operator-related tags from a node's tags
|
||||
static Map<String, String> _extractOperatorTags(Map<String, String> tags) {
|
||||
final operatorTags = <String, String>{};
|
||||
|
||||
for (final entry in tags.entries) {
|
||||
// Include operator= and any operator:*= tags
|
||||
if (entry.key == 'operator' || entry.key.startsWith('operator:')) {
|
||||
operatorTags[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
return operatorTags;
|
||||
}
|
||||
|
||||
/// Returns true if this is a temporary "existing operator" profile
|
||||
bool get isExistingOperatorProfile => id.startsWith('temp-existing-operator-');
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'direction_fov.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
class OsmNode {
|
||||
final int id;
|
||||
@@ -36,9 +38,10 @@ class OsmNode {
|
||||
);
|
||||
}
|
||||
|
||||
bool get hasDirection => directionDeg.isNotEmpty;
|
||||
bool get hasDirection => directionFovPairs.isNotEmpty;
|
||||
|
||||
List<double> get directionDeg {
|
||||
/// Get direction and FOV pairs, supporting range notation like "90-270" or "10-45;90-125;290"
|
||||
List<DirectionFov> get directionFovPairs {
|
||||
final raw = tags['direction'] ?? tags['camera:direction'];
|
||||
if (raw == null) return [];
|
||||
|
||||
@@ -50,17 +53,35 @@ class OsmNode {
|
||||
'W': 270.0, 'WNW': 292.5, 'NW': 315.0, 'NNW': 337.5,
|
||||
};
|
||||
|
||||
// Split on semicolons and parse each direction
|
||||
final directions = <double>[];
|
||||
final directionFovList = <DirectionFov>[];
|
||||
final parts = raw.split(';');
|
||||
|
||||
for (final part in parts) {
|
||||
final trimmed = part.trim().toUpperCase();
|
||||
final trimmed = part.trim();
|
||||
if (trimmed.isEmpty) continue;
|
||||
|
||||
// Check if this part contains a range (e.g., "90-270")
|
||||
if (trimmed.contains('-') && RegExp(r'^\d+\.?\d*-\d+\.?\d*$').hasMatch(trimmed)) {
|
||||
final rangeParts = trimmed.split('-');
|
||||
if (rangeParts.length == 2) {
|
||||
final start = double.tryParse(rangeParts[0]);
|
||||
final end = double.tryParse(rangeParts[1]);
|
||||
|
||||
if (start != null && end != null) {
|
||||
final normalized = _calculateRangeCenter(start, end);
|
||||
directionFovList.add(normalized);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not a range, handle as single direction
|
||||
final trimmedUpper = trimmed.toUpperCase();
|
||||
|
||||
// First try compass direction lookup
|
||||
if (compassDirections.containsKey(trimmed)) {
|
||||
directions.add(compassDirections[trimmed]!);
|
||||
if (compassDirections.containsKey(trimmedUpper)) {
|
||||
final degrees = compassDirections[trimmedUpper]!;
|
||||
directionFovList.add(DirectionFov(degrees, kDirectionConeHalfAngle * 2));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -74,9 +95,40 @@ class OsmNode {
|
||||
|
||||
// Normalize: wrap negative or >360 into 0‑359 range
|
||||
final normalized = ((val % 360) + 360) % 360;
|
||||
directions.add(normalized);
|
||||
directionFovList.add(DirectionFov(normalized, kDirectionConeHalfAngle * 2));
|
||||
}
|
||||
|
||||
return directions;
|
||||
return directionFovList;
|
||||
}
|
||||
|
||||
/// Calculate center and width for a range like "90-270" or "270-90"
|
||||
DirectionFov _calculateRangeCenter(double start, double end) {
|
||||
// Normalize start and end to 0-359 range
|
||||
start = ((start % 360) + 360) % 360;
|
||||
end = ((end % 360) + 360) % 360;
|
||||
|
||||
// Special case: if start equals end, this represents 360° FOV
|
||||
if (start == end) {
|
||||
return DirectionFov(start, 360.0);
|
||||
}
|
||||
|
||||
double width, center;
|
||||
|
||||
if (start > end) {
|
||||
// Wrapping case: 270-90
|
||||
width = (end + 360) - start;
|
||||
center = ((start + end + 360) / 2) % 360;
|
||||
} else {
|
||||
// Normal case: 90-270
|
||||
width = end - start;
|
||||
center = (start + end) / 2;
|
||||
}
|
||||
|
||||
return DirectionFov(center, width);
|
||||
}
|
||||
|
||||
/// Legacy getter for backward compatibility - returns just center directions
|
||||
List<double> get directionDeg {
|
||||
return directionFovPairs.map((df) => df.centerDegrees).toList();
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,73 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'node_profile.dart';
|
||||
import 'operator_profile.dart';
|
||||
import '../state/settings_state.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
enum UploadOperation { create, modify, delete, extract }
|
||||
|
||||
enum UploadState {
|
||||
pending, // Not started yet
|
||||
creatingChangeset, // Creating changeset
|
||||
uploading, // Node operation (create/modify/delete)
|
||||
closingChangeset, // Closing changeset
|
||||
error, // Upload failed (needs user retry) OR changeset not found
|
||||
complete // Everything done
|
||||
}
|
||||
|
||||
class PendingUpload {
|
||||
final LatLng coord;
|
||||
final dynamic direction; // Can be double or String for multiple directions
|
||||
final NodeProfile? profile;
|
||||
final OperatorProfile? operatorProfile;
|
||||
final Map<String, String> refinedTags; // User-selected values for empty profile tags
|
||||
final Map<String, String> additionalExistingTags; // Tags that exist on node but not in profile
|
||||
final String changesetComment; // User-editable changeset comment
|
||||
final UploadMode uploadMode; // Capture upload destination when queued
|
||||
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? tempNodeId; // ID of temporary node created in cache (for specific cleanup)
|
||||
int attempts;
|
||||
bool error;
|
||||
bool completing; // True when upload succeeded but item is showing checkmark briefly
|
||||
bool error; // DEPRECATED: Use uploadState instead
|
||||
String? errorMessage; // Detailed error message for debugging
|
||||
bool completing; // DEPRECATED: Use uploadState instead
|
||||
UploadState uploadState; // Current state in the upload pipeline
|
||||
String? changesetId; // ID of changeset that needs closing
|
||||
DateTime? nodeOperationCompletedAt; // When node operation completed (start of 59-minute countdown)
|
||||
int changesetCloseAttempts; // Number of changeset close attempts
|
||||
DateTime? lastChangesetCloseAttemptAt; // When we last tried to close changeset (for retry timing)
|
||||
int nodeSubmissionAttempts; // Number of node submission attempts (separate from overall attempts)
|
||||
DateTime? lastNodeSubmissionAttemptAt; // When we last tried to submit node (for retry timing)
|
||||
|
||||
PendingUpload({
|
||||
required this.coord,
|
||||
required this.direction,
|
||||
this.profile,
|
||||
this.operatorProfile,
|
||||
Map<String, String>? refinedTags,
|
||||
Map<String, String>? additionalExistingTags,
|
||||
required this.changesetComment,
|
||||
required this.uploadMode,
|
||||
required this.operation,
|
||||
this.originalNodeId,
|
||||
this.submittedNodeId,
|
||||
this.tempNodeId,
|
||||
this.attempts = 0,
|
||||
this.error = false,
|
||||
this.errorMessage,
|
||||
this.completing = false,
|
||||
}) : assert(
|
||||
this.uploadState = UploadState.pending,
|
||||
this.changesetId,
|
||||
this.nodeOperationCompletedAt,
|
||||
this.changesetCloseAttempts = 0,
|
||||
this.lastChangesetCloseAttemptAt,
|
||||
this.nodeSubmissionAttempts = 0,
|
||||
this.lastNodeSubmissionAttemptAt,
|
||||
}) : refinedTags = refinedTags ?? {},
|
||||
additionalExistingTags = additionalExistingTags ?? {},
|
||||
assert(
|
||||
(operation == UploadOperation.create && originalNodeId == null) ||
|
||||
(operation == UploadOperation.create) || (originalNodeId != null),
|
||||
'originalNodeId must be null for create operations and non-null for modify/delete/extract operations'
|
||||
@@ -48,6 +85,53 @@ class PendingUpload {
|
||||
|
||||
// True if this is an extract operation (new node with tags from constrained node)
|
||||
bool get isExtraction => operation == UploadOperation.extract;
|
||||
|
||||
// New state-based helpers
|
||||
bool get needsUserRetry => uploadState == UploadState.error;
|
||||
bool get isActivelyProcessing => uploadState == UploadState.creatingChangeset || uploadState == UploadState.uploading || uploadState == UploadState.closingChangeset;
|
||||
bool get isComplete => uploadState == UploadState.complete;
|
||||
bool get isPending => uploadState == UploadState.pending;
|
||||
bool get isCreatingChangeset => uploadState == UploadState.creatingChangeset;
|
||||
bool get isUploading => uploadState == UploadState.uploading;
|
||||
bool get isClosingChangeset => uploadState == UploadState.closingChangeset;
|
||||
|
||||
// Calculate time until OSM auto-closes changeset (for UI display)
|
||||
// This uses nodeOperationCompletedAt (when changeset was created) as the reference
|
||||
Duration? get timeUntilAutoClose {
|
||||
if (nodeOperationCompletedAt == null) return null;
|
||||
final elapsed = DateTime.now().difference(nodeOperationCompletedAt!);
|
||||
final remaining = kChangesetAutoCloseTimeout - elapsed;
|
||||
return remaining.isNegative ? Duration.zero : remaining;
|
||||
}
|
||||
|
||||
// Check if the 59-minute window has expired (for phases 2 & 3)
|
||||
// This uses nodeOperationCompletedAt (when changeset was created) as the reference
|
||||
bool get hasChangesetExpired {
|
||||
if (nodeOperationCompletedAt == null) return false;
|
||||
return DateTime.now().difference(nodeOperationCompletedAt!) >= kChangesetAutoCloseTimeout;
|
||||
}
|
||||
|
||||
// Legacy method name for backward compatibility
|
||||
bool get shouldGiveUpOnChangeset => hasChangesetExpired;
|
||||
|
||||
// Calculate next retry delay for changeset close using exponential backoff
|
||||
Duration get nextChangesetCloseRetryDelay {
|
||||
final delay = Duration(
|
||||
milliseconds: (kChangesetCloseInitialRetryDelay.inMilliseconds *
|
||||
math.pow(kChangesetCloseBackoffMultiplier, changesetCloseAttempts)).round()
|
||||
);
|
||||
return delay > kChangesetCloseMaxRetryDelay
|
||||
? kChangesetCloseMaxRetryDelay
|
||||
: delay;
|
||||
}
|
||||
|
||||
// Check if it's time to retry changeset close
|
||||
bool get isReadyForChangesetCloseRetry {
|
||||
if (lastChangesetCloseAttemptAt == null) return true; // First attempt
|
||||
|
||||
final nextRetryTime = lastChangesetCloseAttemptAt!.add(nextChangesetCloseRetryDelay);
|
||||
return DateTime.now().isAfter(nextRetryTime);
|
||||
}
|
||||
|
||||
// Get display name for the upload destination
|
||||
String get uploadModeDisplayName {
|
||||
@@ -61,7 +145,89 @@ class PendingUpload {
|
||||
}
|
||||
}
|
||||
|
||||
// Get combined tags from node profile and operator profile
|
||||
// Set error state with detailed message
|
||||
void setError(String message) {
|
||||
error = true; // Keep for backward compatibility
|
||||
uploadState = UploadState.error;
|
||||
errorMessage = message;
|
||||
}
|
||||
|
||||
// Clear error state
|
||||
void clearError() {
|
||||
error = false; // Keep for backward compatibility
|
||||
uploadState = UploadState.pending;
|
||||
errorMessage = null;
|
||||
attempts = 0;
|
||||
changesetCloseAttempts = 0;
|
||||
changesetId = null;
|
||||
nodeOperationCompletedAt = null;
|
||||
lastChangesetCloseAttemptAt = null;
|
||||
nodeSubmissionAttempts = 0;
|
||||
lastNodeSubmissionAttemptAt = null;
|
||||
}
|
||||
|
||||
// Mark as creating changeset
|
||||
void markAsCreatingChangeset() {
|
||||
uploadState = UploadState.creatingChangeset;
|
||||
error = false;
|
||||
completing = false;
|
||||
errorMessage = null;
|
||||
}
|
||||
|
||||
// Mark changeset created, start node operation
|
||||
void markChangesetCreated(String csId) {
|
||||
uploadState = UploadState.uploading;
|
||||
changesetId = csId;
|
||||
nodeOperationCompletedAt = DateTime.now(); // Track when changeset was created for 59-minute timeout
|
||||
}
|
||||
|
||||
// Mark node operation as complete, start changeset close phase
|
||||
void markNodeOperationComplete() {
|
||||
uploadState = UploadState.closingChangeset;
|
||||
changesetCloseAttempts = 0;
|
||||
// Note: nodeSubmissionAttempts preserved for debugging/stats
|
||||
}
|
||||
|
||||
// Mark entire upload as complete
|
||||
void markAsComplete() {
|
||||
uploadState = UploadState.complete;
|
||||
completing = true; // Keep for UI compatibility
|
||||
error = false;
|
||||
errorMessage = null;
|
||||
}
|
||||
|
||||
// Increment changeset close attempt counter and record attempt time
|
||||
void incrementChangesetCloseAttempts() {
|
||||
changesetCloseAttempts++;
|
||||
lastChangesetCloseAttemptAt = DateTime.now();
|
||||
}
|
||||
|
||||
// Increment node submission attempt counter and record attempt time
|
||||
void incrementNodeSubmissionAttempts() {
|
||||
nodeSubmissionAttempts++;
|
||||
lastNodeSubmissionAttemptAt = DateTime.now();
|
||||
}
|
||||
|
||||
// Calculate next retry delay for node submission using exponential backoff
|
||||
Duration get nextNodeSubmissionRetryDelay {
|
||||
final delay = Duration(
|
||||
milliseconds: (kChangesetCloseInitialRetryDelay.inMilliseconds *
|
||||
math.pow(kChangesetCloseBackoffMultiplier, nodeSubmissionAttempts)).round()
|
||||
);
|
||||
return delay > kChangesetCloseMaxRetryDelay
|
||||
? kChangesetCloseMaxRetryDelay
|
||||
: delay;
|
||||
}
|
||||
|
||||
// Check if it's time to retry node submission
|
||||
bool get isReadyForNodeSubmissionRetry {
|
||||
if (lastNodeSubmissionAttemptAt == null) return true; // First attempt
|
||||
|
||||
final nextRetryTime = lastNodeSubmissionAttemptAt!.add(nextNodeSubmissionRetryDelay);
|
||||
return DateTime.now().isAfter(nextRetryTime);
|
||||
}
|
||||
|
||||
// Get combined tags from node profile, operator profile, refined tags, and additional existing tags
|
||||
Map<String, String> getCombinedTags() {
|
||||
// Deletions don't need tags
|
||||
if (operation == UploadOperation.delete || profile == null) {
|
||||
@@ -70,7 +236,21 @@ class PendingUpload {
|
||||
|
||||
final tags = Map<String, String>.from(profile!.tags);
|
||||
|
||||
// Add operator profile tags (they override node profile tags if there are conflicts)
|
||||
// Add additional existing tags first (these have lower precedence)
|
||||
tags.addAll(additionalExistingTags);
|
||||
|
||||
// Apply profile tags again to ensure they take precedence over additional existing tags
|
||||
tags.addAll(profile!.tags);
|
||||
|
||||
// Apply refined tags (these fill in empty values from the profile)
|
||||
for (final entry in refinedTags.entries) {
|
||||
// Only apply refined tags if the profile tag value is empty
|
||||
if (profile!.tags.containsKey(entry.key) && profile!.tags[entry.key]?.trim().isEmpty == true) {
|
||||
tags[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Add operator profile tags (they override everything if there are conflicts)
|
||||
if (operatorProfile != null) {
|
||||
tags.addAll(operatorProfile!.tags);
|
||||
}
|
||||
@@ -86,6 +266,10 @@ class PendingUpload {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out any tags that are still empty after refinement
|
||||
// Empty tags in profiles are fine for refinement UI, but shouldn't be submitted to OSM
|
||||
tags.removeWhere((key, value) => value.trim().isEmpty);
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
@@ -95,13 +279,25 @@ class PendingUpload {
|
||||
'dir': direction,
|
||||
'profile': profile?.toJson(),
|
||||
'operatorProfile': operatorProfile?.toJson(),
|
||||
'refinedTags': refinedTags,
|
||||
'additionalExistingTags': additionalExistingTags,
|
||||
'changesetComment': changesetComment,
|
||||
'uploadMode': uploadMode.index,
|
||||
'operation': operation.index,
|
||||
'originalNodeId': originalNodeId,
|
||||
'submittedNodeId': submittedNodeId,
|
||||
'tempNodeId': tempNodeId,
|
||||
'attempts': attempts,
|
||||
'error': error,
|
||||
'errorMessage': errorMessage,
|
||||
'completing': completing,
|
||||
'uploadState': uploadState.index,
|
||||
'changesetId': changesetId,
|
||||
'nodeOperationCompletedAt': nodeOperationCompletedAt?.millisecondsSinceEpoch,
|
||||
'changesetCloseAttempts': changesetCloseAttempts,
|
||||
'lastChangesetCloseAttemptAt': lastChangesetCloseAttemptAt?.millisecondsSinceEpoch,
|
||||
'nodeSubmissionAttempts': nodeSubmissionAttempts,
|
||||
'lastNodeSubmissionAttemptAt': lastNodeSubmissionAttemptAt?.millisecondsSinceEpoch,
|
||||
};
|
||||
|
||||
factory PendingUpload.fromJson(Map<String, dynamic> j) => PendingUpload(
|
||||
@@ -113,6 +309,13 @@ class PendingUpload {
|
||||
operatorProfile: j['operatorProfile'] != null
|
||||
? OperatorProfile.fromJson(j['operatorProfile'])
|
||||
: null,
|
||||
refinedTags: j['refinedTags'] != null
|
||||
? Map<String, String>.from(j['refinedTags'])
|
||||
: {}, // Default empty map for legacy entries
|
||||
additionalExistingTags: j['additionalExistingTags'] != null
|
||||
? Map<String, String>.from(j['additionalExistingTags'])
|
||||
: {}, // Default empty map for legacy entries
|
||||
changesetComment: j['changesetComment'] ?? _generateLegacyComment(j), // Default for legacy entries
|
||||
uploadMode: j['uploadMode'] != null
|
||||
? UploadMode.values[j['uploadMode']]
|
||||
: UploadMode.production, // Default for legacy entries
|
||||
@@ -121,9 +324,56 @@ class PendingUpload {
|
||||
: (j['originalNodeId'] != null ? UploadOperation.modify : UploadOperation.create), // Legacy compatibility
|
||||
originalNodeId: j['originalNodeId'],
|
||||
submittedNodeId: j['submittedNodeId'],
|
||||
tempNodeId: j['tempNodeId'],
|
||||
attempts: j['attempts'] ?? 0,
|
||||
error: j['error'] ?? false,
|
||||
errorMessage: j['errorMessage'], // Can be null for legacy entries
|
||||
completing: j['completing'] ?? false, // Default to false for legacy entries
|
||||
uploadState: j['uploadState'] != null
|
||||
? UploadState.values[j['uploadState']]
|
||||
: _migrateFromLegacyFields(j), // Migrate from legacy error/completing fields
|
||||
changesetId: j['changesetId'],
|
||||
nodeOperationCompletedAt: j['nodeOperationCompletedAt'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(j['nodeOperationCompletedAt'])
|
||||
: null,
|
||||
changesetCloseAttempts: j['changesetCloseAttempts'] ?? 0,
|
||||
lastChangesetCloseAttemptAt: j['lastChangesetCloseAttemptAt'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(j['lastChangesetCloseAttemptAt'])
|
||||
: null,
|
||||
nodeSubmissionAttempts: j['nodeSubmissionAttempts'] ?? 0,
|
||||
lastNodeSubmissionAttemptAt: j['lastNodeSubmissionAttemptAt'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(j['lastNodeSubmissionAttemptAt'])
|
||||
: null,
|
||||
);
|
||||
|
||||
// Helper to migrate legacy queue items to new state system
|
||||
static UploadState _migrateFromLegacyFields(Map<String, dynamic> j) {
|
||||
final error = j['error'] ?? false;
|
||||
final completing = j['completing'] ?? false;
|
||||
|
||||
if (completing) return UploadState.complete;
|
||||
if (error) return UploadState.error;
|
||||
return UploadState.pending;
|
||||
}
|
||||
|
||||
/// Generate a default changeset comment for legacy uploads that don't have one
|
||||
static String _generateLegacyComment(Map<String, dynamic> j) {
|
||||
final operation = j['operation'] != null
|
||||
? UploadOperation.values[j['operation']]
|
||||
: (j['originalNodeId'] != null ? UploadOperation.modify : UploadOperation.create);
|
||||
|
||||
final profileName = j['profile']?['name'] ?? 'surveillance';
|
||||
|
||||
switch (operation) {
|
||||
case UploadOperation.create:
|
||||
return 'Add $profileName surveillance node';
|
||||
case UploadOperation.modify:
|
||||
return 'Update $profileName surveillance node';
|
||||
case UploadOperation.delete:
|
||||
return 'Delete $profileName surveillance node';
|
||||
case UploadOperation.extract:
|
||||
return 'Extract $profileName surveillance node';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// A suspected surveillance location from the CSV data
|
||||
@@ -35,8 +36,8 @@ class SuspectedLocation {
|
||||
bounds = coordinates.bounds;
|
||||
} catch (e) {
|
||||
// If GeoJSON parsing fails, use default coordinates
|
||||
print('[SuspectedLocation] Failed to parse GeoJSON for ticket $ticketNo: $e');
|
||||
print('[SuspectedLocation] Location string: $locationString');
|
||||
debugPrint('[SuspectedLocation] Failed to parse GeoJSON for ticket $ticketNo: $e');
|
||||
debugPrint('[SuspectedLocation] Location string: $locationString');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +61,7 @@ class SuspectedLocation {
|
||||
// The geoJson IS the geometry object (not wrapped in a 'geometry' property)
|
||||
final coordinates = geoJson['coordinates'] as List?;
|
||||
if (coordinates == null || coordinates.isEmpty) {
|
||||
print('[SuspectedLocation] No coordinates found in GeoJSON');
|
||||
debugPrint('[SuspectedLocation] No coordinates found in GeoJSON');
|
||||
return (centroid: const LatLng(0, 0), bounds: <LatLng>[]);
|
||||
}
|
||||
|
||||
@@ -109,7 +110,7 @@ class SuspectedLocation {
|
||||
}
|
||||
break;
|
||||
default:
|
||||
print('Unsupported geometry type: $type');
|
||||
debugPrint('Unsupported geometry type: $type');
|
||||
}
|
||||
|
||||
if (points.isEmpty) {
|
||||
@@ -127,7 +128,7 @@ class SuspectedLocation {
|
||||
|
||||
return (centroid: centroid, bounds: points);
|
||||
} catch (e) {
|
||||
print('Error extracting coordinates from GeoJSON: $e');
|
||||
debugPrint('Error extracting coordinates from GeoJSON: $e');
|
||||
return (centroid: const LatLng(0, 0), bounds: <LatLng>[]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,35 @@ class TileType {
|
||||
});
|
||||
|
||||
/// Create URL for a specific tile, replacing template variables
|
||||
///
|
||||
/// Supported placeholders:
|
||||
/// - {x}, {y}, {z}: Standard tile coordinates
|
||||
/// - {quadkey}: Bing Maps quadkey format (alternative to x/y/z)
|
||||
/// - {0_3}: Subdomain 0-3 for load balancing
|
||||
/// - {1_4}: Subdomain 1-4 for providers that use 1-based indexing
|
||||
/// - {api_key}: API key placeholder (optional)
|
||||
String getTileUrl(int z, int x, int y, {String? apiKey}) {
|
||||
String url = urlTemplate
|
||||
String url = urlTemplate;
|
||||
|
||||
// Handle Bing Maps quadkey conversion
|
||||
if (url.contains('{quadkey}')) {
|
||||
final quadkey = _convertToQuadkey(x, y, z);
|
||||
url = url.replaceAll('{quadkey}', quadkey);
|
||||
}
|
||||
|
||||
// Handle subdomains for load balancing
|
||||
if (url.contains('{0_3}')) {
|
||||
final subdomain = (x + y) % 4; // 0, 1, 2, 3
|
||||
url = url.replaceAll('{0_3}', subdomain.toString());
|
||||
}
|
||||
|
||||
if (url.contains('{1_4}')) {
|
||||
final subdomain = ((x + y) % 4) + 1; // 1, 2, 3, 4
|
||||
url = url.replaceAll('{1_4}', subdomain.toString());
|
||||
}
|
||||
|
||||
// Standard x/y/z replacement
|
||||
url = url
|
||||
.replaceAll('{z}', z.toString())
|
||||
.replaceAll('{x}', x.toString())
|
||||
.replaceAll('{y}', y.toString());
|
||||
@@ -33,6 +60,19 @@ class TileType {
|
||||
return url;
|
||||
}
|
||||
|
||||
/// Convert x, y, z to Bing Maps quadkey format
|
||||
String _convertToQuadkey(int x, int y, int z) {
|
||||
final quadkey = StringBuffer();
|
||||
for (int i = z; i > 0; i--) {
|
||||
int digit = 0;
|
||||
final mask = 1 << (i - 1);
|
||||
if ((x & mask) != 0) digit++;
|
||||
if ((y & mask) != 0) digit += 2;
|
||||
quadkey.write(digit);
|
||||
}
|
||||
return quadkey.toString();
|
||||
}
|
||||
|
||||
/// Check if this tile type needs an API key
|
||||
bool get requiresApiKey => urlTemplate.contains('{api_key}');
|
||||
|
||||
@@ -161,6 +201,19 @@ class DefaultTileProviders {
|
||||
),
|
||||
],
|
||||
),
|
||||
TileProvider(
|
||||
id: 'bing',
|
||||
name: 'Bing Maps',
|
||||
tileTypes: [
|
||||
TileType(
|
||||
id: 'bing_satellite',
|
||||
name: 'Satellite',
|
||||
urlTemplate: 'https://ecn.t{0_3}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1&n=z',
|
||||
attribution: '© Microsoft Corporation',
|
||||
maxZoom: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
TileProvider(
|
||||
id: 'mapbox',
|
||||
name: 'Mapbox',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../widgets/welcome_dialog.dart';
|
||||
import '../widgets/submission_guide_dialog.dart';
|
||||
|
||||
class AboutScreen extends StatelessWidget {
|
||||
const AboutScreen({super.key});
|
||||
@@ -74,16 +76,8 @@ class AboutScreen extends StatelessWidget {
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Release Notes button
|
||||
Center(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pushNamed(context, '/settings/release-notes');
|
||||
},
|
||||
icon: const Icon(Icons.article_outlined),
|
||||
label: const Text('View Release Notes'),
|
||||
),
|
||||
),
|
||||
// Information dialogs section
|
||||
_buildDialogButtons(context),
|
||||
const SizedBox(height: 24),
|
||||
_buildHelpLinks(context),
|
||||
],
|
||||
@@ -107,75 +101,11 @@ class AboutScreen extends StatelessWidget {
|
||||
_buildLinkText(context, 'Source Code', 'https://github.com/FoggedLens/deflock-app'),
|
||||
const SizedBox(height: 8),
|
||||
_buildLinkText(context, 'Contact', 'https://deflock.me/contact'),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Divider for account management section
|
||||
Divider(
|
||||
color: Theme.of(context).dividerColor.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Account deletion link (less prominent)
|
||||
_buildAccountDeletionLink(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAccountDeletionLink(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _showDeleteAccountDialog(context, locService),
|
||||
child: Text(
|
||||
locService.t('auth.deleteAccount'),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error.withOpacity(0.7),
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteAccountDialog(BuildContext context, LocalizationService locService) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('auth.deleteAccount')),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(locService.t('auth.deleteAccountExplanation')),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
locService.t('auth.deleteAccountWarning'),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(locService.t('actions.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_launchUrl('https://www.openstreetmap.org/account/deletion', context);
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text(locService.t('auth.goToOSM')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLinkText(BuildContext context, String text, String url) {
|
||||
return GestureDetector(
|
||||
@@ -190,4 +120,50 @@ class AboutScreen extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDialogButtons(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Welcome Message button
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const WelcomeDialog(showDontShowAgain: false),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.waving_hand_outlined),
|
||||
label: Text(locService.t('about.showWelcome')),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Submission Guide button
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const SubmissionGuideDialog(showDontShowAgain: false),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.info_outline),
|
||||
label: Text(locService.t('about.showSubmissionGuide')),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Release Notes button
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pushNamed(context, '/settings/release-notes');
|
||||
},
|
||||
icon: const Icon(Icons.article_outlined),
|
||||
label: Text(locService.t('about.viewReleaseNotes')),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import 'settings/sections/max_nodes_section.dart';
|
||||
import 'settings/sections/proximity_alerts_section.dart';
|
||||
import 'settings/sections/suspected_locations_section.dart';
|
||||
import 'settings/sections/tile_provider_section.dart';
|
||||
import 'settings/sections/network_status_section.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class AdvancedSettingsScreen extends StatelessWidget {
|
||||
|
||||
128
lib/screens/coordinators/map_interaction_handler.dart
Normal file
128
lib/screens/coordinators/map_interaction_handler.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../models/suspected_location.dart';
|
||||
import '../../models/search_result.dart';
|
||||
|
||||
/// Handles map interaction events including node taps, suspected location taps,
|
||||
/// and search result selection with appropriate map animations and state updates.
|
||||
class MapInteractionHandler {
|
||||
|
||||
/// Handle node tap with highlighting and map centering
|
||||
void handleNodeTap({
|
||||
required BuildContext context,
|
||||
required OsmNode node,
|
||||
required AnimatedMapController mapController,
|
||||
required Function(int?) onSelectedNodeChanged,
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Disable follow-me when user taps a node
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
// Set the selected node for highlighting
|
||||
onSelectedNodeChanged(node.id);
|
||||
|
||||
// Center the map on the selected node with smooth animation
|
||||
try {
|
||||
mapController.animateTo(
|
||||
dest: node.coord,
|
||||
zoom: mapController.mapController.camera.zoom,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[MapInteractionHandler] Could not center map on node: $e');
|
||||
}
|
||||
|
||||
// Note: Edit session is NOT started here - only when user explicitly presses Edit button
|
||||
}
|
||||
|
||||
/// Handle suspected location tap with selection and highlighting
|
||||
void handleSuspectedLocationTap({
|
||||
required BuildContext context,
|
||||
required SuspectedLocation location,
|
||||
required AnimatedMapController mapController,
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
debugPrint('[MapInteractionHandler] Suspected location tapped: ${location.ticketNo}');
|
||||
|
||||
// Disable follow-me when user taps a suspected location
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
// Select the suspected location for highlighting
|
||||
appState.selectSuspectedLocation(location);
|
||||
|
||||
// Center the map on the suspected location
|
||||
try {
|
||||
mapController.animateTo(
|
||||
dest: location.centroid,
|
||||
zoom: mapController.mapController.camera.zoom.clamp(16.0, 18.0), // Zoom in if needed
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[MapInteractionHandler] Could not center map on suspected location: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle search result selection with map animation and routing setup
|
||||
void handleSearchResultSelection({
|
||||
required BuildContext context,
|
||||
required SearchResult result,
|
||||
required AnimatedMapController mapController,
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
debugPrint('[MapInteractionHandler] Search result selected: ${result.displayName}');
|
||||
|
||||
// Disable follow-me to prevent interference with selection
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
// Update app state with the selection
|
||||
appState.selectSearchResult(result);
|
||||
|
||||
// Animate to the selected location
|
||||
try {
|
||||
mapController.animateTo(
|
||||
dest: result.coordinates,
|
||||
zoom: 16.0, // Good zoom level for search results
|
||||
duration: const Duration(milliseconds: 600),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[MapInteractionHandler] Could not animate to search result: $e');
|
||||
// Fallback to immediate positioning
|
||||
try {
|
||||
mapController.mapController.move(result.coordinates, 16.0);
|
||||
} catch (_) {
|
||||
debugPrint('[MapInteractionHandler] Could not move to search result');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear selected node highlighting
|
||||
void clearSelectedNode({
|
||||
required Function(int?) onSelectedNodeChanged,
|
||||
}) {
|
||||
onSelectedNodeChanged(null);
|
||||
}
|
||||
|
||||
/// Handle user gesture on map (clears selections)
|
||||
void handleUserGesture({
|
||||
required BuildContext context,
|
||||
required Function(int?) onSelectedNodeChanged,
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Clear selected node highlighting
|
||||
onSelectedNodeChanged(null);
|
||||
|
||||
// Clear suspected location selection
|
||||
appState.clearSuspectedLocationSelection();
|
||||
}
|
||||
}
|
||||
243
lib/screens/coordinators/navigation_coordinator.dart
Normal file
243
lib/screens/coordinators/navigation_coordinator.dart
Normal file
@@ -0,0 +1,243 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../app_state.dart' show AppState, FollowMeMode;
|
||||
import '../../widgets/map_view.dart';
|
||||
import '../../dev_config.dart';
|
||||
|
||||
/// Coordinates all navigation and routing functionality including route planning,
|
||||
/// map centering, zoom management, and route visualization.
|
||||
class NavigationCoordinator {
|
||||
FollowMeMode? _previousFollowMeMode; // Track follow-me mode before overview
|
||||
|
||||
/// Start a route with automatic follow-me detection and appropriate centering
|
||||
void startRoute({
|
||||
required BuildContext context,
|
||||
required AnimatedMapController mapController,
|
||||
required GlobalKey<MapViewState>? mapViewKey,
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Get user location and check if we should auto-enable follow-me
|
||||
LatLng? userLocation;
|
||||
bool enableFollowMe = false;
|
||||
|
||||
try {
|
||||
userLocation = mapViewKey?.currentState?.getUserLocation();
|
||||
if (userLocation != null && appState.shouldAutoEnableFollowMe(userLocation)) {
|
||||
debugPrint('[NavigationCoordinator] Auto-enabling follow-me mode - user within 1km of start');
|
||||
appState.setFollowMeMode(FollowMeMode.follow);
|
||||
enableFollowMe = true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[NavigationCoordinator] Could not get user location for auto follow-me: $e');
|
||||
}
|
||||
|
||||
// Start the route
|
||||
appState.startRoute();
|
||||
|
||||
// Zoom to level 14 and center appropriately
|
||||
_zoomAndCenterForRoute(
|
||||
mapController: mapController,
|
||||
followMeEnabled: enableFollowMe,
|
||||
userLocation: userLocation,
|
||||
routeStart: appState.routeStart,
|
||||
);
|
||||
}
|
||||
|
||||
/// Resume a route with appropriate centering
|
||||
void resumeRoute({
|
||||
required BuildContext context,
|
||||
required AnimatedMapController mapController,
|
||||
required GlobalKey<MapViewState>? mapViewKey,
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Hide the overview
|
||||
appState.hideRouteOverview();
|
||||
|
||||
// Get user location to determine centering and follow-me behavior
|
||||
LatLng? userLocation;
|
||||
try {
|
||||
userLocation = mapViewKey?.currentState?.getUserLocation();
|
||||
} catch (e) {
|
||||
debugPrint('[NavigationCoordinator] Could not get user location for route resume: $e');
|
||||
}
|
||||
|
||||
// Determine if user is near the route path
|
||||
bool isNearRoute = false;
|
||||
if (userLocation != null && appState.routePath != null) {
|
||||
isNearRoute = _isUserNearRoute(userLocation, appState.routePath!);
|
||||
}
|
||||
|
||||
// Choose center point and follow-me behavior
|
||||
LatLng centerPoint;
|
||||
bool shouldEnableFollowMe = false;
|
||||
|
||||
if (isNearRoute && userLocation != null) {
|
||||
// User is near route - center on GPS and enable follow-me
|
||||
centerPoint = userLocation;
|
||||
shouldEnableFollowMe = true;
|
||||
debugPrint('[NavigationCoordinator] User near route - centering on GPS with follow-me');
|
||||
} else {
|
||||
// User far from route or no GPS - center on route start
|
||||
centerPoint = appState.routeStart ?? userLocation ?? LatLng(0, 0);
|
||||
shouldEnableFollowMe = false;
|
||||
debugPrint('[NavigationCoordinator] User far from route - centering on start without follow-me');
|
||||
}
|
||||
|
||||
// Apply the centering and zoom
|
||||
try {
|
||||
mapController.animateTo(
|
||||
dest: centerPoint,
|
||||
zoom: kResumeNavigationZoomLevel,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[NavigationCoordinator] Could not animate to resume location: $e');
|
||||
}
|
||||
|
||||
// Set follow-me mode based on proximity
|
||||
if (shouldEnableFollowMe) {
|
||||
// Restore previous follow-me mode if user is near route
|
||||
final modeToRestore = _previousFollowMeMode ?? FollowMeMode.follow;
|
||||
appState.setFollowMeMode(modeToRestore);
|
||||
debugPrint('[NavigationCoordinator] Restored follow-me mode: $modeToRestore');
|
||||
} else {
|
||||
// Keep follow-me off if user is far from route
|
||||
debugPrint('[NavigationCoordinator] Keeping follow-me off - user far from route');
|
||||
}
|
||||
|
||||
// Clear stored follow-me mode
|
||||
_previousFollowMeMode = null;
|
||||
}
|
||||
|
||||
/// Handle navigation button press with route overview logic
|
||||
void handleNavigationButtonPress({
|
||||
required BuildContext context,
|
||||
required AnimatedMapController mapController,
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
if (appState.showRouteButton) {
|
||||
// Route button - show route overview and zoom to show route
|
||||
// Store current follow-me mode and disable it to prevent unexpected map jumps during overview
|
||||
_previousFollowMeMode = appState.followMeMode;
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
appState.showRouteOverview();
|
||||
zoomToShowFullRoute(appState: appState, mapController: mapController);
|
||||
} else {
|
||||
// Search button - toggle search mode
|
||||
if (appState.isInSearchMode) {
|
||||
// Exit search mode
|
||||
appState.clearSearchResults();
|
||||
} else {
|
||||
// Enter search mode
|
||||
try {
|
||||
final center = mapController.mapController.camera.center;
|
||||
final viewbox = mapController.mapController.camera.visibleBounds;
|
||||
appState.enterSearchMode(center, viewbox: viewbox);
|
||||
} catch (e) {
|
||||
debugPrint('[NavigationCoordinator] Could not get map center for search: $e');
|
||||
// Fallback to default location
|
||||
appState.enterSearchMode(LatLng(37.7749, -122.4194));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zoom to show the full route between start and end points
|
||||
void zoomToShowFullRoute({
|
||||
required AppState appState,
|
||||
required AnimatedMapController mapController,
|
||||
}) {
|
||||
if (appState.routeStart == null || appState.routeEnd == null) return;
|
||||
|
||||
try {
|
||||
// Calculate the bounds of the route
|
||||
final start = appState.routeStart!;
|
||||
final end = appState.routeEnd!;
|
||||
|
||||
// Find the center point between start and end
|
||||
final centerLat = (start.latitude + end.latitude) / 2;
|
||||
final centerLng = (start.longitude + end.longitude) / 2;
|
||||
final center = LatLng(centerLat, centerLng);
|
||||
|
||||
// Calculate distance between points to determine appropriate zoom
|
||||
final distance = const Distance().as(LengthUnit.Meter, start, end);
|
||||
double zoom;
|
||||
if (distance < 500) {
|
||||
zoom = 16.0;
|
||||
} else if (distance < 2000) {
|
||||
zoom = 14.0;
|
||||
} else if (distance < 10000) {
|
||||
zoom = 12.0;
|
||||
} else {
|
||||
zoom = 10.0;
|
||||
}
|
||||
|
||||
debugPrint('[NavigationCoordinator] Zooming to show full route: ${distance.toInt()}m, zoom $zoom');
|
||||
|
||||
mapController.animateTo(
|
||||
dest: center,
|
||||
zoom: zoom,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[NavigationCoordinator] Could not zoom to show full route: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if user location is near the route path
|
||||
bool _isUserNearRoute(LatLng userLocation, List<LatLng> routePath) {
|
||||
if (routePath.isEmpty) return false;
|
||||
|
||||
// Check distance to each point in the route path
|
||||
for (final routePoint in routePath) {
|
||||
final distance = const Distance().as(LengthUnit.Meter, userLocation, routePoint);
|
||||
if (distance <= kRouteProximityThresholdMeters) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Internal method to zoom and center for route start/resume
|
||||
void _zoomAndCenterForRoute({
|
||||
required AnimatedMapController mapController,
|
||||
required bool followMeEnabled,
|
||||
required LatLng? userLocation,
|
||||
required LatLng? routeStart,
|
||||
}) {
|
||||
try {
|
||||
LatLng centerLocation;
|
||||
|
||||
if (followMeEnabled && userLocation != null) {
|
||||
// Center on user if follow-me is enabled
|
||||
centerLocation = userLocation;
|
||||
debugPrint('[NavigationCoordinator] Centering on user location for route start');
|
||||
} else if (routeStart != null) {
|
||||
// Center on start pin if user is far away or no GPS
|
||||
centerLocation = routeStart;
|
||||
debugPrint('[NavigationCoordinator] Centering on route start pin');
|
||||
} else {
|
||||
debugPrint('[NavigationCoordinator] No valid location to center on');
|
||||
return;
|
||||
}
|
||||
|
||||
// Animate to zoom 14 and center location
|
||||
mapController.animateTo(
|
||||
dest: centerLocation,
|
||||
zoom: 14.0,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[NavigationCoordinator] Could not zoom/center for route: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
274
lib/screens/coordinators/sheet_coordinator.dart
Normal file
274
lib/screens/coordinators/sheet_coordinator.dart
Normal file
@@ -0,0 +1,274 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../../widgets/add_node_sheet.dart';
|
||||
import '../../widgets/edit_node_sheet.dart';
|
||||
import '../../widgets/navigation_sheet.dart';
|
||||
import '../../widgets/measured_sheet.dart';
|
||||
|
||||
/// Coordinates all bottom sheet operations including opening, closing, height tracking,
|
||||
/// and sheet-related validation logic.
|
||||
class SheetCoordinator {
|
||||
// Track sheet heights for map positioning
|
||||
double _addSheetHeight = 0.0;
|
||||
double _editSheetHeight = 0.0;
|
||||
double _tagSheetHeight = 0.0;
|
||||
double _navigationSheetHeight = 0.0;
|
||||
|
||||
// Track sheet state for auto-open logic
|
||||
bool _editSheetShown = false;
|
||||
bool _navigationSheetShown = false;
|
||||
|
||||
// Flag to prevent map bounce when transitioning from tag sheet to edit sheet
|
||||
bool _transitioningToEdit = false;
|
||||
|
||||
// Follow-me state restoration
|
||||
FollowMeMode? _followMeModeBeforeSheet;
|
||||
|
||||
// Getters for accessing heights
|
||||
double get addSheetHeight => _addSheetHeight;
|
||||
double get editSheetHeight => _editSheetHeight;
|
||||
double get tagSheetHeight => _tagSheetHeight;
|
||||
double get navigationSheetHeight => _navigationSheetHeight;
|
||||
bool get editSheetShown => _editSheetShown;
|
||||
bool get navigationSheetShown => _navigationSheetShown;
|
||||
bool get transitioningToEdit => _transitioningToEdit;
|
||||
|
||||
/// Get the currently active sheet height for map positioning
|
||||
double get activeSheetHeight {
|
||||
if (_addSheetHeight > 0) return _addSheetHeight;
|
||||
if (_editSheetHeight > 0) return _editSheetHeight;
|
||||
if (_navigationSheetHeight > 0) return _navigationSheetHeight;
|
||||
return _tagSheetHeight;
|
||||
}
|
||||
|
||||
/// Update sheet state tracking
|
||||
void setEditSheetShown(bool shown) => _editSheetShown = shown;
|
||||
void setNavigationSheetShown(bool shown) => _navigationSheetShown = shown;
|
||||
void setTransitioningToEdit(bool transitioning) => _transitioningToEdit = transitioning;
|
||||
|
||||
/// Open the add node sheet with validation and setup
|
||||
void openAddNodeSheet({
|
||||
required BuildContext context,
|
||||
required GlobalKey<ScaffoldState> scaffoldKey,
|
||||
required AnimatedMapController mapController,
|
||||
required bool isNodeLimitActive,
|
||||
required VoidCallback onStateChanged,
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Check minimum zoom level before opening sheet
|
||||
final currentZoom = mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForNodeEditingSheets) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('editNode.zoomInRequiredMessage',
|
||||
params: [kMinZoomForNodeEditingSheets.toString()])
|
||||
),
|
||||
duration: const Duration(seconds: 4),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if node limit is active and warn user
|
||||
if (isNodeLimitActive) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('nodeLimitIndicator.editingDisabledMessage')
|
||||
),
|
||||
duration: const Duration(seconds: 4),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save current follow-me mode and disable it while sheet is open
|
||||
_followMeModeBeforeSheet = appState.followMeMode;
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
appState.startAddSession();
|
||||
final session = appState.session!; // guaranteed non‑null now
|
||||
|
||||
final controller = scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom, // Only safe area, no keyboard
|
||||
),
|
||||
child: MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
_addSheetHeight = height + MediaQuery.of(context).padding.bottom;
|
||||
onStateChanged();
|
||||
},
|
||||
child: AddNodeSheet(session: session),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Reset height when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
_addSheetHeight = 0.0;
|
||||
onStateChanged();
|
||||
|
||||
// Handle dismissal by canceling session if still active
|
||||
if (appState.session != null) {
|
||||
debugPrint('[SheetCoordinator] AddNodeSheet dismissed - canceling session');
|
||||
appState.cancelSession();
|
||||
}
|
||||
|
||||
// Restore follow-me mode that was active before sheet opened
|
||||
_restoreFollowMeMode(appState);
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the edit node sheet with map centering
|
||||
void openEditNodeSheet({
|
||||
required BuildContext context,
|
||||
required GlobalKey<ScaffoldState> scaffoldKey,
|
||||
required AnimatedMapController mapController,
|
||||
required VoidCallback onStateChanged,
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Save current follow-me mode and disable it while sheet is open
|
||||
_followMeModeBeforeSheet = appState.followMeMode;
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
final session = appState.editSession!; // should be non-null when this is called
|
||||
|
||||
// Center map on the node being edited
|
||||
try {
|
||||
mapController.animateTo(
|
||||
dest: session.originalNode.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(session.originalNode.coord, mapController.mapController.camera.zoom);
|
||||
} catch (_) {
|
||||
// Controller really not ready, skip centering
|
||||
}
|
||||
}
|
||||
|
||||
// Set transition flag to prevent map bounce
|
||||
_transitioningToEdit = true;
|
||||
|
||||
final controller = scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
final fullHeight = height + MediaQuery.of(context).viewInsets.bottom + MediaQuery.of(context).padding.bottom;
|
||||
_editSheetHeight = fullHeight;
|
||||
onStateChanged();
|
||||
},
|
||||
child: EditNodeSheet(session: session),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Reset height and transition flag when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
_editSheetHeight = 0.0;
|
||||
_transitioningToEdit = false;
|
||||
onStateChanged();
|
||||
|
||||
// Handle dismissal by canceling session if still active
|
||||
if (appState.editSession != null) {
|
||||
debugPrint('[SheetCoordinator] EditNodeSheet dismissed - canceling edit session');
|
||||
appState.cancelEditSession();
|
||||
}
|
||||
|
||||
// Restore follow-me mode that was active before sheet opened
|
||||
_restoreFollowMeMode(appState);
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the navigation sheet for search/routing
|
||||
void openNavigationSheet({
|
||||
required BuildContext context,
|
||||
required GlobalKey<ScaffoldState> scaffoldKey,
|
||||
required VoidCallback onStateChanged,
|
||||
required VoidCallback onStartRoute,
|
||||
required VoidCallback onResumeRoute,
|
||||
}) {
|
||||
final controller = scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
final fullHeight = height + MediaQuery.of(context).viewInsets.bottom + MediaQuery.of(context).padding.bottom;
|
||||
_navigationSheetHeight = fullHeight;
|
||||
onStateChanged();
|
||||
},
|
||||
child: NavigationSheet(
|
||||
onStartRoute: onStartRoute,
|
||||
onResumeRoute: onResumeRoute,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Reset height when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
_navigationSheetHeight = 0.0;
|
||||
onStateChanged();
|
||||
|
||||
// Handle different dismissal scenarios (from original HomeScreen logic)
|
||||
if (context.mounted) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
if (appState.isSettingSecondPoint) {
|
||||
// If user dismisses sheet while setting second point, cancel everything
|
||||
debugPrint('[SheetCoordinator] Sheet dismissed during second point selection - canceling navigation');
|
||||
appState.cancelNavigation();
|
||||
} else if (appState.isInRouteMode && appState.showingOverview) {
|
||||
// If we're in route active mode and showing overview, just hide the overview
|
||||
debugPrint('[SheetCoordinator] Sheet dismissed during route overview - hiding overview');
|
||||
appState.hideRouteOverview();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Update tag sheet height (called externally)
|
||||
void updateTagSheetHeight(double height, VoidCallback onStateChanged) {
|
||||
debugPrint('[SheetCoordinator] Updating tag sheet height: $_tagSheetHeight -> $height');
|
||||
_tagSheetHeight = height;
|
||||
onStateChanged();
|
||||
}
|
||||
|
||||
/// Reset tag sheet height
|
||||
void resetTagSheetHeight(VoidCallback onStateChanged) {
|
||||
debugPrint('[SheetCoordinator] Resetting tag sheet height from: $_tagSheetHeight');
|
||||
_tagSheetHeight = 0.0;
|
||||
onStateChanged();
|
||||
}
|
||||
|
||||
/// Restore the follow-me mode that was active before opening a node sheet
|
||||
void _restoreFollowMeMode(AppState appState) {
|
||||
if (_followMeModeBeforeSheet != null) {
|
||||
debugPrint('[SheetCoordinator] Restoring follow-me mode: $_followMeModeBeforeSheet');
|
||||
appState.setFollowMeMode(_followMeModeBeforeSheet!);
|
||||
_followMeModeBeforeSheet = null; // Clear stored state
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if any node editing/viewing sheet is currently open
|
||||
bool get hasActiveNodeSheet => _addSheetHeight > 0 || _editSheetHeight > 0 || _tagSheetHeight > 0;
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
@@ -10,13 +8,9 @@ import '../dev_config.dart';
|
||||
import '../widgets/map_view.dart';
|
||||
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 '../widgets/navigation_sheet.dart';
|
||||
import '../widgets/search_bar.dart';
|
||||
import '../widgets/suspected_location_sheet.dart';
|
||||
import '../widgets/welcome_dialog.dart';
|
||||
@@ -25,6 +19,10 @@ import '../models/osm_node.dart';
|
||||
import '../models/suspected_location.dart';
|
||||
import '../models/search_result.dart';
|
||||
import '../services/changelog_service.dart';
|
||||
import 'coordinators/sheet_coordinator.dart';
|
||||
import 'coordinators/navigation_coordinator.dart';
|
||||
import 'coordinators/map_interaction_handler.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
@@ -37,17 +35,14 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
final GlobalKey<MapViewState> _mapViewKey = GlobalKey<MapViewState>();
|
||||
late final AnimatedMapController _mapController;
|
||||
bool _editSheetShown = false;
|
||||
bool _navigationSheetShown = false;
|
||||
|
||||
// Track sheet heights for map positioning
|
||||
double _addSheetHeight = 0.0;
|
||||
double _editSheetHeight = 0.0;
|
||||
double _tagSheetHeight = 0.0;
|
||||
double _navigationSheetHeight = 0.0;
|
||||
// Coordinators for managing different aspects of the home screen
|
||||
late final SheetCoordinator _sheetCoordinator;
|
||||
late final NavigationCoordinator _navigationCoordinator;
|
||||
late final MapInteractionHandler _mapInteractionHandler;
|
||||
|
||||
// Flag to prevent map bounce when transitioning from tag sheet to edit sheet
|
||||
bool _transitioningToEdit = false;
|
||||
// Track node limit state for button disabling
|
||||
bool _isNodeLimitActive = false;
|
||||
|
||||
// Track selected node for highlighting
|
||||
int? _selectedNodeId;
|
||||
@@ -59,6 +54,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mapController = AnimatedMapController(vsync: this);
|
||||
_sheetCoordinator = SheetCoordinator();
|
||||
_navigationCoordinator = NavigationCoordinator();
|
||||
_mapInteractionHandler = MapInteractionHandler();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -102,73 +100,21 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
|
||||
void _openAddNodeSheet() {
|
||||
final appState = context.read<AppState>();
|
||||
// Disable follow-me when adding a camera so the map doesn't jump around
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
appState.startAddSession();
|
||||
final session = appState.session!; // guaranteed non‑null now
|
||||
|
||||
final controller = _scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom, // Only safe area, no keyboard
|
||||
),
|
||||
child: MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
setState(() {
|
||||
_addSheetHeight = height + MediaQuery.of(context).padding.bottom;
|
||||
});
|
||||
},
|
||||
child: AddNodeSheet(session: session),
|
||||
),
|
||||
),
|
||||
_sheetCoordinator.openAddNodeSheet(
|
||||
context: context,
|
||||
scaffoldKey: _scaffoldKey,
|
||||
mapController: _mapController,
|
||||
isNodeLimitActive: _isNodeLimitActive,
|
||||
onStateChanged: () => setState(() {}),
|
||||
);
|
||||
|
||||
// Reset height when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
setState(() {
|
||||
_addSheetHeight = 0.0;
|
||||
});
|
||||
|
||||
// Handle dismissal by canceling session if still active
|
||||
final appState = context.read<AppState>();
|
||||
if (appState.session != null) {
|
||||
debugPrint('[HomeScreen] AddNodeSheet dismissed - canceling session');
|
||||
appState.cancelSession();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _openEditNodeSheet() {
|
||||
final appState = context.read<AppState>();
|
||||
// Disable follow-me when editing a camera so the map doesn't jump around
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
final session = appState.editSession!; // should be non-null when this is called
|
||||
|
||||
// Center map on the node being edited (same animation as openNodeTagSheet)
|
||||
try {
|
||||
_mapController.animateTo(
|
||||
dest: session.originalNode.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(session.originalNode.coord, _mapController.mapController.camera.zoom);
|
||||
} catch (_) {
|
||||
// Controller really not ready, skip centering
|
||||
}
|
||||
}
|
||||
|
||||
// Set transition flag to prevent map bounce
|
||||
_transitioningToEdit = true;
|
||||
// Set transition flag BEFORE closing tag sheet to prevent map bounce
|
||||
_sheetCoordinator.setTransitioningToEdit(true);
|
||||
|
||||
// Close any existing tag sheet first
|
||||
if (_tagSheetHeight > 0) {
|
||||
if (_sheetCoordinator.tagSheetHeight > 0) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@@ -176,84 +122,58 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
if (!mounted) return;
|
||||
|
||||
final controller = _scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom, // Only safe area, no keyboard
|
||||
),
|
||||
child: MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
setState(() {
|
||||
_editSheetHeight = height + MediaQuery.of(context).padding.bottom;
|
||||
// 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),
|
||||
),
|
||||
),
|
||||
_sheetCoordinator.openEditNodeSheet(
|
||||
context: context,
|
||||
scaffoldKey: _scaffoldKey,
|
||||
mapController: _mapController,
|
||||
onStateChanged: () {
|
||||
setState(() {
|
||||
// Clear tag sheet height and selected node when transitioning
|
||||
if (_sheetCoordinator.editSheetHeight > 0 && _sheetCoordinator.transitioningToEdit) {
|
||||
_sheetCoordinator.resetTagSheetHeight(() {});
|
||||
_selectedNodeId = null; // Clear selection when moving to edit
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Reset height when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
setState(() {
|
||||
_editSheetHeight = 0.0;
|
||||
_transitioningToEdit = false;
|
||||
});
|
||||
|
||||
// Handle dismissal by canceling edit session if still active
|
||||
final appState = context.read<AppState>();
|
||||
if (appState.editSession != null) {
|
||||
debugPrint('[HomeScreen] EditNodeSheet dismissed - canceling edit session');
|
||||
appState.cancelEditSession();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _openNavigationSheet() {
|
||||
final controller = _scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom, // Only safe area, no keyboard
|
||||
),
|
||||
child: MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
setState(() {
|
||||
_navigationSheetHeight = height + MediaQuery.of(context).padding.bottom;
|
||||
});
|
||||
},
|
||||
child: NavigationSheet(
|
||||
onStartRoute: _onStartRoute,
|
||||
onResumeRoute: _onResumeRoute,
|
||||
),
|
||||
),
|
||||
),
|
||||
_sheetCoordinator.openNavigationSheet(
|
||||
context: context,
|
||||
scaffoldKey: _scaffoldKey,
|
||||
onStateChanged: () => setState(() {}),
|
||||
onStartRoute: _onStartRoute,
|
||||
onResumeRoute: _onResumeRoute,
|
||||
);
|
||||
}
|
||||
|
||||
// Request location permission on first launch
|
||||
Future<void> _requestLocationPermissionIfFirstLaunch() async {
|
||||
if (!mounted) return;
|
||||
|
||||
// Reset height when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
setState(() {
|
||||
_navigationSheetHeight = 0.0;
|
||||
});
|
||||
try {
|
||||
// Only request on first launch or if user has never seen welcome
|
||||
final isFirstLaunch = await ChangelogService().isFirstLaunch();
|
||||
final hasSeenWelcome = await ChangelogService().hasSeenWelcome();
|
||||
|
||||
// Handle different dismissal scenarios
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
if (appState.isSettingSecondPoint) {
|
||||
// If user dismisses sheet while setting second point, cancel everything
|
||||
debugPrint('[HomeScreen] Sheet dismissed during second point selection - canceling navigation');
|
||||
appState.cancelNavigation();
|
||||
} else if (appState.isInRouteMode && appState.showingOverview) {
|
||||
// If we're in route active mode and showing overview, just hide the overview
|
||||
debugPrint('[HomeScreen] Sheet dismissed during route overview - hiding overview');
|
||||
appState.hideRouteOverview();
|
||||
if (isFirstLaunch || !hasSeenWelcome) {
|
||||
// Check if location services are enabled
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
debugPrint('[HomeScreen] Location services disabled - skipping permission request');
|
||||
return;
|
||||
}
|
||||
|
||||
// Request location permission (this will show system dialog if needed)
|
||||
final permission = await Geolocator.requestPermission();
|
||||
debugPrint('[HomeScreen] First launch location permission result: $permission');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Silently handle errors to avoid breaking the app launch
|
||||
debugPrint('[HomeScreen] Error requesting location permission: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for and display welcome/changelog popup
|
||||
@@ -265,8 +185,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
|
||||
// Run any needed migrations first
|
||||
final versionsNeedingMigration = await ChangelogService().getVersionsNeedingMigration();
|
||||
if (!mounted) return;
|
||||
for (final version in versionsNeedingMigration) {
|
||||
await ChangelogService().runMigration(version, appState);
|
||||
await ChangelogService().runMigration(version, appState, context);
|
||||
}
|
||||
|
||||
// Determine what popup to show
|
||||
@@ -281,10 +202,15 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const WelcomeDialog(),
|
||||
);
|
||||
|
||||
// Request location permission right after welcome dialog on first launch
|
||||
if (!mounted) return;
|
||||
await _requestLocationPermissionIfFirstLaunch();
|
||||
break;
|
||||
|
||||
case PopupType.changelog:
|
||||
final changelogContent = await ChangelogService().getChangelogContentForDisplay();
|
||||
if (!mounted) return;
|
||||
if (changelogContent != null) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
@@ -316,208 +242,59 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
|
||||
void _onStartRoute() {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Get user location and check if we should auto-enable follow-me
|
||||
LatLng? userLocation;
|
||||
bool enableFollowMe = false;
|
||||
|
||||
try {
|
||||
userLocation = _mapViewKey.currentState?.getUserLocation();
|
||||
if (userLocation != null && appState.shouldAutoEnableFollowMe(userLocation)) {
|
||||
debugPrint('[HomeScreen] Auto-enabling follow-me mode - user within 1km of start');
|
||||
appState.setFollowMeMode(FollowMeMode.follow);
|
||||
enableFollowMe = true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[HomeScreen] Could not get user location for auto follow-me: $e');
|
||||
}
|
||||
|
||||
// Start the route
|
||||
appState.startRoute();
|
||||
|
||||
// Zoom to level 14 and center appropriately
|
||||
_zoomAndCenterForRoute(enableFollowMe, userLocation, appState.routeStart);
|
||||
}
|
||||
|
||||
void _zoomAndCenterForRoute(bool followMeEnabled, LatLng? userLocation, LatLng? routeStart) {
|
||||
try {
|
||||
LatLng centerLocation;
|
||||
|
||||
if (followMeEnabled && userLocation != null) {
|
||||
// Center on user if follow-me is enabled
|
||||
centerLocation = userLocation;
|
||||
debugPrint('[HomeScreen] Centering on user location for route start');
|
||||
} else if (routeStart != null) {
|
||||
// Center on start pin if user is far away or no GPS
|
||||
centerLocation = routeStart;
|
||||
debugPrint('[HomeScreen] Centering on route start pin');
|
||||
} else {
|
||||
debugPrint('[HomeScreen] No valid location to center on');
|
||||
return;
|
||||
}
|
||||
|
||||
// Animate to zoom 14 and center location
|
||||
_mapController.animateTo(
|
||||
dest: centerLocation,
|
||||
zoom: 14.0,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[HomeScreen] Could not zoom/center for route: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _onResumeRoute() {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Hide the overview
|
||||
appState.hideRouteOverview();
|
||||
|
||||
// Zoom and center for resumed route
|
||||
// For resume, we always center on user if GPS is available, otherwise start pin
|
||||
LatLng? userLocation;
|
||||
try {
|
||||
userLocation = _mapViewKey.currentState?.getUserLocation();
|
||||
} catch (e) {
|
||||
debugPrint('[HomeScreen] Could not get user location for route resume: $e');
|
||||
}
|
||||
|
||||
_zoomAndCenterForRoute(
|
||||
appState.followMeMode != FollowMeMode.off, // Use current follow-me state
|
||||
userLocation,
|
||||
appState.routeStart
|
||||
_navigationCoordinator.startRoute(
|
||||
context: context,
|
||||
mapController: _mapController,
|
||||
mapViewKey: _mapViewKey,
|
||||
);
|
||||
}
|
||||
|
||||
void _zoomToShowFullRoute(AppState appState) {
|
||||
if (appState.routeStart == null || appState.routeEnd == null) return;
|
||||
|
||||
try {
|
||||
// Calculate the bounds of the route
|
||||
final start = appState.routeStart!;
|
||||
final end = appState.routeEnd!;
|
||||
|
||||
// Find the center point between start and end
|
||||
final centerLat = (start.latitude + end.latitude) / 2;
|
||||
final centerLng = (start.longitude + end.longitude) / 2;
|
||||
final center = LatLng(centerLat, centerLng);
|
||||
|
||||
// Calculate distance between points to determine appropriate zoom
|
||||
final distance = const Distance().as(LengthUnit.Meter, start, end);
|
||||
double zoom;
|
||||
if (distance < 500) {
|
||||
zoom = 16.0;
|
||||
} else if (distance < 2000) {
|
||||
zoom = 14.0;
|
||||
} else if (distance < 10000) {
|
||||
zoom = 12.0;
|
||||
} else {
|
||||
zoom = 10.0;
|
||||
}
|
||||
|
||||
debugPrint('[HomeScreen] Zooming to show full route - distance: ${distance.toStringAsFixed(0)}m, zoom: $zoom');
|
||||
|
||||
_mapController.animateTo(
|
||||
dest: center,
|
||||
zoom: zoom,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[HomeScreen] Could not zoom to show full route: $e');
|
||||
}
|
||||
void _onResumeRoute() {
|
||||
_navigationCoordinator.resumeRoute(
|
||||
context: context,
|
||||
mapController: _mapController,
|
||||
mapViewKey: _mapViewKey,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
void _onNavigationButtonPressed() {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
debugPrint('[HomeScreen] Navigation button pressed - showRouteButton: ${appState.showRouteButton}, navigationMode: ${appState.navigationMode}');
|
||||
|
||||
if (appState.showRouteButton) {
|
||||
// Route button - show route overview and zoom to show route
|
||||
debugPrint('[HomeScreen] Showing route overview');
|
||||
appState.showRouteOverview();
|
||||
|
||||
// Zoom out a bit to show the full route when viewing overview
|
||||
_zoomToShowFullRoute(appState);
|
||||
_navigationCoordinator.zoomToShowFullRoute(
|
||||
appState: appState,
|
||||
mapController: _mapController,
|
||||
);
|
||||
} else {
|
||||
// Search button
|
||||
if (appState.offlineMode) {
|
||||
// Show offline snackbar instead of entering search mode
|
||||
debugPrint('[HomeScreen] Search disabled - offline mode');
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Search not available while offline'),
|
||||
duration: const Duration(seconds: 3),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Enter search mode normally
|
||||
debugPrint('[HomeScreen] Entering search mode');
|
||||
try {
|
||||
final mapCenter = _mapController.mapController.camera.center;
|
||||
debugPrint('[HomeScreen] Map center: $mapCenter');
|
||||
appState.enterSearchMode(mapCenter);
|
||||
} catch (e) {
|
||||
// Controller not ready, use fallback location
|
||||
debugPrint('[HomeScreen] Map controller not ready: $e, using fallback');
|
||||
appState.enterSearchMode(LatLng(37.7749, -122.4194));
|
||||
}
|
||||
}
|
||||
// Search/navigation button - delegate to coordinator
|
||||
_navigationCoordinator.handleNavigationButtonPress(
|
||||
context: context,
|
||||
mapController: _mapController,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchResultSelected(SearchResult result) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Update navigation state with selected result
|
||||
appState.selectSearchResult(result);
|
||||
|
||||
// Jump to the search result location
|
||||
try {
|
||||
_mapController.animateTo(
|
||||
dest: result.coordinates,
|
||||
zoom: 16.0, // Good zoom level for viewing the area
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (_) {
|
||||
// Map controller not ready, fallback to immediate move
|
||||
try {
|
||||
_mapController.mapController.move(result.coordinates, 16.0);
|
||||
} catch (_) {
|
||||
debugPrint('[HomeScreen] Could not move to search result: ${result.coordinates}');
|
||||
}
|
||||
}
|
||||
_mapInteractionHandler.handleSearchResultSelection(
|
||||
context: context,
|
||||
result: result,
|
||||
mapController: _mapController,
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the map interaction (centering and follow-me disable)
|
||||
_mapInteractionHandler.handleNodeTap(
|
||||
context: context,
|
||||
node: node,
|
||||
mapController: _mapController,
|
||||
onSelectedNodeChanged: (id) => setState(() => _selectedNodeId = id),
|
||||
);
|
||||
|
||||
final controller = _scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
@@ -525,13 +302,29 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
),
|
||||
child: MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
setState(() {
|
||||
_tagSheetHeight = height + MediaQuery.of(context).padding.bottom;
|
||||
});
|
||||
_sheetCoordinator.updateTagSheetHeight(
|
||||
height + MediaQuery.of(context).padding.bottom,
|
||||
() => setState(() {}),
|
||||
);
|
||||
},
|
||||
child: NodeTagSheet(
|
||||
node: node,
|
||||
isNodeLimitActive: _isNodeLimitActive,
|
||||
onEditPressed: () {
|
||||
// Check minimum zoom level before starting edit session
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForNodeEditingSheets) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('editNode.zoomInRequiredMessage',
|
||||
params: [kMinZoomForNodeEditingSheets.toString()])
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final appState = context.read<AppState>();
|
||||
appState.startEditSession(node);
|
||||
// This will trigger _openEditNodeSheet via the existing auto-show logic
|
||||
@@ -543,37 +336,22 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
|
||||
// Reset height and selection when sheet is dismissed (unless transitioning to edit)
|
||||
controller.closed.then((_) {
|
||||
if (!_transitioningToEdit) {
|
||||
setState(() {
|
||||
_tagSheetHeight = 0.0;
|
||||
_selectedNodeId = null; // Clear selection
|
||||
});
|
||||
if (!_sheetCoordinator.transitioningToEdit) {
|
||||
_sheetCoordinator.resetTagSheetHeight(() => setState(() {}));
|
||||
setState(() => _selectedNodeId = null);
|
||||
}
|
||||
// If transitioning to edit, keep the height until edit sheet takes over
|
||||
});
|
||||
}
|
||||
|
||||
void openSuspectedLocationSheet(SuspectedLocation location) {
|
||||
final appState = context.read<AppState>();
|
||||
appState.selectSuspectedLocation(location);
|
||||
|
||||
// Start smooth centering animation simultaneously with sheet opening
|
||||
try {
|
||||
_mapController.animateTo(
|
||||
dest: location.centroid,
|
||||
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(location.centroid, _mapController.mapController.camera.zoom);
|
||||
} catch (_) {
|
||||
// Controller really not ready, skip centering
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the map interaction (centering and selection)
|
||||
_mapInteractionHandler.handleSuspectedLocationTap(
|
||||
context: context,
|
||||
location: location,
|
||||
mapController: _mapController,
|
||||
);
|
||||
|
||||
final controller = _scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
@@ -581,9 +359,10 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
),
|
||||
child: MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
setState(() {
|
||||
_tagSheetHeight = height + MediaQuery.of(context).padding.bottom;
|
||||
});
|
||||
_sheetCoordinator.updateTagSheetHeight(
|
||||
height + MediaQuery.of(context).padding.bottom,
|
||||
() => setState(() {}),
|
||||
);
|
||||
},
|
||||
child: SuspectedLocationSheet(location: location),
|
||||
),
|
||||
@@ -591,10 +370,10 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
);
|
||||
|
||||
// Reset height and clear selection when sheet is dismissed
|
||||
final appState = context.read<AppState>();
|
||||
controller.closed.then((_) {
|
||||
setState(() {
|
||||
_tagSheetHeight = 0.0;
|
||||
});
|
||||
if (!mounted) return;
|
||||
_sheetCoordinator.resetTagSheetHeight(() => setState(() {}));
|
||||
appState.clearSuspectedLocationSelection();
|
||||
});
|
||||
}
|
||||
@@ -604,46 +383,46 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
// Auto-open edit sheet when edit session starts
|
||||
if (appState.editSession != null && !_editSheetShown) {
|
||||
_editSheetShown = true;
|
||||
if (appState.editSession != null && !_sheetCoordinator.editSheetShown) {
|
||||
_sheetCoordinator.setEditSheetShown(true);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _openEditNodeSheet());
|
||||
} else if (appState.editSession == null) {
|
||||
_editSheetShown = false;
|
||||
_sheetCoordinator.setEditSheetShown(false);
|
||||
}
|
||||
|
||||
// Auto-open navigation sheet when needed - simplified logic (only in dev mode)
|
||||
// Auto-open navigation sheet when needed - only when online and in nav features mode
|
||||
if (kEnableNavigationFeatures) {
|
||||
final shouldShowNavSheet = appState.isInSearchMode || appState.showingOverview;
|
||||
if (shouldShowNavSheet && !_navigationSheetShown) {
|
||||
_navigationSheetShown = true;
|
||||
final shouldShowNavSheet = !appState.offlineMode && (appState.isInSearchMode || appState.showingOverview);
|
||||
if (shouldShowNavSheet && !_sheetCoordinator.navigationSheetShown) {
|
||||
_sheetCoordinator.setNavigationSheetShown(true);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _openNavigationSheet());
|
||||
} else if (!shouldShowNavSheet) {
|
||||
_navigationSheetShown = false;
|
||||
} else if (!shouldShowNavSheet && _sheetCoordinator.navigationSheetShown) {
|
||||
_sheetCoordinator.setNavigationSheetShown(false);
|
||||
// When sheet should close (including going offline), clean up navigation state
|
||||
if (appState.offlineMode) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
appState.cancelNavigation();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for welcome/changelog popup after app is fully initialized
|
||||
if (appState.isInitialized && !_hasCheckedForPopup) {
|
||||
_hasCheckedForPopup = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _checkForPopup());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkForPopup();
|
||||
// Check if re-authentication is needed for message notifications
|
||||
appState.checkAndPromptReauthForMessages(context);
|
||||
});
|
||||
}
|
||||
|
||||
// Pass the active sheet height directly to the map
|
||||
final activeSheetHeight = _addSheetHeight > 0
|
||||
? _addSheetHeight
|
||||
: (_editSheetHeight > 0
|
||||
? _editSheetHeight
|
||||
: (_navigationSheetHeight > 0
|
||||
? _navigationSheetHeight
|
||||
: _tagSheetHeight));
|
||||
final activeSheetHeight = _sheetCoordinator.activeSheetHeight;
|
||||
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<CameraProviderWithCache>(create: (_) => CameraProviderWithCache()),
|
||||
],
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(viewInsets: EdgeInsets.zero),
|
||||
child: Scaffold(
|
||||
return MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(viewInsets: EdgeInsets.zero),
|
||||
child: Scaffold(
|
||||
key: _scaffoldKey,
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false, // Disable automatic back button
|
||||
@@ -656,24 +435,46 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
IconButton(
|
||||
tooltip: _getFollowMeTooltip(appState.followMeMode),
|
||||
icon: Icon(_getFollowMeIcon(appState.followMeMode)),
|
||||
onPressed: () {
|
||||
final oldMode = appState.followMeMode;
|
||||
final newMode = _getNextFollowMeMode(oldMode);
|
||||
debugPrint('[HomeScreen] Follow mode changed: $oldMode → $newMode');
|
||||
appState.setFollowMeMode(newMode);
|
||||
// If enabling follow-me, retry location init in case permission was granted
|
||||
if (newMode != FollowMeMode.off) {
|
||||
_mapViewKey.currentState?.retryLocationInit();
|
||||
}
|
||||
},
|
||||
onPressed: (_mapViewKey.currentState?.hasLocation == true && !_sheetCoordinator.hasActiveNodeSheet)
|
||||
? () {
|
||||
final oldMode = appState.followMeMode;
|
||||
final newMode = _getNextFollowMeMode(oldMode);
|
||||
debugPrint('[HomeScreen] Follow mode changed: $oldMode → $newMode');
|
||||
appState.setFollowMeMode(newMode);
|
||||
// If enabling follow-me, retry location init in case permission was granted
|
||||
if (newMode != FollowMeMode.off) {
|
||||
_mapViewKey.currentState?.retryLocationInit();
|
||||
}
|
||||
}
|
||||
: null, // Grey out when no location or when node sheet is open
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => IconButton(
|
||||
tooltip: LocalizationService.instance.settings,
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () => Navigator.pushNamed(context, '/settings'),
|
||||
),
|
||||
builder: (context, child) {
|
||||
final appState = context.watch<AppState>();
|
||||
return IconButton(
|
||||
tooltip: LocalizationService.instance.settings,
|
||||
icon: Stack(
|
||||
children: [
|
||||
const Icon(Icons.settings),
|
||||
if (appState.hasUnreadMessages)
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: () => Navigator.pushNamed(context, '/settings'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -688,14 +489,36 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
onNodeTap: openNodeTagSheet,
|
||||
onSuspectedLocationTap: openSuspectedLocationSheet,
|
||||
onSearchPressed: _onNavigationButtonPressed,
|
||||
onNodeLimitChanged: (isLimited) {
|
||||
setState(() {
|
||||
_isNodeLimitActive = isLimited;
|
||||
});
|
||||
},
|
||||
onLocationStatusChanged: () {
|
||||
// Re-render when location status changes (for follow-me button state)
|
||||
setState(() {});
|
||||
},
|
||||
onUserGesture: () {
|
||||
// Only clear selected node if tag sheet is not open
|
||||
// This prevents nodes from losing their grey-out when map is moved while viewing tags
|
||||
if (_sheetCoordinator.tagSheetHeight == 0) {
|
||||
_mapInteractionHandler.handleUserGesture(
|
||||
context: context,
|
||||
onSelectedNodeChanged: (id) => setState(() => _selectedNodeId = id),
|
||||
);
|
||||
} else {
|
||||
// Tag sheet is open - only handle suspected location clearing, not node selection
|
||||
final appState = context.read<AppState>();
|
||||
appState.clearSuspectedLocationSelection();
|
||||
}
|
||||
|
||||
if (appState.followMeMode != FollowMeMode.off) {
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
}
|
||||
},
|
||||
),
|
||||
// Search bar (slides in when in search mode) - only online since search doesn't work offline
|
||||
if (!appState.offlineMode && appState.isInSearchMode)
|
||||
// Search bar (slides in when in search mode)
|
||||
if (appState.isInSearchMode)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
@@ -725,7 +548,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).shadowColor.withOpacity(0.3),
|
||||
color: Theme.of(context).shadowColor.withValues(alpha: 0.3),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, -2),
|
||||
)
|
||||
@@ -760,10 +583,26 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(Icons.download_for_offline),
|
||||
label: Text(LocalizationService.instance.download),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
),
|
||||
onPressed: () {
|
||||
// Check minimum zoom level before opening download dialog
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForOfflineDownload) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('download.areaTooBigMessage',
|
||||
params: [kMinZoomForOfflineDownload.toString()])
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
@@ -783,7 +622,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +1,115 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/distance_service.dart';
|
||||
import '../app_state.dart';
|
||||
import '../state/settings_state.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class NavigationSettingsScreen extends StatelessWidget {
|
||||
class NavigationSettingsScreen extends StatefulWidget {
|
||||
const NavigationSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<NavigationSettingsScreen> createState() => _NavigationSettingsScreenState();
|
||||
}
|
||||
|
||||
class _NavigationSettingsScreenState extends State<NavigationSettingsScreen> {
|
||||
late TextEditingController _distanceController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final appState = context.read<AppState>();
|
||||
final displayValue = DistanceService.convertFromMeters(
|
||||
appState.navigationAvoidanceDistance.toDouble(),
|
||||
appState.distanceUnit
|
||||
);
|
||||
_distanceController = TextEditingController(
|
||||
text: displayValue.round().toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_distanceController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateDistance(AppState appState, String value) {
|
||||
final displayValue = double.tryParse(value) ?? (appState.distanceUnit == DistanceUnit.metric ? 250.0 : 820.0);
|
||||
final metersValue = DistanceService.convertToMeters(displayValue, appState.distanceUnit, isSmallDistance: true);
|
||||
appState.setNavigationAvoidanceDistance(metersValue.round().clamp(0, 2000));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('navigation.navigationSettings')),
|
||||
),
|
||||
body: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Coming soon message
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.blue),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Navigation Features',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
return Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
// Update the text field when the unit or distance changes
|
||||
final displayValue = DistanceService.convertFromMeters(
|
||||
appState.navigationAvoidanceDistance.toDouble(),
|
||||
appState.distanceUnit
|
||||
);
|
||||
if (_distanceController.text != displayValue.round().toString()) {
|
||||
_distanceController.text = displayValue.round().toString();
|
||||
}
|
||||
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('navigation.navigationSettings')),
|
||||
),
|
||||
body: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.social_distance),
|
||||
title: Text(locService.t('navigation.avoidanceDistance')),
|
||||
subtitle: Text(locService.t('navigation.avoidanceDistanceSubtitle')),
|
||||
trailing: SizedBox(
|
||||
width: 80,
|
||||
child: TextField(
|
||||
controller: _distanceController,
|
||||
keyboardType: const TextInputType.numberWithOptions(signed: false, decimal: false),
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixText: DistanceService.getSmallDistanceUnit(appState.distanceUnit),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Navigation and routing settings will be available here. Coming soon:\n\n'
|
||||
'• Surveillance avoidance distance\n'
|
||||
'• Route planning preferences\n'
|
||||
'• Search history management\n'
|
||||
'• Distance units (metric/imperial)',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
onSubmitted: (value) => _updateDistance(appState, value),
|
||||
onEditingComplete: () => _updateDistance(appState, _distanceController.text),
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
_buildDisabledSetting(
|
||||
context,
|
||||
icon: Icons.history,
|
||||
title: locService.t('navigation.searchHistory'),
|
||||
subtitle: locService.t('navigation.searchHistorySubtitle'),
|
||||
value: '10 searches',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Placeholder settings (disabled for now)
|
||||
_buildDisabledSetting(
|
||||
context,
|
||||
icon: Icons.warning_outlined,
|
||||
title: locService.t('navigation.avoidanceDistance'),
|
||||
subtitle: locService.t('navigation.avoidanceDistanceSubtitle'),
|
||||
value: '100 ${locService.t('navigation.meters')}',
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
_buildDisabledSetting(
|
||||
context,
|
||||
icon: Icons.history,
|
||||
title: locService.t('navigation.searchHistory'),
|
||||
subtitle: locService.t('navigation.searchHistorySubtitle'),
|
||||
value: '10 searches',
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
_buildDisabledSetting(
|
||||
context,
|
||||
icon: Icons.straighten,
|
||||
title: locService.t('navigation.units'),
|
||||
subtitle: locService.t('navigation.unitsSubtitle'),
|
||||
value: locService.t('navigation.metric'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,7 +132,7 @@ class NavigationSettingsScreen extends StatelessWidget {
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -126,4 +143,4 @@ class NavigationSettingsScreen extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../widgets/nsi_tag_value_field.dart';
|
||||
|
||||
class OperatorProfileEditor extends StatefulWidget {
|
||||
const OperatorProfileEditor({super.key, required this.profile});
|
||||
@@ -54,6 +55,12 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.profile.name.isEmpty ? locService.t('operatorProfileEditor.newOperatorProfile') : locService.t('operatorProfileEditor.editOperatorProfile')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _save,
|
||||
child: Text(locService.t('profileEditor.saveProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
@@ -86,10 +93,6 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
const SizedBox(height: 8),
|
||||
..._buildTagRows(),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: Text(locService.t('profileEditor.saveProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -102,7 +105,6 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
|
||||
return List.generate(_tags.length, (i) {
|
||||
final keyController = TextEditingController(text: _tags[i].key);
|
||||
final valueController = TextEditingController(text: _tags[i].value);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
@@ -123,14 +125,12 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: locService.t('profileEditor.valueHint'),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
controller: valueController,
|
||||
onChanged: (v) => _tags[i] = MapEntry(_tags[i].key, v),
|
||||
child: NSITagValueField(
|
||||
key: ValueKey('${_tags[i].key}_$i'), // Rebuild when key changes
|
||||
tagKey: _tags[i].key,
|
||||
initialValue: _tags[i].value,
|
||||
hintText: locService.t('profileEditor.valueHint'),
|
||||
onChanged: (v) => setState(() => _tags[i] = MapEntry(_tags[i].key, v)),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
@@ -155,8 +155,8 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
|
||||
final tagMap = <String, String>{};
|
||||
for (final e in _tags) {
|
||||
if (e.key.trim().isEmpty || e.value.trim().isEmpty) continue;
|
||||
tagMap[e.key.trim()] = e.value.trim();
|
||||
if (e.key.trim().isEmpty) continue; // Skip only if key is empty
|
||||
tagMap[e.key.trim()] = e.value.trim(); // Allow empty values for refinement
|
||||
}
|
||||
|
||||
final newProfile = widget.profile.copyWith(
|
||||
|
||||
@@ -4,12 +4,28 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../state/settings_state.dart';
|
||||
import '../screens/settings/sections/upload_mode_section.dart';
|
||||
|
||||
class OSMAccountScreen extends StatelessWidget {
|
||||
class OSMAccountScreen extends StatefulWidget {
|
||||
const OSMAccountScreen({super.key});
|
||||
|
||||
@override
|
||||
State<OSMAccountScreen> createState() => _OSMAccountScreenState();
|
||||
}
|
||||
|
||||
class _OSMAccountScreenState extends State<OSMAccountScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Check for messages when screen loads
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final appState = context.read<AppState>();
|
||||
if (appState.isLoggedIn) {
|
||||
appState.checkMessages();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
@@ -90,14 +106,68 @@ class OSMAccountScreen extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
// Only show OSM website buttons when not in simulate mode
|
||||
if (appState.uploadMode != UploadMode.simulate) ...[
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.history),
|
||||
title: Text(locService.t('auth.viewMyEdits')),
|
||||
subtitle: Text(locService.t('auth.viewMyEditsSubtitle')),
|
||||
trailing: const Icon(Icons.open_in_new),
|
||||
onTap: () async {
|
||||
final url = Uri.parse('https://openstreetmap.org/user/${Uri.encodeComponent(appState.username)}/history');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
// Messages button - only show when not in simulate mode
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.history),
|
||||
title: Text(locService.t('auth.viewMyEdits')),
|
||||
subtitle: Text(locService.t('auth.viewMyEditsSubtitle')),
|
||||
leading: Stack(
|
||||
children: [
|
||||
const Icon(Icons.message),
|
||||
if (appState.hasUnreadMessages)
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 12,
|
||||
minHeight: 12,
|
||||
),
|
||||
child: Text(
|
||||
'${appState.unreadMessageCount}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onError,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(locService.t('auth.viewMessages')),
|
||||
subtitle: Text(appState.hasUnreadMessages
|
||||
? locService.t('auth.unreadMessagesCount', params: [appState.unreadMessageCount.toString()])
|
||||
: locService.t('auth.noUnreadMessages')),
|
||||
trailing: const Icon(Icons.open_in_new),
|
||||
onTap: () async {
|
||||
final url = Uri.parse('https://openstreetmap.org/user/${Uri.encodeComponent(appState.username)}/history');
|
||||
final url = Uri.parse(appState.getMessagesUrl());
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
@@ -109,6 +179,7 @@ class OSMAccountScreen extends StatelessWidget {
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -167,10 +238,181 @@ class OSMAccountScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Account deletion section - only show when logged in and not in simulate mode
|
||||
if (appState.isLoggedIn && appState.uploadMode != UploadMode.simulate) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildAccountDeletionSection(context, appState),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAccountDeletionSection(BuildContext context, AppState appState) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('auth.accountManagement'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
locService.t('auth.accountManagementDescription'),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Show current upload destination
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_getCurrentDestinationText(locService, appState.uploadMode),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Delete account button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _showDeleteAccountDialog(context, locService, appState.uploadMode),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: Text(locService.t('auth.deleteAccount')),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.error.withValues(alpha: 0.5)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getCurrentDestinationText(LocalizationService locService, UploadMode uploadMode) {
|
||||
switch (uploadMode) {
|
||||
case UploadMode.production:
|
||||
return locService.t('auth.currentDestinationProduction');
|
||||
case UploadMode.sandbox:
|
||||
return locService.t('auth.currentDestinationSandbox');
|
||||
case UploadMode.simulate:
|
||||
return locService.t('auth.currentDestinationSimulate');
|
||||
}
|
||||
}
|
||||
|
||||
void _showDeleteAccountDialog(BuildContext context, LocalizationService locService, UploadMode uploadMode) {
|
||||
final deleteUrl = _getDeleteAccountUrl(uploadMode);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('auth.deleteAccount')),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(locService.t('auth.deleteAccountExplanation')),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
locService.t('auth.deleteAccountWarning'),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Show which account will be deleted
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.error.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_getCurrentDestinationText(locService, uploadMode),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(locService.t('actions.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_launchDeleteAccountUrl(deleteUrl, context, locService);
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text(locService.t('auth.goToOSM')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getDeleteAccountUrl(UploadMode uploadMode) {
|
||||
switch (uploadMode) {
|
||||
case UploadMode.production:
|
||||
return 'https://www.openstreetmap.org/account/deletion';
|
||||
case UploadMode.sandbox:
|
||||
return 'https://master.apis.dev.openstreetmap.org/account/deletion';
|
||||
case UploadMode.simulate:
|
||||
// For simulate mode, just go to production since it's not a real account
|
||||
return 'https://www.openstreetmap.org/account/deletion';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _launchDeleteAccountUrl(String url, BuildContext context, LocalizationService locService) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../widgets/nsi_tag_value_field.dart';
|
||||
|
||||
class ProfileEditor extends StatefulWidget {
|
||||
const ProfileEditor({super.key, required this.profile});
|
||||
@@ -20,6 +21,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
late List<MapEntry<String, String>> _tags;
|
||||
late bool _requiresDirection;
|
||||
late bool _submittable;
|
||||
late TextEditingController _fovCtrl;
|
||||
|
||||
static const _defaultTags = [
|
||||
MapEntry('man_made', 'surveillance'),
|
||||
@@ -38,6 +40,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
_nameCtrl = TextEditingController(text: widget.profile.name);
|
||||
_requiresDirection = widget.profile.requiresDirection;
|
||||
_submittable = widget.profile.submittable;
|
||||
_fovCtrl = TextEditingController(text: widget.profile.fov?.toString() ?? '');
|
||||
|
||||
if (widget.profile.tags.isEmpty) {
|
||||
// New profile → start with sensible defaults
|
||||
@@ -50,6 +53,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_fovCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -65,6 +69,12 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
title: Text(!widget.profile.editable
|
||||
? locService.t('profileEditor.viewProfile')
|
||||
: (widget.profile.name.isEmpty ? locService.t('profileEditor.newProfile') : locService.t('profileEditor.editProfile'))),
|
||||
actions: widget.profile.editable ? [
|
||||
TextButton(
|
||||
onPressed: _save,
|
||||
child: Text(locService.t('profileEditor.saveProfile')),
|
||||
),
|
||||
] : null,
|
||||
),
|
||||
body: ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
@@ -91,6 +101,21 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
onChanged: (value) => setState(() => _requiresDirection = value ?? true),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
if (_requiresDirection) Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
|
||||
child: TextField(
|
||||
controller: _fovCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('profileEditor.fov'),
|
||||
hintText: locService.t('profileEditor.fovHint'),
|
||||
helperText: locService.t('profileEditor.fovSubtitle'),
|
||||
errorText: _validateFov(),
|
||||
suffixText: '°',
|
||||
),
|
||||
onChanged: (value) => setState(() {}), // Trigger validation
|
||||
),
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('profileEditor.submittable')),
|
||||
subtitle: Text(locService.t('profileEditor.submittableSubtitle')),
|
||||
@@ -107,7 +132,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
if (widget.profile.editable)
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _tags.add(const MapEntry('', ''))),
|
||||
onPressed: () => setState(() => _tags.insert(0, const MapEntry('', ''))),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(locService.t('profileEditor.addTag')),
|
||||
),
|
||||
@@ -116,11 +141,6 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
const SizedBox(height: 8),
|
||||
..._buildTagRows(),
|
||||
const SizedBox(height: 24),
|
||||
if (widget.profile.editable)
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: Text(locService.t('profileEditor.saveProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -133,7 +153,6 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
|
||||
return List.generate(_tags.length, (i) {
|
||||
final keyController = TextEditingController(text: _tags[i].key);
|
||||
final valueController = TextEditingController(text: _tags[i].value);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
@@ -157,17 +176,15 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: locService.t('profileEditor.valueHint'),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
controller: valueController,
|
||||
child: NSITagValueField(
|
||||
key: ValueKey('${_tags[i].key}_$i'), // Rebuild when key changes
|
||||
tagKey: _tags[i].key,
|
||||
initialValue: _tags[i].value,
|
||||
hintText: locService.t('profileEditor.valueHint'),
|
||||
readOnly: !widget.profile.editable,
|
||||
onChanged: !widget.profile.editable
|
||||
? null
|
||||
: (v) => _tags[i] = MapEntry(_tags[i].key, v),
|
||||
? (v) {} // No-op when read-only
|
||||
: (v) => setState(() => _tags[i] = MapEntry(_tags[i].key, v)),
|
||||
),
|
||||
),
|
||||
if (widget.profile.editable)
|
||||
@@ -181,6 +198,17 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
});
|
||||
}
|
||||
|
||||
String? _validateFov() {
|
||||
final text = _fovCtrl.text.trim();
|
||||
if (text.isEmpty) return null; // Optional field
|
||||
|
||||
final fov = double.tryParse(text);
|
||||
if (fov == null || fov <= 0 || fov > 360) {
|
||||
return LocalizationService.instance.t('profileEditor.fovInvalid');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final locService = LocalizationService.instance;
|
||||
final name = _nameCtrl.text.trim();
|
||||
@@ -190,11 +218,20 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
.showSnackBar(SnackBar(content: Text(locService.t('profileEditor.profileNameRequired'))));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate FOV if provided
|
||||
if (_validateFov() != null) {
|
||||
return; // Don't save if FOV validation fails
|
||||
}
|
||||
|
||||
// Parse FOV
|
||||
final fovText = _fovCtrl.text.trim();
|
||||
final fov = fovText.isEmpty ? null : double.tryParse(fovText);
|
||||
|
||||
final tagMap = <String, String>{};
|
||||
for (final e in _tags) {
|
||||
if (e.key.trim().isEmpty || e.value.trim().isEmpty) continue;
|
||||
tagMap[e.key.trim()] = e.value.trim();
|
||||
if (e.key.trim().isEmpty) continue; // Skip only if key is empty
|
||||
tagMap[e.key.trim()] = e.value.trim(); // Allow empty values for refinement
|
||||
}
|
||||
|
||||
if (tagMap.isEmpty) {
|
||||
@@ -211,6 +248,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
requiresDirection: _requiresDirection,
|
||||
submittable: _submittable,
|
||||
editable: true, // All custom profiles are editable by definition
|
||||
fov: fov,
|
||||
);
|
||||
|
||||
context.read<AppState>().addOrUpdateProfile(newProfile);
|
||||
|
||||
@@ -99,7 +99,7 @@ class _ReleaseNotesScreenState extends State<ReleaseNotesScreen> {
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -142,8 +142,8 @@ class _ReleaseNotesScreenState extends State<ReleaseNotesScreen> {
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isCurrentVersion
|
||||
? Theme.of(context).colorScheme.primary.withOpacity(0.3)
|
||||
: Theme.of(context).dividerColor.withOpacity(0.3),
|
||||
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.3)
|
||||
: Theme.of(context).dividerColor.withValues(alpha: 0.3),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../state/settings_state.dart';
|
||||
|
||||
class LanguageSection extends StatefulWidget {
|
||||
const LanguageSection({super.key});
|
||||
@@ -20,27 +23,29 @@ class _LanguageSectionState extends State<LanguageSection> {
|
||||
_loadLanguageNames();
|
||||
}
|
||||
|
||||
_loadSelectedLanguage() async {
|
||||
Future<void> _loadSelectedLanguage() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_selectedLanguage = prefs.getString('language_code');
|
||||
});
|
||||
}
|
||||
|
||||
_loadLanguageNames() async {
|
||||
Future<void> _loadLanguageNames() async {
|
||||
final locService = LocalizationService.instance;
|
||||
final Map<String, String> names = {};
|
||||
|
||||
|
||||
for (String langCode in locService.availableLanguages) {
|
||||
names[langCode] = await locService.getLanguageDisplayName(langCode);
|
||||
}
|
||||
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_languageNames = names;
|
||||
});
|
||||
}
|
||||
|
||||
_setLanguage(String? languageCode) async {
|
||||
Future<void> _setLanguage(String? languageCode) async {
|
||||
await LocalizationService.instance.setLanguage(languageCode);
|
||||
setState(() {
|
||||
_selectedLanguage = languageCode;
|
||||
@@ -49,43 +54,95 @@ class _LanguageSectionState extends State<LanguageSection> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// System Default option
|
||||
RadioListTile<String?>(
|
||||
title: Text(locService.t('settings.systemDefault')),
|
||||
value: null,
|
||||
groupValue: _selectedLanguage,
|
||||
onChanged: _setLanguage,
|
||||
),
|
||||
// English always appears second (if available)
|
||||
if (locService.availableLanguages.contains('en'))
|
||||
RadioListTile<String>(
|
||||
title: Text(_languageNames['en'] ?? 'English'),
|
||||
value: 'en',
|
||||
groupValue: _selectedLanguage,
|
||||
onChanged: _setLanguage,
|
||||
return Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Language section
|
||||
RadioGroup<String?>(
|
||||
groupValue: _selectedLanguage,
|
||||
onChanged: _setLanguage,
|
||||
child: Column(
|
||||
children: [
|
||||
// System Default option
|
||||
RadioListTile<String?>(
|
||||
title: Text(locService.t('settings.systemDefault')),
|
||||
value: null,
|
||||
),
|
||||
// English always appears second (if available)
|
||||
if (locService.availableLanguages.contains('en'))
|
||||
RadioListTile<String?>(
|
||||
title: Text(_languageNames['en'] ?? 'English'),
|
||||
value: 'en',
|
||||
),
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Divider between language and units
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Distance Units section
|
||||
Text(
|
||||
locService.t('settings.distanceUnit'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
locService.t('settings.distanceUnitSubtitle'),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
RadioGroup<DistanceUnit>(
|
||||
groupValue: appState.distanceUnit,
|
||||
onChanged: (unit) {
|
||||
if (unit != null) {
|
||||
appState.setDistanceUnit(unit);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
// Metric option
|
||||
RadioListTile<DistanceUnit>(
|
||||
title: Text(locService.t('units.metricDescription')),
|
||||
value: DistanceUnit.metric,
|
||||
),
|
||||
|
||||
// Imperial option
|
||||
RadioListTile<DistanceUnit>(
|
||||
title: Text(locService.t('units.imperialDescription')),
|
||||
value: DistanceUnit.imperial,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 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,
|
||||
groupValue: _selectedLanguage,
|
||||
onChanged: _setLanguage,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ class _MaxNodesSectionState extends State<MaxNodesSection> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final maxNodes = context.read<AppState>().maxCameras;
|
||||
final maxNodes = context.read<AppState>().maxNodes;
|
||||
_controller = TextEditingController(text: maxNodes.toString());
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class _MaxNodesSectionState extends State<MaxNodesSection> {
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
final current = appState.maxCameras;
|
||||
final current = appState.maxNodes;
|
||||
final showWarning = current > 1000;
|
||||
|
||||
return Column(
|
||||
@@ -79,8 +79,8 @@ class _MaxNodesSectionState extends State<MaxNodesSection> {
|
||||
),
|
||||
onFieldSubmitted: (value) {
|
||||
final n = int.tryParse(value) ?? 10;
|
||||
appState.maxCameras = n;
|
||||
_controller.text = appState.maxCameras.toString();
|
||||
appState.maxNodes = n;
|
||||
_controller.text = appState.maxNodes.toString();
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../models/node_profile.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../../widgets/profile_add_choice_dialog.dart';
|
||||
import '../../profile_editor.dart';
|
||||
|
||||
class NodeProfilesSection extends StatelessWidget {
|
||||
@@ -27,18 +28,7 @@ class NodeProfilesSection extends StatelessWidget {
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(
|
||||
profile: NodeProfile(
|
||||
id: const Uuid().v4(),
|
||||
name: '',
|
||||
tags: const {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: () => _showAddProfileDialog(context),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(locService.t('profiles.newProfile')),
|
||||
),
|
||||
@@ -121,6 +111,34 @@ class NodeProfilesSection extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddProfileDialog(BuildContext context) async {
|
||||
final result = await showDialog<String?>(
|
||||
context: context,
|
||||
builder: (context) => const ProfileAddChoiceDialog(),
|
||||
);
|
||||
|
||||
// If user chose to create custom profile, open the profile editor
|
||||
if (result == 'create' && context.mounted) {
|
||||
_createNewProfile(context);
|
||||
}
|
||||
// If user chose import from website, ProfileAddChoiceDialog handles opening the URL
|
||||
}
|
||||
|
||||
void _createNewProfile(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(
|
||||
profile: NodeProfile(
|
||||
id: const Uuid().v4(),
|
||||
name: '',
|
||||
tags: const {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteProfileDialog(BuildContext context, NodeProfile profile) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
@@ -87,9 +87,9 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
|
||||
: "${(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' +
|
||||
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) {
|
||||
@@ -207,7 +207,7 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -77,27 +77,7 @@ class OfflineModeSection extends StatelessWidget {
|
||||
onChanged: (value) => _handleOfflineModeChange(context, appState, value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.pause_circle_outline,
|
||||
color: appState.offlineMode
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
title: Text(
|
||||
locService.t('settings.pauseQueueProcessingSubtitle'),
|
||||
style: appState.offlineMode
|
||||
? TextStyle(color: Theme.of(context).disabledColor)
|
||||
: null,
|
||||
),
|
||||
trailing: Switch(
|
||||
value: appState.pauseQueueProcessing,
|
||||
onChanged: appState.offlineMode
|
||||
? null // Disable when offline mode is on
|
||||
: (value) => appState.setPauseQueueProcessing(value),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'package:provider/provider.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../../services/proximity_alert_service.dart';
|
||||
import '../../../services/distance_service.dart';
|
||||
import '../../../state/settings_state.dart';
|
||||
import '../../../dev_config.dart';
|
||||
|
||||
/// Settings section for proximity alerts configuration
|
||||
@@ -25,8 +27,13 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
final appState = context.read<AppState>();
|
||||
// Convert meters to display units for the text field
|
||||
final displayValue = DistanceService.convertFromMeters(
|
||||
appState.proximityAlertDistance.toDouble(),
|
||||
appState.distanceUnit
|
||||
);
|
||||
_distanceController = TextEditingController(
|
||||
text: appState.proximityAlertDistance.toString(),
|
||||
text: displayValue.round().toString(),
|
||||
);
|
||||
_checkNotificationPermissions();
|
||||
}
|
||||
@@ -69,12 +76,18 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
|
||||
|
||||
void _updateDistance(AppState appState) {
|
||||
final text = _distanceController.text.trim();
|
||||
final distance = int.tryParse(text);
|
||||
if (distance != null) {
|
||||
appState.setProximityAlertDistance(distance);
|
||||
final displayValue = double.tryParse(text);
|
||||
if (displayValue != null) {
|
||||
// Convert from display units back to meters for storage
|
||||
final metersValue = DistanceService.convertToMeters(displayValue, appState.distanceUnit, isSmallDistance: true);
|
||||
appState.setProximityAlertDistance(metersValue.round());
|
||||
} else {
|
||||
// Reset to current value if invalid
|
||||
_distanceController.text = appState.proximityAlertDistance.toString();
|
||||
final displayValue = DistanceService.convertFromMeters(
|
||||
appState.proximityAlertDistance.toDouble(),
|
||||
appState.distanceUnit
|
||||
);
|
||||
_distanceController.text = displayValue.round().toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +97,15 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
|
||||
builder: (context, appState, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
// Update the text field when the unit or distance changes
|
||||
final displayValue = DistanceService.convertFromMeters(
|
||||
appState.proximityAlertDistance.toDouble(),
|
||||
appState.distanceUnit
|
||||
);
|
||||
if (_distanceController.text != displayValue.round().toString()) {
|
||||
_distanceController.text = displayValue.round().toString();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -118,9 +140,9 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -171,49 +193,50 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
|
||||
),
|
||||
],
|
||||
|
||||
// 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: const TextInputType.numberWithOptions(signed: true, decimal: true),
|
||||
textInputAction: TextInputAction.done,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 8,
|
||||
// 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: const TextInputType.numberWithOptions(signed: true, decimal: true),
|
||||
textInputAction: TextInputAction.done,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 8,
|
||||
),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
border: OutlineInputBorder(),
|
||||
onSubmitted: (_) => _updateDistance(appState),
|
||||
onEditingComplete: () => _updateDistance(appState),
|
||||
),
|
||||
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),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('units.${appState.distanceUnit == DistanceUnit.metric ? 'metersLong' : 'feetLong'}')),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
locService.t('proximityAlerts.rangeInfo', params: [
|
||||
DistanceService.convertFromMeters(kProximityAlertMinDistance.toDouble(), appState.distanceUnit).round().toString(),
|
||||
DistanceService.convertFromMeters(kProximityAlertMaxDistance.toDouble(), appState.distanceUnit).round().toString(),
|
||||
locService.t('units.${appState.distanceUnit == DistanceUnit.metric ? 'metersLong' : 'feetLong'}'),
|
||||
DistanceService.convertFromMeters(kProximityAlertDefaultDistance.toDouble(), appState.distanceUnit).round().toString(),
|
||||
]),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../../state/settings_state.dart';
|
||||
|
||||
class QueueSection extends StatelessWidget {
|
||||
const QueueSection({super.key});
|
||||
@@ -112,20 +111,20 @@ class QueueSection extends StatelessWidget {
|
||||
? Colors.red
|
||||
: _getUploadModeColor(upload.uploadMode),
|
||||
),
|
||||
title: Text(locService.t('queue.cameraWithIndex', params: [(index + 1).toString()]) +
|
||||
title: Text(locService.t('queue.itemWithIndex', params: [(index + 1).toString()]) +
|
||||
(upload.error ? locService.t('queue.error') : "") +
|
||||
(upload.completing ? locService.t('queue.completing') : "")),
|
||||
subtitle: Text(
|
||||
locService.t('queue.destination', params: [_getUploadModeDisplayName(upload.uploadMode)]) + '\n' +
|
||||
locService.t('queue.latitude', params: [upload.coord.latitude.toStringAsFixed(6)]) + '\n' +
|
||||
locService.t('queue.longitude', params: [upload.coord.longitude.toStringAsFixed(6)]) + '\n' +
|
||||
locService.t('queue.direction', params: [
|
||||
upload.direction is String
|
||||
'${locService.t('queue.destination', params: [_getUploadModeDisplayName(upload.uploadMode)])}\n'
|
||||
'${locService.t('queue.latitude', params: [upload.coord.latitude.toStringAsFixed(6)])}\n'
|
||||
'${locService.t('queue.longitude', params: [upload.coord.longitude.toStringAsFixed(6)])}\n'
|
||||
'${locService.t('queue.direction', params: [
|
||||
upload.direction is String
|
||||
? upload.direction.toString()
|
||||
: upload.direction.round().toString()
|
||||
]) + '\n' +
|
||||
locService.t('queue.attempts', params: [upload.attempts.toString()]) +
|
||||
(upload.error ? "\n${locService.t('queue.uploadFailedRetry')}" : "")
|
||||
])}\n'
|
||||
'${locService.t('queue.attempts', params: [upload.attempts.toString()])}'
|
||||
'${upload.error ? "\n${locService.t('queue.uploadFailedRetry')}" : ""}'
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -2,10 +2,36 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../../services/distance_service.dart';
|
||||
import '../../../state/settings_state.dart';
|
||||
|
||||
class SuspectedLocationsSection extends StatelessWidget {
|
||||
class SuspectedLocationsSection extends StatefulWidget {
|
||||
const SuspectedLocationsSection({super.key});
|
||||
|
||||
@override
|
||||
State<SuspectedLocationsSection> createState() => _SuspectedLocationsSectionState();
|
||||
}
|
||||
|
||||
class _SuspectedLocationsSectionState extends State<SuspectedLocationsSection> {
|
||||
DateTime? _lastFetch;
|
||||
bool _wasLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadLastFetch();
|
||||
}
|
||||
|
||||
void _loadLastFetch() async {
|
||||
final appState = context.read<AppState>();
|
||||
final lastFetch = await appState.suspectedLocationsLastFetch;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_lastFetch = lastFetch;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
@@ -15,14 +41,31 @@ class SuspectedLocationsSection extends StatelessWidget {
|
||||
final appState = context.watch<AppState>();
|
||||
final isEnabled = appState.suspectedLocationsEnabled;
|
||||
final isLoading = appState.suspectedLocationsLoading;
|
||||
final lastFetch = appState.suspectedLocationsLastFetch;
|
||||
final downloadProgress = appState.suspectedLocationsDownloadProgress;
|
||||
|
||||
// Check if loading just finished and reload last fetch time
|
||||
if (_wasLoading && !isLoading) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadLastFetch();
|
||||
});
|
||||
}
|
||||
_wasLoading = isLoading;
|
||||
|
||||
String getLastFetchText() {
|
||||
if (lastFetch == null) {
|
||||
// Show status during loading
|
||||
if (isLoading) {
|
||||
if (downloadProgress != null) {
|
||||
return 'Downloading data... (this may take a few minutes)';
|
||||
} else {
|
||||
return 'Processing data...';
|
||||
}
|
||||
}
|
||||
|
||||
if (_lastFetch == null) {
|
||||
return locService.t('suspectedLocations.neverFetched');
|
||||
} else {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(lastFetch);
|
||||
final diff = now.difference(_lastFetch!);
|
||||
if (diff.inDays > 0) {
|
||||
return locService.t('suspectedLocations.daysAgo', params: [diff.inDays.toString()]);
|
||||
} else if (diff.inHours > 0) {
|
||||
@@ -42,6 +85,11 @@ class SuspectedLocationsSection extends StatelessWidget {
|
||||
// The loading state will be managed by suspected location state
|
||||
final success = await appState.refreshSuspectedLocations();
|
||||
|
||||
// Refresh the last fetch time after successful refresh
|
||||
if (success) {
|
||||
_loadLastFetch();
|
||||
}
|
||||
|
||||
// Show result snackbar
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -85,10 +133,31 @@ class SuspectedLocationsSection extends StatelessWidget {
|
||||
title: Text(locService.t('suspectedLocations.lastUpdated')),
|
||||
subtitle: Text(getLastFetchText()),
|
||||
trailing: isLoading
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
? SizedBox(
|
||||
width: 80,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
child: downloadProgress != null
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
LinearProgressIndicator(
|
||||
value: downloadProgress,
|
||||
backgroundColor: Colors.grey[300],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${(downloadProgress * 100).toInt()}%',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
@@ -121,22 +190,28 @@ class SuspectedLocationsSection extends StatelessWidget {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.social_distance),
|
||||
title: Text(locService.t('suspectedLocations.minimumDistance')),
|
||||
subtitle: Text(locService.t('suspectedLocations.minimumDistanceSubtitle', params: [appState.suspectedLocationMinDistance.toString()])),
|
||||
subtitle: Text(locService.t('suspectedLocations.minimumDistanceSubtitle', params: [
|
||||
DistanceService.formatDistance(appState.suspectedLocationMinDistance.toDouble(), appState.distanceUnit)
|
||||
])),
|
||||
trailing: SizedBox(
|
||||
width: 80,
|
||||
child: TextFormField(
|
||||
initialValue: appState.suspectedLocationMinDistance.toString(),
|
||||
initialValue: DistanceService.convertFromMeters(
|
||||
appState.suspectedLocationMinDistance.toDouble(),
|
||||
appState.distanceUnit
|
||||
).round().toString(),
|
||||
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: const InputDecoration(
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
border: OutlineInputBorder(),
|
||||
suffixText: 'm',
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixText: DistanceService.getSmallDistanceUnit(appState.distanceUnit),
|
||||
),
|
||||
onFieldSubmitted: (value) {
|
||||
final distance = int.tryParse(value) ?? 100;
|
||||
appState.setSuspectedLocationMinDistance(distance.clamp(0, 1000));
|
||||
final displayValue = double.tryParse(value) ?? (appState.distanceUnit == DistanceUnit.metric ? 100.0 : 328.0);
|
||||
final metersValue = DistanceService.convertToMeters(displayValue, appState.distanceUnit, isSmallDistance: true);
|
||||
appState.setSuspectedLocationMinDistance(metersValue.round().clamp(0, 1000));
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -44,7 +44,7 @@ class TileProviderSection extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
else
|
||||
...providers.map((provider) => _buildProviderTile(context, provider, appState)).toList(),
|
||||
...providers.map((provider) => _buildProviderTile(context, provider, appState)),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -89,7 +89,7 @@ class TileProviderSection extends StatelessWidget {
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.surfaceVariant,
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.map,
|
||||
color: isSelected
|
||||
|
||||
@@ -37,34 +37,77 @@ class UploadModeSection extends StatelessWidget {
|
||||
child: Text(locService.t('uploadMode.simulate')),
|
||||
),
|
||||
],
|
||||
onChanged: (mode) {
|
||||
if (mode != null) appState.setUploadMode(mode);
|
||||
onChanged: appState.pendingCount > 0 ? null : (mode) {
|
||||
if (mode != null) {
|
||||
appState.setUploadMode(mode);
|
||||
// Check if re-authentication is needed after mode change
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
appState.checkAndPromptReauthForMessages(context);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 56, top: 2, right: 16, bottom: 12),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
switch (appState.uploadMode) {
|
||||
case UploadMode.production:
|
||||
return Text(
|
||||
locService.t('uploadMode.productionDescription'),
|
||||
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7))
|
||||
);
|
||||
case UploadMode.sandbox:
|
||||
return Text(
|
||||
locService.t('uploadMode.sandboxDescription'),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orange),
|
||||
);
|
||||
case UploadMode.simulate:
|
||||
default:
|
||||
return Text(
|
||||
locService.t('uploadMode.simulateDescription'),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.deepPurple)
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Upload mode restriction message when queue has items
|
||||
if (appState.pendingCount > 0) ...[
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.orange, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('uploadMode.cannotChangeWithQueue', params: [appState.pendingCount.toString()]),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orange),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
|
||||
// Normal upload mode description
|
||||
Builder(
|
||||
builder: (context) {
|
||||
switch (appState.uploadMode) {
|
||||
case UploadMode.production:
|
||||
return Text(
|
||||
locService.t('uploadMode.productionDescription'),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: appState.pendingCount > 0
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7)
|
||||
)
|
||||
);
|
||||
case UploadMode.sandbox:
|
||||
return Text(
|
||||
locService.t('uploadMode.sandboxDescription'),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: appState.pendingCount > 0
|
||||
? Theme.of(context).disabledColor
|
||||
: Colors.orange
|
||||
),
|
||||
);
|
||||
case UploadMode.simulate:
|
||||
return Text(
|
||||
locService.t('uploadMode.simulateDescription'),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: appState.pendingCount > 0
|
||||
? Theme.of(context).disabledColor
|
||||
: Colors.deepPurple
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/version_service.dart';
|
||||
import '../dev_config.dart';
|
||||
@@ -23,13 +25,7 @@ class SettingsScreen extends StatelessWidget {
|
||||
),
|
||||
children: [
|
||||
// OpenStreetMap Account
|
||||
_buildNavigationTile(
|
||||
context,
|
||||
icon: Icons.account_circle,
|
||||
title: locService.t('auth.osmAccountTitle'),
|
||||
subtitle: locService.t('auth.osmAccountSubtitle'),
|
||||
onTap: () => Navigator.pushNamed(context, '/settings/osm-account'),
|
||||
),
|
||||
_buildOSMAccountTile(context, locService),
|
||||
const Divider(),
|
||||
|
||||
// Upload Queue
|
||||
@@ -106,7 +102,7 @@ class SettingsScreen extends StatelessWidget {
|
||||
child: Text(
|
||||
'Version: ${VersionService().version}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -117,6 +113,35 @@ class SettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOSMAccountTile(BuildContext context, LocalizationService locService) {
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return ListTile(
|
||||
leading: Stack(
|
||||
children: [
|
||||
const Icon(Icons.account_circle),
|
||||
if (appState.hasUnreadMessages)
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(locService.t('auth.osmAccountTitle')),
|
||||
subtitle: Text(locService.t('auth.osmAccountSubtitle')),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: () => Navigator.pushNamed(context, '/settings/osm-account'),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavigationTile(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
@@ -318,9 +317,15 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
|
||||
),
|
||||
validator: (value) {
|
||||
if (value?.trim().isEmpty == true) return locService.t('tileTypeEditor.urlTemplateRequired');
|
||||
if (!value!.contains('{z}') || !value.contains('{x}') || !value.contains('{y}')) {
|
||||
|
||||
// Check for either quadkey OR x+y+z placeholders
|
||||
final hasQuadkey = value!.contains('{quadkey}');
|
||||
final hasXYZ = value.contains('{x}') && value.contains('{y}') && value.contains('{z}');
|
||||
|
||||
if (!hasQuadkey && !hasXYZ) {
|
||||
return locService.t('tileTypeEditor.urlTemplatePlaceholders');
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
),
|
||||
@@ -403,11 +408,20 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
|
||||
});
|
||||
|
||||
try {
|
||||
// Use a sample tile from configured preview location
|
||||
final url = _urlController.text
|
||||
.replaceAll('{z}', kPreviewTileZoom.toString())
|
||||
.replaceAll('{x}', kPreviewTileX.toString())
|
||||
.replaceAll('{y}', kPreviewTileY.toString());
|
||||
// Create a temporary TileType to use the getTileUrl method
|
||||
final tempTileType = TileType(
|
||||
id: 'preview',
|
||||
name: 'Preview',
|
||||
urlTemplate: _urlController.text.trim(),
|
||||
attribution: 'Preview',
|
||||
);
|
||||
|
||||
final url = tempTileType.getTileUrl(
|
||||
kPreviewTileZoom,
|
||||
kPreviewTileX,
|
||||
kPreviewTileY,
|
||||
apiKey: null, // Don't use API key for preview
|
||||
);
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../app_state.dart';
|
||||
import '../models/pending_upload.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../state/settings_state.dart';
|
||||
|
||||
class UploadQueueScreen extends StatelessWidget {
|
||||
const UploadQueueScreen({super.key});
|
||||
|
||||
void _showErrorDialog(BuildContext context, PendingUpload upload, LocalizationService locService) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('queue.errorDetails')),
|
||||
content: SingleChildScrollView(
|
||||
child: Text(
|
||||
upload.errorMessage ?? 'Unknown error',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.ok),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getUploadModeDisplayName(UploadMode mode) {
|
||||
final locService = LocalizationService.instance;
|
||||
switch (mode) {
|
||||
@@ -19,6 +40,39 @@ class UploadQueueScreen extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
String _getUploadStateText(PendingUpload upload, LocalizationService locService) {
|
||||
switch (upload.uploadState) {
|
||||
case UploadState.pending:
|
||||
return upload.attempts > 0 ? ' (Retry ${upload.attempts + 1})' : '';
|
||||
case UploadState.creatingChangeset:
|
||||
return locService.t('queue.creatingChangeset');
|
||||
case UploadState.uploading:
|
||||
// Only show time remaining and attempt count if there have been node submission failures
|
||||
if (upload.nodeSubmissionAttempts > 0) {
|
||||
final timeLeft = upload.timeUntilAutoClose;
|
||||
if (timeLeft != null && timeLeft.inMinutes > 0) {
|
||||
return '${locService.t('queue.uploading')} (${upload.nodeSubmissionAttempts} attempts, ${timeLeft.inMinutes}m left)';
|
||||
} else {
|
||||
return '${locService.t('queue.uploading')} (${upload.nodeSubmissionAttempts} attempts)';
|
||||
}
|
||||
}
|
||||
return locService.t('queue.uploading');
|
||||
case UploadState.closingChangeset:
|
||||
// Only show time remaining if we've had changeset close failures
|
||||
if (upload.changesetCloseAttempts > 0) {
|
||||
final timeLeft = upload.timeUntilAutoClose;
|
||||
if (timeLeft != null && timeLeft.inMinutes > 0) {
|
||||
return '${locService.t('queue.closingChangeset')} (${timeLeft.inMinutes}m left)';
|
||||
}
|
||||
}
|
||||
return locService.t('queue.closingChangeset');
|
||||
case UploadState.error:
|
||||
return locService.t('queue.error');
|
||||
case UploadState.complete:
|
||||
return locService.t('queue.completing');
|
||||
}
|
||||
}
|
||||
|
||||
Color _getUploadModeColor(UploadMode mode) {
|
||||
switch (mode) {
|
||||
case UploadMode.production:
|
||||
@@ -38,6 +92,9 @@ class UploadQueueScreen extends StatelessWidget {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
// Check if queue processing is paused
|
||||
final isQueuePaused = appState.offlineMode || appState.pauseQueueProcessing;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('queue.title')),
|
||||
@@ -50,6 +107,71 @@ class UploadQueueScreen extends StatelessWidget {
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
children: [
|
||||
// Queue processing status indicator
|
||||
if (isQueuePaused && appState.pendingCount > 0)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.pause_circle_outline, color: Colors.orange),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('queue.processingPaused'),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
appState.offlineMode
|
||||
? locService.t('queue.pausedDueToOffline')
|
||||
: locService.t('queue.pausedByUser'),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Pause Upload Queue Toggle
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.pause_circle_outline,
|
||||
color: appState.offlineMode
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
title: Text(locService.t('settings.pauseQueueProcessing')),
|
||||
subtitle: Text(
|
||||
locService.t('settings.pauseQueueProcessingSubtitle'),
|
||||
style: appState.offlineMode
|
||||
? TextStyle(color: Theme.of(context).disabledColor)
|
||||
: null,
|
||||
),
|
||||
trailing: Switch(
|
||||
value: appState.pauseQueueProcessing,
|
||||
onChanged: appState.offlineMode
|
||||
? null // Disable when offline mode is on
|
||||
: (value) => appState.setPauseQueueProcessing(value),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Clear Upload Queue button - always visible
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -82,7 +204,7 @@ class UploadQueueScreen extends StatelessWidget {
|
||||
icon: const Icon(Icons.clear_all),
|
||||
label: Text(locService.t('queue.clearUploadQueue')),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: appState.pendingCount > 0 ? null : Theme.of(context).disabledColor.withOpacity(0.1),
|
||||
backgroundColor: appState.pendingCount > 0 ? null : Theme.of(context).disabledColor.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -101,13 +223,13 @@ class UploadQueueScreen extends StatelessWidget {
|
||||
Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 64,
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.4),
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.4),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
locService.t('queue.nothingInQueue'),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -130,33 +252,40 @@ class UploadQueueScreen extends StatelessWidget {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
upload.error ? Icons.error : Icons.camera_alt,
|
||||
color: upload.error
|
||||
? Colors.red
|
||||
: _getUploadModeColor(upload.uploadMode),
|
||||
),
|
||||
leading: upload.uploadState == UploadState.error
|
||||
? GestureDetector(
|
||||
onTap: () {
|
||||
_showErrorDialog(context, upload, locService);
|
||||
},
|
||||
child: Icon(
|
||||
Icons.error,
|
||||
color: Colors.red,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.camera_alt,
|
||||
color: _getUploadModeColor(upload.uploadMode),
|
||||
),
|
||||
title: Text(
|
||||
locService.t('queue.cameraWithIndex', params: [(index + 1).toString()]) +
|
||||
(upload.error ? locService.t('queue.error') : "") +
|
||||
(upload.completing ? locService.t('queue.completing') : "")
|
||||
locService.t('queue.itemWithIndex', params: [(index + 1).toString()]) +
|
||||
_getUploadStateText(upload, locService)
|
||||
),
|
||||
subtitle: Text(
|
||||
locService.t('queue.destination', params: [_getUploadModeDisplayName(upload.uploadMode)]) + '\n' +
|
||||
locService.t('queue.latitude', params: [upload.coord.latitude.toStringAsFixed(6)]) + '\n' +
|
||||
locService.t('queue.longitude', params: [upload.coord.longitude.toStringAsFixed(6)]) + '\n' +
|
||||
locService.t('queue.direction', params: [
|
||||
upload.direction is String
|
||||
'${locService.t('queue.destination', params: [_getUploadModeDisplayName(upload.uploadMode)])}\n'
|
||||
'${locService.t('queue.latitude', params: [upload.coord.latitude.toStringAsFixed(6)])}\n'
|
||||
'${locService.t('queue.longitude', params: [upload.coord.longitude.toStringAsFixed(6)])}\n'
|
||||
'${locService.t('queue.direction', params: [
|
||||
upload.direction is String
|
||||
? upload.direction.toString()
|
||||
: upload.direction.round().toString()
|
||||
]) + '\n' +
|
||||
locService.t('queue.attempts', params: [upload.attempts.toString()]) +
|
||||
(upload.error ? "\n${locService.t('queue.uploadFailedRetry')}" : "")
|
||||
])}\n'
|
||||
'${locService.t('queue.attempts', params: [upload.attempts.toString()])}'
|
||||
'${upload.uploadState == UploadState.error ? "\n${locService.t('queue.uploadFailedRetry')}" : ""}'
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (upload.error && !upload.completing)
|
||||
if (upload.uploadState == UploadState.error)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
color: Colors.orange,
|
||||
@@ -165,7 +294,7 @@ class UploadQueueScreen extends StatelessWidget {
|
||||
appState.retryUpload(upload);
|
||||
},
|
||||
),
|
||||
if (upload.completing)
|
||||
if (upload.uploadState == UploadState.complete)
|
||||
const Icon(Icons.check_circle, color: Colors.green)
|
||||
else
|
||||
IconButton(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:oauth2_client/oauth2_client.dart';
|
||||
import 'package:oauth2_client/oauth2_helper.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -30,7 +30,6 @@ class AuthService {
|
||||
case UploadMode.sandbox:
|
||||
return 'osm_token_sandbox';
|
||||
case UploadMode.simulate:
|
||||
default:
|
||||
return 'osm_token_simulate';
|
||||
}
|
||||
}
|
||||
@@ -51,7 +50,7 @@ class AuthService {
|
||||
_helper = OAuth2Helper(
|
||||
client,
|
||||
clientId: clientId,
|
||||
scopes: ['read_prefs', 'write_api'],
|
||||
scopes: ['read_prefs', 'write_api', 'consume_messages'],
|
||||
enablePKCE: true,
|
||||
// tokenStorageKey: _tokenKey, // not supported by this package version
|
||||
);
|
||||
@@ -97,10 +96,10 @@ class AuthService {
|
||||
final tokenJson = jsonEncode(tokenMap);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_tokenKey, tokenJson); // Save token for current mode
|
||||
_displayName = await _fetchUsername(token!.accessToken!);
|
||||
_displayName = await _fetchUsername(token.accessToken!);
|
||||
return _displayName;
|
||||
} catch (e) {
|
||||
print('AuthService: OAuth login failed: $e');
|
||||
debugPrint('AuthService: OAuth login failed: $e');
|
||||
log('OAuth login failed: $e');
|
||||
rethrow;
|
||||
}
|
||||
@@ -128,7 +127,7 @@ class AuthService {
|
||||
_displayName = await _fetchUsername(accessToken);
|
||||
return _displayName;
|
||||
} catch (e) {
|
||||
print('AuthService: Error restoring login with stored token: $e');
|
||||
debugPrint('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();
|
||||
@@ -194,7 +193,7 @@ class AuthService {
|
||||
final displayName = userData['user']?['display_name'];
|
||||
return displayName;
|
||||
} catch (e) {
|
||||
print('AuthService: Error fetching username: $e');
|
||||
debugPrint('AuthService: Error fetching username: $e');
|
||||
log('Error fetching username: $e');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'version_service.dart';
|
||||
import '../app_state.dart';
|
||||
import '../migrations.dart';
|
||||
|
||||
/// Service for managing changelog data and first launch detection
|
||||
class ChangelogService {
|
||||
@@ -13,6 +14,8 @@ class ChangelogService {
|
||||
|
||||
static const String _lastSeenVersionKey = 'last_seen_version';
|
||||
static const String _hasSeenWelcomeKey = 'has_seen_welcome';
|
||||
static const String _hasSeenSubmissionGuideKey = 'has_seen_submission_guide';
|
||||
static const String _hasCompletedPositioningTutorialKey = 'has_completed_positioning_tutorial';
|
||||
|
||||
Map<String, dynamic>? _changelogData;
|
||||
bool _initialized = false;
|
||||
@@ -67,6 +70,30 @@ class ChangelogService {
|
||||
await prefs.setBool(_hasSeenWelcomeKey, true);
|
||||
}
|
||||
|
||||
/// Check if user has seen the submission guide popup
|
||||
Future<bool> hasSeenSubmissionGuide() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_hasSeenSubmissionGuideKey) ?? false;
|
||||
}
|
||||
|
||||
/// Mark that user has seen the submission guide popup
|
||||
Future<void> markSubmissionGuideSeen() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_hasSeenSubmissionGuideKey, true);
|
||||
}
|
||||
|
||||
/// Check if user has completed the positioning tutorial
|
||||
Future<bool> hasCompletedPositioningTutorial() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_hasCompletedPositioningTutorialKey) ?? false;
|
||||
}
|
||||
|
||||
/// Mark that user has completed the positioning tutorial
|
||||
Future<void> markPositioningTutorialCompleted() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_hasCompletedPositioningTutorialKey, true);
|
||||
}
|
||||
|
||||
/// Check if app version has changed since last launch
|
||||
Future<bool> hasVersionChanged() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -190,6 +217,14 @@ class ChangelogService {
|
||||
versionsNeedingMigration.add('1.3.1');
|
||||
}
|
||||
|
||||
if (needsMigration(lastSeenVersion, currentVersion, '1.5.3')) {
|
||||
versionsNeedingMigration.add('1.5.3');
|
||||
}
|
||||
|
||||
if (needsMigration(lastSeenVersion, currentVersion, '1.6.3')) {
|
||||
versionsNeedingMigration.add('1.6.3');
|
||||
}
|
||||
|
||||
// Future versions can be added here
|
||||
// if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) {
|
||||
// versionsNeedingMigration.add('2.0.0');
|
||||
@@ -245,25 +280,9 @@ class ChangelogService {
|
||||
bool get isInitialized => _initialized;
|
||||
|
||||
/// Run a specific migration by version number
|
||||
Future<void> runMigration(String version, AppState appState) async {
|
||||
Future<void> runMigration(String version, AppState appState, BuildContext? context) async {
|
||||
debugPrint('[ChangelogService] Running $version migration');
|
||||
|
||||
switch (version) {
|
||||
case '1.3.1':
|
||||
// Enable network status indicator for all existing users
|
||||
await appState.setNetworkStatusIndicatorEnabled(true);
|
||||
debugPrint('[ChangelogService] 1.3.1 migration completed: enabled network status indicator');
|
||||
break;
|
||||
|
||||
// Future migrations can be added here
|
||||
// case '2.0.0':
|
||||
// await appState.doSomethingNew();
|
||||
// debugPrint('[ChangelogService] 2.0.0 migration completed');
|
||||
// break;
|
||||
|
||||
default:
|
||||
debugPrint('[ChangelogService] Unknown migration version: $version');
|
||||
}
|
||||
await OneTimeMigrations.runMigration(version, appState, context);
|
||||
}
|
||||
|
||||
/// Check if a migration should run
|
||||
@@ -284,8 +303,8 @@ class ChangelogService {
|
||||
final v2Parts = v2.split('.').map(int.parse).toList();
|
||||
|
||||
// Ensure we have at least 3 parts (major.minor.patch)
|
||||
while (v1Parts.length < 3) v1Parts.add(0);
|
||||
while (v2Parts.length < 3) v2Parts.add(0);
|
||||
while (v1Parts.length < 3) { v1Parts.add(0); }
|
||||
while (v2Parts.length < 3) { v2Parts.add(0); }
|
||||
|
||||
// Compare major version first
|
||||
if (v1Parts[0] < v2Parts[0]) return -1;
|
||||
|
||||
156
lib/services/deep_link_service.dart
Normal file
156
lib/services/deep_link_service.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import 'dart:async';
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import 'profile_import_service.dart';
|
||||
import '../screens/profile_editor.dart';
|
||||
|
||||
class DeepLinkService {
|
||||
static final DeepLinkService _instance = DeepLinkService._internal();
|
||||
factory DeepLinkService() => _instance;
|
||||
DeepLinkService._internal();
|
||||
|
||||
late AppLinks _appLinks;
|
||||
StreamSubscription<Uri>? _linkSubscription;
|
||||
|
||||
/// Initialize deep link handling (sets up stream listener only)
|
||||
Future<void> init() async {
|
||||
_appLinks = AppLinks();
|
||||
|
||||
// Set up stream listener for links when app is already running
|
||||
_linkSubscription = _appLinks.uriLinkStream.listen(
|
||||
_processLink,
|
||||
onError: (err) {
|
||||
debugPrint('[DeepLinkService] Link stream error: $err');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Process a deep link
|
||||
void _processLink(Uri uri) {
|
||||
debugPrint('[DeepLinkService] Processing deep link: $uri');
|
||||
|
||||
// Only handle deflockapp scheme
|
||||
if (uri.scheme != 'deflockapp') {
|
||||
debugPrint('[DeepLinkService] Ignoring non-deflockapp scheme: ${uri.scheme}');
|
||||
return;
|
||||
}
|
||||
|
||||
// Route based on path
|
||||
switch (uri.host) {
|
||||
case 'profiles':
|
||||
_handleProfilesLink(uri);
|
||||
break;
|
||||
case 'auth':
|
||||
// OAuth links are handled by flutter_web_auth_2
|
||||
debugPrint('[DeepLinkService] OAuth link handled by flutter_web_auth_2');
|
||||
break;
|
||||
default:
|
||||
debugPrint('[DeepLinkService] Unknown deep link host: ${uri.host}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for initial link after app is fully ready
|
||||
Future<void> checkInitialLink() async {
|
||||
debugPrint('[DeepLinkService] Checking for initial link...');
|
||||
|
||||
try {
|
||||
final initialLink = await _appLinks.getInitialLink();
|
||||
if (initialLink != null) {
|
||||
debugPrint('[DeepLinkService] Found initial link: $initialLink');
|
||||
_processLink(initialLink);
|
||||
} else {
|
||||
debugPrint('[DeepLinkService] No initial link found');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[DeepLinkService] Failed to get initial link: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle profile-related deep links
|
||||
void _handleProfilesLink(Uri uri) {
|
||||
final segments = uri.pathSegments;
|
||||
|
||||
if (segments.isEmpty) {
|
||||
debugPrint('[DeepLinkService] No path segments in profiles link');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (segments[0]) {
|
||||
case 'add':
|
||||
_handleAddProfileLink(uri);
|
||||
break;
|
||||
default:
|
||||
debugPrint('[DeepLinkService] Unknown profiles path: ${segments[0]}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle profile add deep link: `deflockapp://profiles/add?p=<base64>`
|
||||
void _handleAddProfileLink(Uri uri) {
|
||||
final base64Data = uri.queryParameters['p'];
|
||||
|
||||
if (base64Data == null || base64Data.isEmpty) {
|
||||
_showError('Invalid profile link: missing profile data');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse profile from base64
|
||||
final profile = ProfileImportService.parseProfileFromBase64(base64Data);
|
||||
|
||||
if (profile == null) {
|
||||
_showError('Invalid profile data');
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to profile editor with the imported profile
|
||||
_navigateToProfileEditor(profile);
|
||||
}
|
||||
|
||||
/// Navigate to profile editor with pre-filled profile data
|
||||
void _navigateToProfileEditor(NodeProfile profile) {
|
||||
final context = _navigatorKey?.currentContext;
|
||||
|
||||
if (context == null) {
|
||||
debugPrint('[DeepLinkService] No navigator context available');
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: profile),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show error message to user
|
||||
void _showError(String message) {
|
||||
final context = _navigatorKey?.currentContext;
|
||||
|
||||
if (context == null) {
|
||||
debugPrint('[DeepLinkService] Error (no context): $message');
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Global navigator key for navigation
|
||||
GlobalKey<NavigatorState>? _navigatorKey;
|
||||
|
||||
/// Set the global navigator key
|
||||
void setNavigatorKey(GlobalKey<NavigatorState> navigatorKey) {
|
||||
_navigatorKey = navigatorKey;
|
||||
}
|
||||
|
||||
/// Clean up resources
|
||||
void dispose() {
|
||||
_linkSubscription?.cancel();
|
||||
}
|
||||
}
|
||||
157
lib/services/deflock_tile_provider.dart
Normal file
157
lib/services/deflock_tile_provider.dart
Normal file
@@ -0,0 +1,157 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import 'map_data_provider.dart';
|
||||
import 'offline_area_service.dart';
|
||||
|
||||
/// Custom tile provider that integrates with DeFlock's offline/online architecture.
|
||||
///
|
||||
/// This replaces the complex HTTP interception approach with a clean TileProvider
|
||||
/// implementation that directly interfaces with our MapDataProvider system.
|
||||
class DeflockTileProvider extends TileProvider {
|
||||
final MapDataProvider _mapDataProvider = MapDataProvider();
|
||||
|
||||
@override
|
||||
ImageProvider getImage(TileCoordinates coordinates, TileLayer options) {
|
||||
// Get current provider info to include in cache key
|
||||
final appState = AppState.instance;
|
||||
final providerId = appState.selectedTileProvider?.id ?? 'unknown';
|
||||
final tileTypeId = appState.selectedTileType?.id ?? 'unknown';
|
||||
|
||||
return DeflockTileImageProvider(
|
||||
coordinates: coordinates,
|
||||
options: options,
|
||||
mapDataProvider: _mapDataProvider,
|
||||
providerId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Image provider that fetches tiles through our MapDataProvider.
|
||||
///
|
||||
/// This handles the actual tile fetching using our existing offline/online
|
||||
/// routing logic without any HTTP interception complexity.
|
||||
class DeflockTileImageProvider extends ImageProvider<DeflockTileImageProvider> {
|
||||
final TileCoordinates coordinates;
|
||||
final TileLayer options;
|
||||
final MapDataProvider mapDataProvider;
|
||||
final String providerId;
|
||||
final String tileTypeId;
|
||||
|
||||
const DeflockTileImageProvider({
|
||||
required this.coordinates,
|
||||
required this.options,
|
||||
required this.mapDataProvider,
|
||||
required this.providerId,
|
||||
required this.tileTypeId,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<DeflockTileImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture<DeflockTileImageProvider>(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(DeflockTileImageProvider key, ImageDecoderCallback decode) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _loadAsync(key, decode, chunkEvents),
|
||||
chunkEvents: chunkEvents.stream,
|
||||
scale: 1.0,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Codec> _loadAsync(
|
||||
DeflockTileImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
) async {
|
||||
try {
|
||||
// Get current tile provider and type from app state
|
||||
final appState = AppState.instance;
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
|
||||
if (selectedProvider == null || selectedTileType == null) {
|
||||
throw Exception('No tile provider configured');
|
||||
}
|
||||
|
||||
// Smart cache routing: only check offline cache when needed
|
||||
final MapSource source = _shouldCheckOfflineCache(appState)
|
||||
? MapSource.auto // Check offline first, then network
|
||||
: MapSource.remote; // Skip offline cache, go directly to network
|
||||
|
||||
final tileBytes = await mapDataProvider.getTile(
|
||||
z: coordinates.z,
|
||||
x: coordinates.x,
|
||||
y: coordinates.y,
|
||||
source: source,
|
||||
);
|
||||
|
||||
// Decode the image bytes
|
||||
final buffer = await ImmutableBuffer.fromUint8List(Uint8List.fromList(tileBytes));
|
||||
return await decode(buffer);
|
||||
|
||||
} catch (e) {
|
||||
// Don't log routine offline misses to avoid console spam
|
||||
if (!e.toString().contains('offline mode is enabled')) {
|
||||
debugPrint('[DeflockTileProvider] Failed to load tile ${coordinates.z}/${coordinates.x}/${coordinates.y}: $e');
|
||||
}
|
||||
|
||||
// Re-throw the exception and let FlutterMap handle missing tiles gracefully
|
||||
// This is better than trying to provide fallback images
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is DeflockTileImageProvider &&
|
||||
other.coordinates == coordinates &&
|
||||
other.providerId == providerId &&
|
||||
other.tileTypeId == tileTypeId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(coordinates, providerId, tileTypeId);
|
||||
|
||||
/// Determine if we should check offline cache for this tile request.
|
||||
/// Only check offline cache if:
|
||||
/// 1. We're in offline mode (forced), OR
|
||||
/// 2. We have offline areas for the current provider/type
|
||||
///
|
||||
/// This avoids expensive filesystem searches when browsing online
|
||||
/// with providers that have no offline areas.
|
||||
bool _shouldCheckOfflineCache(AppState appState) {
|
||||
// Always check offline cache in offline mode
|
||||
if (appState.offlineMode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For online mode, only check if we might actually have relevant offline data
|
||||
final currentProvider = appState.selectedTileProvider;
|
||||
final currentTileType = appState.selectedTileType;
|
||||
|
||||
if (currentProvider == null || currentTileType == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Quick check: do we have any offline areas for this provider/type?
|
||||
// This avoids the expensive per-tile filesystem search in fetchLocalTile
|
||||
final offlineService = OfflineAreaService();
|
||||
final hasRelevantAreas = offlineService.hasOfflineAreasForProvider(
|
||||
currentProvider.id,
|
||||
currentTileType.id,
|
||||
);
|
||||
|
||||
return hasRelevantAreas;
|
||||
}
|
||||
}
|
||||
87
lib/services/distance_service.dart
Normal file
87
lib/services/distance_service.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import '../state/settings_state.dart';
|
||||
|
||||
/// Service for distance unit conversions and formatting
|
||||
///
|
||||
/// Follows brutalist principles: simple, explicit conversions without fancy abstractions.
|
||||
/// All APIs work in metric units (meters/km), this service only handles display formatting.
|
||||
class DistanceService {
|
||||
// Conversion constants
|
||||
static const double _metersToFeet = 3.28084;
|
||||
static const double _metersToMiles = 0.000621371;
|
||||
|
||||
/// Format distance for display based on unit preference
|
||||
///
|
||||
/// For metric: uses meters for < 1000m, kilometers for >= 1000m
|
||||
/// For imperial: uses feet for < 5280ft (1 mile), miles for >= 5280ft
|
||||
static String formatDistance(double distanceInMeters, DistanceUnit unit) {
|
||||
switch (unit) {
|
||||
case DistanceUnit.metric:
|
||||
if (distanceInMeters < 1000) {
|
||||
return '${distanceInMeters.round()} m';
|
||||
} else {
|
||||
return '${(distanceInMeters / 1000).toStringAsFixed(1)} km';
|
||||
}
|
||||
|
||||
case DistanceUnit.imperial:
|
||||
final distanceInFeet = distanceInMeters * _metersToFeet;
|
||||
if (distanceInFeet < 5280) {
|
||||
return '${distanceInFeet.round()} ft';
|
||||
} else {
|
||||
final distanceInMiles = distanceInMeters * _metersToMiles;
|
||||
return '${distanceInMiles.toStringAsFixed(1)} mi';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format large distances (like route distances) for display
|
||||
///
|
||||
/// Always uses the larger unit (km/miles) for routes
|
||||
static String formatRouteDistance(double distanceInMeters, DistanceUnit unit) {
|
||||
switch (unit) {
|
||||
case DistanceUnit.metric:
|
||||
return '${(distanceInMeters / 1000).toStringAsFixed(1)} km';
|
||||
|
||||
case DistanceUnit.imperial:
|
||||
final distanceInMiles = distanceInMeters * _metersToMiles;
|
||||
return '${distanceInMiles.toStringAsFixed(1)} mi';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the unit suffix for small distances (used in form fields, etc.)
|
||||
static String getSmallDistanceUnit(DistanceUnit unit) {
|
||||
switch (unit) {
|
||||
case DistanceUnit.metric:
|
||||
return 'm';
|
||||
case DistanceUnit.imperial:
|
||||
return 'ft';
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert displayed distance value back to meters for API usage
|
||||
///
|
||||
/// This is for form fields where users enter values in their preferred units
|
||||
static double convertToMeters(double value, DistanceUnit unit, {bool isSmallDistance = true}) {
|
||||
switch (unit) {
|
||||
case DistanceUnit.metric:
|
||||
return isSmallDistance ? value : value * 1000; // m or km to m
|
||||
|
||||
case DistanceUnit.imperial:
|
||||
if (isSmallDistance) {
|
||||
return value / _metersToFeet; // ft to m
|
||||
} else {
|
||||
return value / _metersToMiles; // miles to m
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert meters to the preferred small distance unit for form display
|
||||
static double convertFromMeters(double meters, DistanceUnit unit) {
|
||||
switch (unit) {
|
||||
case DistanceUnit.metric:
|
||||
return meters;
|
||||
|
||||
case DistanceUnit.imperial:
|
||||
return meters * _metersToFeet;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,42 +25,32 @@ class LocalizationService extends ChangeNotifier {
|
||||
|
||||
Future<void> _discoverAvailableLanguages() async {
|
||||
_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'))
|
||||
final assetManifest = await AssetManifest.loadFromAssetBundle(rootBundle);
|
||||
final localizationAssets = assetManifest.listAssets()
|
||||
.where((path) => path.startsWith('lib/localizations/') && path.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'
|
||||
|
||||
|
||||
for (final assetPath in localizationAssets) {
|
||||
try {
|
||||
// Try to load and parse the file to ensure it's valid
|
||||
final jsonString = await rootBundle.loadString(filePath);
|
||||
final jsonString = await rootBundle.loadString(assetPath);
|
||||
final parsedJson = json.decode(jsonString);
|
||||
|
||||
// Basic validation - ensure it has the expected structure
|
||||
|
||||
if (parsedJson is Map && parsedJson.containsKey('language')) {
|
||||
final languageCode = assetPath.split('/').last.replaceAll('.json', '');
|
||||
_availableLanguages.add(languageCode);
|
||||
debugPrint('Found localization: $languageCode');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to load localization file $filePath: $e');
|
||||
debugPrint('Failed to load localization file $assetPath: $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('Failed to load asset manifest: $e');
|
||||
_availableLanguages = ['en'];
|
||||
}
|
||||
|
||||
|
||||
debugPrint('Available languages: $_availableLanguages');
|
||||
}
|
||||
|
||||
@@ -119,28 +109,31 @@ class LocalizationService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String t(String key, {List<String>? params}) {
|
||||
String t(String key, {List<String>? params}) =>
|
||||
lookup(_strings, key, params: params);
|
||||
|
||||
/// Pure lookup function used by [t] and available for testing.
|
||||
static String lookup(Map<String, dynamic> strings, String key,
|
||||
{List<String>? params}) {
|
||||
List<String> keys = key.split('.');
|
||||
dynamic current = _strings;
|
||||
|
||||
dynamic current = strings;
|
||||
|
||||
for (String k in keys) {
|
||||
if (current is Map && current.containsKey(k)) {
|
||||
current = current[k];
|
||||
} else {
|
||||
// Return the key as fallback for missing translations
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
String result = current is String ? current : key;
|
||||
|
||||
// Replace parameters if provided - replace first occurrence only for each parameter
|
||||
|
||||
if (params != null) {
|
||||
for (int i = 0; i < params.length; i++) {
|
||||
result = result.replaceFirst('{}', params[i]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/node_profile.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';
|
||||
import 'prefetch_area_service.dart';
|
||||
import 'node_data_manager.dart';
|
||||
import 'node_spatial_cache.dart';
|
||||
|
||||
enum MapSource { local, remote, auto } // For future use
|
||||
|
||||
@@ -27,107 +23,31 @@ class MapDataProvider {
|
||||
factory MapDataProvider() => _instance;
|
||||
MapDataProvider._();
|
||||
|
||||
// REMOVED: AppState get _appState => AppState();
|
||||
final NodeDataManager _nodeDataManager = NodeDataManager();
|
||||
|
||||
bool get isOfflineMode => AppState.instance.offlineMode;
|
||||
void setOfflineMode(bool enabled) {
|
||||
AppState.instance.setOfflineMode(enabled);
|
||||
}
|
||||
|
||||
/// Fetch surveillance nodes from OSM/Overpass or local storage.
|
||||
/// Remote is default. If source is MapSource.auto, remote is tried first unless offline.
|
||||
/// Fetch surveillance nodes using the new simplified system.
|
||||
/// Returns cached data immediately if available, otherwise fetches from appropriate source.
|
||||
Future<List<OsmNode>> getNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
MapSource source = MapSource.auto,
|
||||
bool isUserInitiated = false,
|
||||
}) async {
|
||||
final offline = AppState.instance.offlineMode;
|
||||
|
||||
// Explicit remote request: error if offline, else always remote
|
||||
if (source == MapSource.remote) {
|
||||
if (offline) {
|
||||
throw OfflineModeException("Cannot fetch remote nodes in offline mode.");
|
||||
}
|
||||
return _fetchRemoteNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: AppState.instance.maxCameras,
|
||||
);
|
||||
}
|
||||
|
||||
// Explicit local request: always use local
|
||||
if (source == MapSource.local) {
|
||||
return fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
);
|
||||
}
|
||||
|
||||
// AUTO: In offline mode, behavior depends on upload mode
|
||||
if (offline) {
|
||||
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,
|
||||
maxNodes: AppState.instance.maxCameras,
|
||||
);
|
||||
}
|
||||
} 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: use pre-fetch service for efficient area loading
|
||||
final preFetchService = PrefetchAreaService();
|
||||
|
||||
// Always get local nodes first (fast, from cache)
|
||||
final localNodes = await fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxNodes: AppState.instance.maxCameras,
|
||||
);
|
||||
|
||||
// Check if we need to trigger a new pre-fetch (spatial or temporal)
|
||||
final needsFetch = !preFetchService.isWithinPreFetchedArea(bounds, profiles, uploadMode) ||
|
||||
preFetchService.isDataStale();
|
||||
|
||||
if (needsFetch) {
|
||||
// Outside area OR data stale - start pre-fetch with loading state
|
||||
debugPrint('[MapDataProvider] Starting pre-fetch with loading state');
|
||||
NetworkStatus.instance.setWaiting();
|
||||
preFetchService.requestPreFetchIfNeeded(
|
||||
viewBounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
);
|
||||
} else {
|
||||
debugPrint('[MapDataProvider] Using existing fresh pre-fetched area cache');
|
||||
}
|
||||
|
||||
// Apply rendering limit and warn if nodes are being excluded
|
||||
final maxNodes = AppState.instance.maxCameras;
|
||||
if (localNodes.length > maxNodes) {
|
||||
NetworkStatus.instance.reportNodeLimitReached(localNodes.length, maxNodes);
|
||||
}
|
||||
|
||||
return localNodes.take(maxNodes).toList();
|
||||
}
|
||||
return _nodeDataManager.getNodesFor(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
isUserInitiated: isUserInitiated,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Bulk node fetch for offline downloads using new system
|
||||
Future<List<OsmNode>> getAllNodesForDownload({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
@@ -135,16 +55,12 @@ class MapDataProvider {
|
||||
int maxResults = 0, // 0 = no limit for offline downloads
|
||||
int maxTries = 3,
|
||||
}) async {
|
||||
final offline = AppState.instance.offlineMode;
|
||||
if (offline) {
|
||||
if (AppState.instance.offlineMode) {
|
||||
throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode.");
|
||||
}
|
||||
return _fetchRemoteNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults, // Pass 0 for unlimited
|
||||
);
|
||||
|
||||
// For downloads, always fetch fresh data (don't use cache)
|
||||
return _nodeDataManager.fetchWithSplitting(bounds, profiles);
|
||||
}
|
||||
|
||||
/// Fetch tile image bytes. Default is to try local first, then remote if not offline. Honors explicit source.
|
||||
@@ -206,57 +122,48 @@ class MapDataProvider {
|
||||
clearRemoteTileQueueSelective(currentBounds);
|
||||
}
|
||||
|
||||
/// Fetch remote nodes with Overpass first, OSM API fallback
|
||||
Future<List<OsmNode>> _fetchRemoteNodes({
|
||||
/// Add or update nodes in cache (for upload queue integration)
|
||||
void addOrUpdateNodes(List<OsmNode> nodes) {
|
||||
_nodeDataManager.addOrUpdateNodes(nodes);
|
||||
}
|
||||
|
||||
/// NodeCache compatibility - alias for addOrUpdateNodes
|
||||
void addOrUpdate(List<OsmNode> nodes) {
|
||||
addOrUpdateNodes(nodes);
|
||||
}
|
||||
|
||||
/// Remove node from cache (for deletions)
|
||||
void removeNodeById(int nodeId) {
|
||||
_nodeDataManager.removeNodeById(nodeId);
|
||||
}
|
||||
|
||||
/// Clear cache (when profiles change)
|
||||
void clearCache() {
|
||||
_nodeDataManager.clearCache();
|
||||
}
|
||||
|
||||
/// Force refresh current area (manual retry)
|
||||
Future<void> refreshArea({
|
||||
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,
|
||||
);
|
||||
}
|
||||
return _nodeDataManager.refreshArea(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
);
|
||||
}
|
||||
|
||||
/// NodeCache compatibility methods for upload queue
|
||||
/// These all delegate to the singleton cache to ensure consistency
|
||||
OsmNode? getNodeById(int nodeId) => NodeSpatialCache().getNodeById(nodeId);
|
||||
void removePendingEditMarker(int nodeId) => NodeSpatialCache().removePendingEditMarker(nodeId);
|
||||
void removePendingDeletionMarker(int nodeId) => NodeSpatialCache().removePendingDeletionMarker(nodeId);
|
||||
void removeTempNodeById(int tempNodeId) => NodeSpatialCache().removeTempNodeById(tempNodeId);
|
||||
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) =>
|
||||
NodeSpatialCache().findNodesWithinDistance(coord, distanceMeters, excludeNodeId: excludeNodeId);
|
||||
|
||||
/// Check if we have good cache coverage for the given area (prevents submission in uncovered areas)
|
||||
bool hasGoodCoverageFor(LatLngBounds bounds) => NodeSpatialCache().hasDataFor(bounds);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
@@ -8,7 +7,6 @@ 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).
|
||||
@@ -20,6 +18,28 @@ Future<List<OsmNode>> fetchOsmApiNodes({
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
try {
|
||||
final nodes = await _fetchFromOsmApi(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults,
|
||||
);
|
||||
|
||||
return nodes;
|
||||
} catch (e) {
|
||||
debugPrint('[fetchOsmApiNodes] OSM API operation failed: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal method that performs the actual OSM API fetch.
|
||||
Future<List<OsmNode>> _fetchFromOsmApi({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
required int maxResults,
|
||||
}) async {
|
||||
// Choose API endpoint based on upload mode
|
||||
final String apiHost = uploadMode == UploadMode.sandbox
|
||||
? 'api06.dev.openstreetmap.org'
|
||||
@@ -41,8 +61,7 @@ Future<List<OsmNode>> fetchOsmApiNodes({
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
debugPrint('[fetchOsmApiNodes] OSM API error: ${response.statusCode} - ${response.body}');
|
||||
NetworkStatus.instance.reportOverpassIssue(); // Reuse same status tracking
|
||||
return [];
|
||||
throw Exception('OSM API error: ${response.statusCode} - ${response.body}');
|
||||
}
|
||||
|
||||
// Parse XML response
|
||||
@@ -53,20 +72,14 @@ Future<List<OsmNode>> fetchOsmApiNodes({
|
||||
debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes');
|
||||
}
|
||||
|
||||
NetworkStatus.instance.reportOverpassSuccess(); // Reuse same status tracking
|
||||
// Don't report success here - let the top level handle it
|
||||
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 [];
|
||||
// Don't report status here - let the top level handle it
|
||||
rethrow; // Re-throw to let caller handle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +183,11 @@ bool _nodeMatchesProfiles(Map<String, String> nodeTags, List<NodeProfile> profil
|
||||
/// 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
|
||||
// Skip empty values as they are for refinement purposes only
|
||||
for (final entry in profile.tags.entries) {
|
||||
if (entry.value.trim().isEmpty) {
|
||||
continue; // Skip empty values - they don't need to match anything
|
||||
}
|
||||
if (nodeTags[entry.key] != entry.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
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 '../../models/node_profile.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../models/pending_upload.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
import '../network_status.dart';
|
||||
import '../overpass_node_limit_exception.dart';
|
||||
|
||||
/// Fetches surveillance nodes from the Overpass OSM API for the given bounds and profiles.
|
||||
/// If the query fails due to too many nodes, automatically splits the area and retries.
|
||||
Future<List<OsmNode>> fetchOverpassNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
required int maxResults,
|
||||
}) async {
|
||||
// Check if this is a user-initiated fetch (indicated by loading state)
|
||||
final wasUserInitiated = NetworkStatus.instance.currentStatus == NetworkStatusType.waiting;
|
||||
|
||||
return _fetchOverpassNodesWithSplitting(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults,
|
||||
splitDepth: 0,
|
||||
wasUserInitiated: wasUserInitiated,
|
||||
);
|
||||
}
|
||||
|
||||
/// Internal method that handles splitting when node limit is exceeded.
|
||||
Future<List<OsmNode>> _fetchOverpassNodesWithSplitting({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
required int maxResults,
|
||||
required int splitDepth,
|
||||
required bool wasUserInitiated,
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
const int maxSplitDepth = kMaxPreFetchSplitDepth; // Maximum times we'll split (4^3 = 64 max sub-areas)
|
||||
|
||||
try {
|
||||
return await _fetchSingleOverpassQuery(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxResults: maxResults,
|
||||
);
|
||||
} on OverpassRateLimitException catch (e) {
|
||||
// Rate limits should NOT be split - just fail with extended backoff
|
||||
debugPrint('[fetchOverpassNodes] Rate limited - using extended backoff, not splitting');
|
||||
|
||||
// Report error if user was waiting
|
||||
if (wasUserInitiated) {
|
||||
NetworkStatus.instance.setNetworkError();
|
||||
}
|
||||
|
||||
// Wait longer for rate limits before giving up entirely
|
||||
await Future.delayed(const Duration(seconds: 30));
|
||||
return []; // Return empty rather than rethrowing
|
||||
} on OverpassNodeLimitException {
|
||||
// If we've hit max split depth, give up to avoid infinite recursion
|
||||
if (splitDepth >= maxSplitDepth) {
|
||||
debugPrint('[fetchOverpassNodes] Max split depth reached, giving up on area: $bounds');
|
||||
// Report timeout if this was user-initiated (can't split further)
|
||||
if (wasUserInitiated) {
|
||||
NetworkStatus.instance.setTimeoutError();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Split the bounds into 4 quadrants and try each separately
|
||||
debugPrint('[fetchOverpassNodes] Splitting area into quadrants (depth: $splitDepth)');
|
||||
final quadrants = _splitBounds(bounds);
|
||||
final List<OsmNode> allNodes = [];
|
||||
|
||||
for (final quadrant in quadrants) {
|
||||
final nodes = await _fetchOverpassNodesWithSplitting(
|
||||
bounds: quadrant,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: 0, // No limit on individual quadrants to avoid double-limiting
|
||||
splitDepth: splitDepth + 1,
|
||||
wasUserInitiated: wasUserInitiated,
|
||||
);
|
||||
allNodes.addAll(nodes);
|
||||
}
|
||||
|
||||
debugPrint('[fetchOverpassNodes] Collected ${allNodes.length} nodes from ${quadrants.length} quadrants');
|
||||
return allNodes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform a single Overpass query without splitting logic.
|
||||
Future<List<OsmNode>> _fetchSingleOverpassQuery({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
required int maxResults,
|
||||
}) async {
|
||||
const String overpassEndpoint = 'https://overpass-api.de/api/interpreter';
|
||||
|
||||
// Build the Overpass query
|
||||
final query = _buildOverpassQuery(bounds, profiles, maxResults);
|
||||
|
||||
try {
|
||||
debugPrint('[fetchOverpassNodes] Querying Overpass for surveillance nodes...');
|
||||
debugPrint('[fetchOverpassNodes] Query:\n$query');
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(overpassEndpoint),
|
||||
body: {'data': query.trim()}
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
final errorBody = response.body;
|
||||
debugPrint('[fetchOverpassNodes] Overpass API error: $errorBody');
|
||||
|
||||
// Check if it's specifically the 50k node limit error (HTTP 400)
|
||||
// Exact message: "You requested too many nodes (limit is 50000)"
|
||||
if (errorBody.contains('too many nodes') &&
|
||||
errorBody.contains('50000')) {
|
||||
debugPrint('[fetchOverpassNodes] Detected 50k node limit error, will attempt splitting');
|
||||
throw OverpassNodeLimitException('Query exceeded node limit', serverResponse: errorBody);
|
||||
}
|
||||
|
||||
// Check for timeout errors that indicate query complexity (should split)
|
||||
// Common timeout messages from Overpass
|
||||
if (errorBody.contains('timeout') ||
|
||||
errorBody.contains('runtime limit exceeded') ||
|
||||
errorBody.contains('Query timed out')) {
|
||||
debugPrint('[fetchOverpassNodes] Detected timeout error, will attempt splitting to reduce complexity');
|
||||
throw OverpassNodeLimitException('Query timed out', serverResponse: errorBody);
|
||||
}
|
||||
|
||||
// Check for rate limiting (should NOT split - needs longer backoff)
|
||||
if (errorBody.contains('rate limited') ||
|
||||
errorBody.contains('too many requests') ||
|
||||
response.statusCode == 429) {
|
||||
debugPrint('[fetchOverpassNodes] Rate limited by Overpass API - needs extended backoff');
|
||||
throw OverpassRateLimitException('Rate limited by server', serverResponse: errorBody);
|
||||
}
|
||||
|
||||
NetworkStatus.instance.reportOverpassIssue();
|
||||
return [];
|
||||
}
|
||||
|
||||
final data = await compute(jsonDecode, response.body) as Map<String, dynamic>;
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
|
||||
if (elements.length > 20) {
|
||||
debugPrint('[fetchOverpassNodes] Retrieved ${elements.length} elements (nodes + ways/relations)');
|
||||
}
|
||||
|
||||
NetworkStatus.instance.reportOverpassSuccess();
|
||||
|
||||
// Parse response to determine which nodes are constrained
|
||||
final nodes = _parseOverpassResponseWithConstraints(elements);
|
||||
|
||||
// Clean up any pending uploads that now appear in Overpass results
|
||||
_cleanupCompletedUploads(nodes);
|
||||
|
||||
return nodes;
|
||||
|
||||
} catch (e) {
|
||||
// Re-throw OverpassNodeLimitException so splitting logic can catch it
|
||||
if (e is OverpassNodeLimitException) rethrow;
|
||||
|
||||
debugPrint('[fetchOverpassNodes] 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an Overpass API query for surveillance nodes matching the given profiles within bounds.
|
||||
/// Also fetches ways and relations that reference these nodes to determine constraint status.
|
||||
String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int maxResults) {
|
||||
// Build node clauses for each profile
|
||||
final nodeClauses = profiles.map((profile) {
|
||||
// Convert profile tags to Overpass filter format
|
||||
final tagFilters = profile.tags.entries
|
||||
.map((entry) => '["${entry.key}"="${entry.value}"]')
|
||||
.join();
|
||||
|
||||
// Build the node query with tag filters and bounding box
|
||||
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
|
||||
}).join('\n ');
|
||||
|
||||
return '''
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
$nodeClauses
|
||||
);
|
||||
out body ${maxResults > 0 ? maxResults : ''};
|
||||
(
|
||||
way(bn);
|
||||
rel(bn);
|
||||
);
|
||||
out meta;
|
||||
''';
|
||||
}
|
||||
|
||||
/// Split a LatLngBounds into 4 quadrants (NW, NE, SW, SE).
|
||||
List<LatLngBounds> _splitBounds(LatLngBounds bounds) {
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
final centerLng = (bounds.east + bounds.west) / 2;
|
||||
|
||||
return [
|
||||
// Southwest quadrant (bottom-left)
|
||||
LatLngBounds(
|
||||
LatLng(bounds.south, bounds.west),
|
||||
LatLng(centerLat, centerLng),
|
||||
),
|
||||
// Southeast quadrant (bottom-right)
|
||||
LatLngBounds(
|
||||
LatLng(bounds.south, centerLng),
|
||||
LatLng(centerLat, bounds.east),
|
||||
),
|
||||
// Northwest quadrant (top-left)
|
||||
LatLngBounds(
|
||||
LatLng(centerLat, bounds.west),
|
||||
LatLng(bounds.north, centerLng),
|
||||
),
|
||||
// Northeast quadrant (top-right)
|
||||
LatLngBounds(
|
||||
LatLng(centerLat, centerLng),
|
||||
LatLng(bounds.north, bounds.east),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Parse Overpass response elements to create OsmNode objects with constraint information.
|
||||
List<OsmNode> _parseOverpassResponseWithConstraints(List<dynamic> elements) {
|
||||
final nodeElements = <Map<String, dynamic>>[];
|
||||
final constrainedNodeIds = <int>{};
|
||||
|
||||
// First pass: collect surveillance nodes and identify constrained nodes
|
||||
for (final element in elements.whereType<Map<String, dynamic>>()) {
|
||||
final type = element['type'] as String?;
|
||||
|
||||
if (type == 'node') {
|
||||
// This is a surveillance node - collect it
|
||||
nodeElements.add(element);
|
||||
} else if (type == 'way' || type == 'relation') {
|
||||
// This is a way/relation that references some of our nodes
|
||||
final refs = element['nodes'] as List<dynamic>? ??
|
||||
element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? [];
|
||||
|
||||
// Mark all referenced nodes as constrained
|
||||
for (final ref in refs) {
|
||||
if (ref is int) {
|
||||
constrainedNodeIds.add(ref);
|
||||
} else if (ref is String) {
|
||||
final nodeId = int.tryParse(ref);
|
||||
if (nodeId != null) constrainedNodeIds.add(nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: create OsmNode objects with constraint info
|
||||
final nodes = nodeElements.map((element) {
|
||||
final nodeId = element['id'] as int;
|
||||
final isConstrained = constrainedNodeIds.contains(nodeId);
|
||||
|
||||
return OsmNode(
|
||||
id: nodeId,
|
||||
coord: LatLng(element['lat'], element['lon']),
|
||||
tags: Map<String, String>.from(element['tags'] ?? {}),
|
||||
isConstrained: isConstrained,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final constrainedCount = nodes.where((n) => n.isConstrained).length;
|
||||
if (constrainedCount > 0) {
|
||||
debugPrint('[fetchOverpassNodes] Found $constrainedCount constrained nodes out of ${nodes.length} total');
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../offline_area_service.dart';
|
||||
import '../offline_areas/offline_area_models.dart';
|
||||
import '../offline_areas/offline_tile_utils.dart';
|
||||
|
||||
@@ -6,10 +6,9 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:deflockapp/dev_config.dart';
|
||||
import '../network_status.dart';
|
||||
|
||||
/// Global semaphore to limit simultaneous tile fetches
|
||||
final _tileFetchSemaphore = _SimpleSemaphore(4); // Max 4 concurrent
|
||||
final _tileFetchSemaphore = _SimpleSemaphore(kTileFetchConcurrentThreads);
|
||||
|
||||
/// Clear queued tile requests when map view changes significantly
|
||||
void clearRemoteTileQueue() {
|
||||
@@ -35,7 +34,7 @@ void clearRemoteTileQueueSelective(LatLngBounds currentBounds) {
|
||||
/// Calculate retry delay using configurable backoff strategy.
|
||||
/// Uses: initialDelay * (multiplier ^ (attempt - 1)) + randomJitter, capped at maxDelay
|
||||
int _calculateRetryDelay(int attempt, Random random) {
|
||||
// Calculate exponential backoff: initialDelay * (multiplier ^ (attempt - 1))
|
||||
// Calculate exponential backoff
|
||||
final baseDelay = (kTileFetchInitialDelayMs *
|
||||
pow(kTileFetchBackoffMultiplier, attempt - 1)).round();
|
||||
|
||||
@@ -93,15 +92,15 @@ bool _isTileVisible(int z, int x, int y, LatLngBounds viewBounds) {
|
||||
|
||||
|
||||
|
||||
/// Fetches a tile from any remote provider, with in-memory retries/backoff, and global concurrency limit.
|
||||
/// Returns tile image bytes, or throws on persistent failure.
|
||||
/// Fetches a tile from any remote provider with unlimited retries.
|
||||
/// Returns tile image bytes. Retries forever until success.
|
||||
/// Brutalist approach: Keep trying until it works - no arbitrary retry limits.
|
||||
Future<List<int>> fetchRemoteTile({
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
required String url,
|
||||
}) async {
|
||||
const int maxAttempts = kTileFetchMaxAttempts;
|
||||
int attempt = 0;
|
||||
final random = Random();
|
||||
final hostInfo = Uri.parse(url).host; // For logging
|
||||
@@ -109,42 +108,35 @@ Future<List<int>> fetchRemoteTile({
|
||||
while (true) {
|
||||
await _tileFetchSemaphore.acquire(z: z, x: x, y: y);
|
||||
try {
|
||||
// Only log on first attempt or errors
|
||||
if (attempt == 1) {
|
||||
// Only log on first attempt
|
||||
if (attempt == 0) {
|
||||
debugPrint('[fetchRemoteTile] Fetching $z/$x/$y from $hostInfo');
|
||||
}
|
||||
attempt++;
|
||||
final resp = await http.get(Uri.parse(url));
|
||||
|
||||
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
|
||||
// Success - no logging for normal operation
|
||||
NetworkStatus.instance.reportOsmTileSuccess(); // Generic tile server reporting
|
||||
// Success!
|
||||
if (attempt > 1) {
|
||||
debugPrint('[fetchRemoteTile] SUCCESS $z/$x/$y from $hostInfo after $attempt attempts');
|
||||
}
|
||||
return resp.bodyBytes;
|
||||
} else {
|
||||
debugPrint('[fetchRemoteTile] FAIL $z/$x/$y from $hostInfo: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
|
||||
NetworkStatus.instance.reportOsmTileIssue(); // Generic tile server reporting
|
||||
throw HttpException('Failed to fetch tile $z/$x/$y from $hostInfo: status ${resp.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
// Report network issues on connection errors
|
||||
if (e.toString().contains('Connection refused') ||
|
||||
e.toString().contains('Connection timed out') ||
|
||||
e.toString().contains('Connection reset')) {
|
||||
NetworkStatus.instance.reportOsmTileIssue(); // Generic tile server reporting
|
||||
}
|
||||
|
||||
if (attempt >= maxAttempts) {
|
||||
debugPrint("[fetchRemoteTile] Failed for $z/$x/$y from $hostInfo after $attempt attempts: $e");
|
||||
rethrow;
|
||||
}
|
||||
|
||||
// Calculate delay and retry (no attempt limit - keep trying forever)
|
||||
final delay = _calculateRetryDelay(attempt, random);
|
||||
if (attempt == 1) {
|
||||
debugPrint("[fetchRemoteTile] Attempt $attempt for $z/$x/$y from $hostInfo failed: $e. Retrying in ${delay}ms.");
|
||||
} else if (attempt % 10 == 0) {
|
||||
// Log every 10th attempt to show we're still working
|
||||
debugPrint("[fetchRemoteTile] Still trying $z/$x/$y from $hostInfo (attempt $attempt). Retrying in ${delay}ms.");
|
||||
}
|
||||
await Future.delayed(Duration(milliseconds: delay));
|
||||
} finally {
|
||||
_tileFetchSemaphore.release();
|
||||
_tileFetchSemaphore.release(z: z, x: x, y: y);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,18 +164,40 @@ class _TileRequest {
|
||||
_TileRequest({required this.z, required this.x, required this.y, required this.callback});
|
||||
}
|
||||
|
||||
/// Spatially-aware counting semaphore for tile requests
|
||||
/// Spatially-aware counting semaphore for tile requests with deduplication
|
||||
class _SimpleSemaphore {
|
||||
final int _max;
|
||||
int _current = 0;
|
||||
final List<_TileRequest> _queue = [];
|
||||
final Set<String> _inFlightTiles = {}; // Track in-flight requests for deduplication
|
||||
_SimpleSemaphore(this._max);
|
||||
|
||||
Future<void> acquire({int? z, int? x, int? y}) async {
|
||||
// Create tile key for deduplication
|
||||
final tileKey = '${z ?? -1}/${x ?? -1}/${y ?? -1}';
|
||||
|
||||
// If this tile is already in flight, skip the request
|
||||
if (_inFlightTiles.contains(tileKey)) {
|
||||
debugPrint('[SimpleSemaphore] Skipping duplicate request for $tileKey');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to in-flight tracking
|
||||
_inFlightTiles.add(tileKey);
|
||||
|
||||
if (_current < _max) {
|
||||
_current++;
|
||||
return;
|
||||
} else {
|
||||
// Check queue size limit to prevent memory bloat
|
||||
if (_queue.length >= kTileFetchMaxQueueSize) {
|
||||
// Remove oldest request to make room
|
||||
final oldRequest = _queue.removeAt(0);
|
||||
final oldKey = '${oldRequest.z}/${oldRequest.x}/${oldRequest.y}';
|
||||
_inFlightTiles.remove(oldKey);
|
||||
debugPrint('[SimpleSemaphore] Queue full, dropped oldest request: $oldKey');
|
||||
}
|
||||
|
||||
final c = Completer<void>();
|
||||
final request = _TileRequest(
|
||||
z: z ?? -1,
|
||||
@@ -196,7 +210,11 @@ class _SimpleSemaphore {
|
||||
}
|
||||
}
|
||||
|
||||
void release() {
|
||||
void release({int? z, int? x, int? y}) {
|
||||
// Remove from in-flight tracking
|
||||
final tileKey = '${z ?? -1}/${x ?? -1}/${y ?? -1}';
|
||||
_inFlightTiles.remove(tileKey);
|
||||
|
||||
if (_queue.isNotEmpty) {
|
||||
final request = _queue.removeAt(0);
|
||||
request.callback();
|
||||
@@ -209,19 +227,37 @@ class _SimpleSemaphore {
|
||||
int clearQueue() {
|
||||
final clearedCount = _queue.length;
|
||||
_queue.clear();
|
||||
_inFlightTiles.clear(); // Also clear deduplication tracking
|
||||
return clearedCount;
|
||||
}
|
||||
|
||||
/// Clear only tiles that don't pass the visibility filter
|
||||
int clearStaleRequests(bool Function(int z, int x, int y) isStale) {
|
||||
final initialCount = _queue.length;
|
||||
_queue.removeWhere((request) => isStale(request.z, request.x, request.y));
|
||||
final clearedCount = initialCount - _queue.length;
|
||||
final initialInFlightCount = _inFlightTiles.length;
|
||||
|
||||
if (clearedCount > 0) {
|
||||
debugPrint('[SimpleSemaphore] Cleared $clearedCount stale tile requests, kept ${_queue.length}');
|
||||
// Remove stale requests from queue
|
||||
_queue.removeWhere((request) => isStale(request.z, request.x, request.y));
|
||||
|
||||
// Remove stale tiles from in-flight tracking
|
||||
_inFlightTiles.removeWhere((tileKey) {
|
||||
final parts = tileKey.split('/');
|
||||
if (parts.length == 3) {
|
||||
final z = int.tryParse(parts[0]) ?? -1;
|
||||
final x = int.tryParse(parts[1]) ?? -1;
|
||||
final y = int.tryParse(parts[2]) ?? -1;
|
||||
return isStale(z, x, y);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
final queueClearedCount = initialCount - _queue.length;
|
||||
final inFlightClearedCount = initialInFlightCount - _inFlightTiles.length;
|
||||
|
||||
if (queueClearedCount > 0 || inFlightClearedCount > 0) {
|
||||
debugPrint('[SimpleSemaphore] Cleared $queueClearedCount stale queue + $inFlightClearedCount stale in-flight, kept ${_queue.length}');
|
||||
}
|
||||
|
||||
return clearedCount;
|
||||
return queueClearedCount + inFlightClearedCount;
|
||||
}
|
||||
}
|
||||
@@ -1,282 +1,117 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import '../app_state.dart';
|
||||
|
||||
enum NetworkIssueType { osmTiles, overpassApi, both }
|
||||
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success, nodeLimitReached }
|
||||
|
||||
|
||||
/// Simple enum-based network status for surveillance data requests.
|
||||
/// Only tracks the latest user-initiated request - background requests are ignored.
|
||||
enum NetworkRequestStatus {
|
||||
idle, // No active requests
|
||||
loading, // Request in progress
|
||||
splitting, // Request being split due to limits/timeouts
|
||||
success, // Data loaded successfully
|
||||
timeout, // Request timed out
|
||||
rateLimited, // API rate limited
|
||||
noData, // No offline data available
|
||||
error, // Other network errors
|
||||
}
|
||||
|
||||
class NetworkStatus extends ChangeNotifier {
|
||||
static final NetworkStatus instance = NetworkStatus._();
|
||||
NetworkStatus._();
|
||||
|
||||
bool _osmTilesHaveIssues = false;
|
||||
bool _overpassHasIssues = false;
|
||||
bool _isWaitingForData = false;
|
||||
bool _isTimedOut = false;
|
||||
bool _hasNoData = false;
|
||||
bool _hasSuccess = false;
|
||||
int _recentOfflineMisses = 0;
|
||||
Timer? _osmRecoveryTimer;
|
||||
Timer? _overpassRecoveryTimer;
|
||||
Timer? _waitingTimer;
|
||||
Timer? _noDataResetTimer;
|
||||
Timer? _successResetTimer;
|
||||
bool _nodeLimitReached = false;
|
||||
Timer? _nodeLimitResetTimer;
|
||||
|
||||
// Getters
|
||||
bool get hasAnyIssues => _osmTilesHaveIssues || _overpassHasIssues;
|
||||
bool get osmTilesHaveIssues => _osmTilesHaveIssues;
|
||||
bool get overpassHasIssues => _overpassHasIssues;
|
||||
bool get isWaitingForData => _isWaitingForData;
|
||||
bool get isTimedOut => _isTimedOut;
|
||||
bool get hasNoData => _hasNoData;
|
||||
bool get hasSuccess => _hasSuccess;
|
||||
bool get nodeLimitReached => _nodeLimitReached;
|
||||
NetworkRequestStatus _status = NetworkRequestStatus.idle;
|
||||
Timer? _autoResetTimer;
|
||||
|
||||
NetworkStatusType get currentStatus {
|
||||
// Simple single-path status logic
|
||||
if (hasAnyIssues) return NetworkStatusType.issues;
|
||||
if (_isWaitingForData) return NetworkStatusType.waiting;
|
||||
if (_isTimedOut) return NetworkStatusType.timedOut;
|
||||
if (_hasNoData) return NetworkStatusType.noData;
|
||||
if (_hasSuccess) return NetworkStatusType.success;
|
||||
if (_nodeLimitReached) return NetworkStatusType.nodeLimitReached;
|
||||
return NetworkStatusType.ready;
|
||||
}
|
||||
|
||||
NetworkIssueType? get currentIssueType {
|
||||
if (_osmTilesHaveIssues && _overpassHasIssues) return NetworkIssueType.both;
|
||||
if (_osmTilesHaveIssues) return NetworkIssueType.osmTiles;
|
||||
if (_overpassHasIssues) return NetworkIssueType.overpassApi;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Report tile server issues (for any provider)
|
||||
void reportOsmTileIssue() {
|
||||
if (!_osmTilesHaveIssues) {
|
||||
_osmTilesHaveIssues = true;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Tile server issues detected');
|
||||
/// Current network status
|
||||
NetworkRequestStatus get status => _status;
|
||||
|
||||
/// Set status and handle auto-reset timers
|
||||
void _setStatus(NetworkRequestStatus newStatus) {
|
||||
if (_status == newStatus) return;
|
||||
|
||||
_status = newStatus;
|
||||
_autoResetTimer?.cancel();
|
||||
|
||||
// Auto-reset certain statuses after a delay
|
||||
switch (newStatus) {
|
||||
case NetworkRequestStatus.success:
|
||||
_autoResetTimer = Timer(const Duration(seconds: 2), () {
|
||||
_setStatus(NetworkRequestStatus.idle);
|
||||
});
|
||||
break;
|
||||
case NetworkRequestStatus.timeout:
|
||||
case NetworkRequestStatus.error:
|
||||
_autoResetTimer = Timer(const Duration(seconds: 5), () {
|
||||
_setStatus(NetworkRequestStatus.idle);
|
||||
});
|
||||
break;
|
||||
case NetworkRequestStatus.noData:
|
||||
_autoResetTimer = Timer(const Duration(seconds: 3), () {
|
||||
_setStatus(NetworkRequestStatus.idle);
|
||||
});
|
||||
break;
|
||||
case NetworkRequestStatus.rateLimited:
|
||||
_autoResetTimer = Timer(const Duration(minutes: 2), () {
|
||||
_setStatus(NetworkRequestStatus.idle);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// No auto-reset for idle, loading, splitting
|
||||
break;
|
||||
}
|
||||
|
||||
// Reset recovery timer - if we keep getting errors, keep showing indicator
|
||||
_osmRecoveryTimer?.cancel();
|
||||
_osmRecoveryTimer = Timer(const Duration(minutes: 2), () {
|
||||
_osmTilesHaveIssues = false;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Tile server issues cleared');
|
||||
});
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Report Overpass API issues
|
||||
void reportOverpassIssue() {
|
||||
if (!_overpassHasIssues) {
|
||||
_overpassHasIssues = true;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Overpass API issues detected');
|
||||
}
|
||||
|
||||
// Reset recovery timer
|
||||
_overpassRecoveryTimer?.cancel();
|
||||
_overpassRecoveryTimer = Timer(const Duration(minutes: 2), () {
|
||||
_overpassHasIssues = false;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Overpass API issues cleared');
|
||||
});
|
||||
}
|
||||
|
||||
/// Report successful operations to potentially clear issues faster
|
||||
void reportOsmTileSuccess() {
|
||||
// Clear issues immediately on success (they were likely temporary)
|
||||
if (_osmTilesHaveIssues) {
|
||||
// Quietly clear - don't log routine success
|
||||
_osmTilesHaveIssues = false;
|
||||
_osmRecoveryTimer?.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void reportOverpassSuccess() {
|
||||
if (_overpassHasIssues) {
|
||||
// Quietly clear - don't log routine success
|
||||
_overpassHasIssues = false;
|
||||
_overpassRecoveryTimer?.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set waiting status (show when loading tiles/cameras)
|
||||
void setWaiting() {
|
||||
// Clear any previous timeout/no-data state when starting new wait
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_recentOfflineMisses = 0;
|
||||
_noDataResetTimer?.cancel();
|
||||
|
||||
if (!_isWaitingForData) {
|
||||
_isWaitingForData = true;
|
||||
notifyListeners();
|
||||
// Don't log routine waiting - only log if we stay waiting too long
|
||||
}
|
||||
|
||||
// Set timeout for genuine network issues (not 404s)
|
||||
_waitingTimer?.cancel();
|
||||
_waitingTimer = Timer(const Duration(seconds: 8), () {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = true;
|
||||
debugPrint('[NetworkStatus] Request timed out - likely network issues');
|
||||
notifyListeners();
|
||||
});
|
||||
/// Start loading surveillance data
|
||||
void setLoading() {
|
||||
debugPrint('[NetworkStatus] Loading surveillance data');
|
||||
_setStatus(NetworkRequestStatus.loading);
|
||||
}
|
||||
|
||||
/// Show success status briefly when data loads
|
||||
/// Request is being split due to complexity/limits
|
||||
void setSplitting() {
|
||||
debugPrint('[NetworkStatus] Splitting request due to complexity');
|
||||
_setStatus(NetworkRequestStatus.splitting);
|
||||
}
|
||||
|
||||
/// Data loaded successfully
|
||||
void setSuccess() {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_hasSuccess = true;
|
||||
_recentOfflineMisses = 0;
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
|
||||
// Auto-clear success status after 2 seconds
|
||||
_successResetTimer?.cancel();
|
||||
_successResetTimer = Timer(const Duration(seconds: 2), () {
|
||||
if (_hasSuccess) {
|
||||
_hasSuccess = false;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Show no-data status briefly when tiles aren't available
|
||||
void setNoData() {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasSuccess = false;
|
||||
_hasNoData = true;
|
||||
_waitingTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
|
||||
// Auto-clear no-data status after 2 seconds
|
||||
_noDataResetTimer?.cancel();
|
||||
_noDataResetTimer = Timer(const Duration(seconds: 2), () {
|
||||
if (_hasNoData) {
|
||||
_hasNoData = false;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear waiting/timeout/no-data status (legacy method for compatibility)
|
||||
void clearWaiting() {
|
||||
if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess || _nodeLimitReached) {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_hasSuccess = false;
|
||||
_nodeLimitReached = false;
|
||||
_recentOfflineMisses = 0;
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
_nodeLimitResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
debugPrint('[NetworkStatus] Surveillance data loaded successfully');
|
||||
_setStatus(NetworkRequestStatus.success);
|
||||
}
|
||||
|
||||
/// Set timeout error state
|
||||
void setTimeoutError() {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = true;
|
||||
_hasNoData = false;
|
||||
_hasSuccess = false;
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
/// Request timed out
|
||||
void setTimeout() {
|
||||
debugPrint('[NetworkStatus] Request timed out');
|
||||
|
||||
// Auto-clear timeout after 5 seconds
|
||||
Timer(const Duration(seconds: 5), () {
|
||||
if (_isTimedOut) {
|
||||
_isTimedOut = false;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
_setStatus(NetworkRequestStatus.timeout);
|
||||
}
|
||||
|
||||
/// Set network error state (rate limits, connection issues, etc.)
|
||||
void setNetworkError() {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_hasSuccess = false;
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
|
||||
// Use existing issue reporting system
|
||||
reportOverpassIssue();
|
||||
/// Rate limited by API
|
||||
void setRateLimited() {
|
||||
debugPrint('[NetworkStatus] Rate limited by API');
|
||||
_setStatus(NetworkRequestStatus.rateLimited);
|
||||
}
|
||||
|
||||
/// No offline data available
|
||||
void setNoData() {
|
||||
debugPrint('[NetworkStatus] No offline data available');
|
||||
_setStatus(NetworkRequestStatus.noData);
|
||||
}
|
||||
|
||||
/// Network or other error
|
||||
void setError() {
|
||||
debugPrint('[NetworkStatus] Network error occurred');
|
||||
_setStatus(NetworkRequestStatus.error);
|
||||
}
|
||||
|
||||
/// Show notification that node display limit was reached
|
||||
void reportNodeLimitReached(int totalNodes, int maxNodes) {
|
||||
_nodeLimitReached = true;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Node display limit reached: $totalNodes found, showing $maxNodes');
|
||||
|
||||
// Auto-clear after 8 seconds
|
||||
_nodeLimitResetTimer?.cancel();
|
||||
_nodeLimitResetTimer = Timer(const Duration(seconds: 8), () {
|
||||
if (_nodeLimitReached) {
|
||||
_nodeLimitReached = false;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
/// Clear status (force to idle)
|
||||
void clear() {
|
||||
_setStatus(NetworkRequestStatus.idle);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Report that a tile was not available offline
|
||||
void reportOfflineMiss() {
|
||||
_recentOfflineMisses++;
|
||||
debugPrint('[NetworkStatus] Offline miss #$_recentOfflineMisses');
|
||||
|
||||
// If we get several misses in a short time, show "no data" status
|
||||
if (_recentOfflineMisses >= 3 && !_hasNoData) {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = true;
|
||||
_waitingTimer?.cancel();
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] No offline data available for this area');
|
||||
}
|
||||
|
||||
// Reset the miss counter after some time
|
||||
_noDataResetTimer?.cancel();
|
||||
_noDataResetTimer = Timer(const Duration(seconds: 5), () {
|
||||
_recentOfflineMisses = 0;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_osmRecoveryTimer?.cancel();
|
||||
_overpassRecoveryTimer?.cancel();
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
_nodeLimitResetTimer?.cancel();
|
||||
_autoResetTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
@@ -66,10 +67,26 @@ class NodeCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the _pending_deletion marker from a specific node (when deletion is cancelled)
|
||||
void removePendingDeletionMarker(int nodeId) {
|
||||
final node = _nodes[nodeId];
|
||||
if (node != null && node.tags.containsKey('_pending_deletion')) {
|
||||
final cleanTags = Map<String, String>.from(node.tags);
|
||||
cleanTags.remove('_pending_deletion');
|
||||
|
||||
_nodes[nodeId] = OsmNode(
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: cleanTags,
|
||||
isConstrained: node.isConstrained, // Preserve constraint information
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)');
|
||||
debugPrint('[NodeCache] Removed node $nodeId from cache (successful deletion)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,9 +112,26 @@ class NodeCache {
|
||||
}
|
||||
|
||||
if (nodesToRemove.isNotEmpty) {
|
||||
print('[NodeCache] Removed ${nodesToRemove.length} temp nodes at coordinate ${coord.latitude}, ${coord.longitude}');
|
||||
debugPrint('[NodeCache] Removed ${nodesToRemove.length} temp nodes at coordinate ${coord.latitude}, ${coord.longitude}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a specific temporary node by its ID (for queue item-specific cleanup)
|
||||
void removeTempNodeById(int tempNodeId) {
|
||||
if (tempNodeId >= 0) {
|
||||
debugPrint('[NodeCache] Warning: Attempted to remove non-temp node ID $tempNodeId');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_nodes.remove(tempNodeId) != null) {
|
||||
debugPrint('[NodeCache] Removed specific temp node $tempNodeId from cache');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a specific node by ID (returns null if not found)
|
||||
OsmNode? getNodeById(int nodeId) {
|
||||
return _nodes[nodeId];
|
||||
}
|
||||
|
||||
/// Check if two coordinates match within tolerance
|
||||
bool _coordsMatch(LatLng coord1, LatLng coord2, double tolerance) {
|
||||
@@ -107,6 +141,7 @@ class NodeCache {
|
||||
|
||||
/// Find nodes within the specified distance (in meters) of the given coordinate
|
||||
/// Excludes nodes with the excludeNodeId (useful when checking proximity for edited nodes)
|
||||
/// Includes pending nodes to warn about potential duplicates
|
||||
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) {
|
||||
final nearbyNodes = <OsmNode>[];
|
||||
|
||||
@@ -116,11 +151,9 @@ class NodeCache {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip temporary nodes (negative IDs) with pending upload/edit/deletion markers
|
||||
if (node.id < 0 && (
|
||||
node.tags.containsKey('_pending_upload') ||
|
||||
node.tags.containsKey('_pending_edit') ||
|
||||
node.tags.containsKey('_pending_deletion'))) {
|
||||
// Include all nodes (real and pending) to catch potential duplicates
|
||||
// Only skip nodes marked for deletion since they won't actually exist after processing
|
||||
if (node.tags.containsKey('_pending_deletion')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
403
lib/services/node_data_manager.dart
Normal file
403
lib/services/node_data_manager.dart
Normal file
@@ -0,0 +1,403 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../app_state.dart';
|
||||
import 'overpass_service.dart';
|
||||
import 'node_spatial_cache.dart';
|
||||
import 'network_status.dart';
|
||||
import 'map_data_submodules/nodes_from_osm_api.dart';
|
||||
import 'map_data_submodules/nodes_from_local.dart';
|
||||
import 'offline_area_service.dart';
|
||||
import 'offline_areas/offline_area_models.dart';
|
||||
|
||||
/// Coordinates node data fetching between cache, Overpass, and OSM API.
|
||||
/// Simple interface: give me nodes for this view with proper caching and error handling.
|
||||
class NodeDataManager extends ChangeNotifier {
|
||||
static final NodeDataManager _instance = NodeDataManager._();
|
||||
factory NodeDataManager() => _instance;
|
||||
NodeDataManager._();
|
||||
|
||||
final OverpassService _overpassService = OverpassService();
|
||||
final NodeSpatialCache _cache = NodeSpatialCache();
|
||||
|
||||
// Track ongoing user-initiated requests for status reporting
|
||||
final Set<String> _userInitiatedRequests = <String>{};
|
||||
|
||||
/// Get nodes for the given bounds and profiles.
|
||||
/// Returns cached data immediately if available, otherwise fetches from appropriate source.
|
||||
Future<List<OsmNode>> getNodesFor({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
bool isUserInitiated = false,
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
// Handle offline mode - no loading states needed, data is instant
|
||||
if (AppState.instance.offlineMode) {
|
||||
// Clear any existing loading states since offline data is instant
|
||||
if (isUserInitiated) {
|
||||
NetworkStatus.instance.clear();
|
||||
}
|
||||
|
||||
if (uploadMode == UploadMode.sandbox) {
|
||||
// Offline + Sandbox = no nodes (local cache is production data)
|
||||
debugPrint('[NodeDataManager] Offline + Sandbox mode: returning no nodes');
|
||||
return [];
|
||||
} else {
|
||||
// Offline + Production = use local offline areas (instant)
|
||||
final offlineNodes = await fetchLocalNodes(bounds: bounds, profiles: profiles);
|
||||
|
||||
// Add offline nodes to cache so they integrate with the rest of the system
|
||||
if (offlineNodes.isNotEmpty) {
|
||||
_cache.addOrUpdateNodes(offlineNodes);
|
||||
// Mark this area as having coverage for submit button logic
|
||||
_cache.markAreaAsFetched(bounds, offlineNodes);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Show brief success for user-initiated offline loads with data
|
||||
if (isUserInitiated && offlineNodes.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
NetworkStatus.instance.setSuccess();
|
||||
});
|
||||
} else if (isUserInitiated && offlineNodes.isEmpty) {
|
||||
// Show no data briefly for offline areas with no surveillance devices
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
NetworkStatus.instance.setNoData();
|
||||
});
|
||||
}
|
||||
|
||||
return offlineNodes;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle sandbox mode (always fetch from OSM API, but integrate with cache system for UI)
|
||||
if (uploadMode == UploadMode.sandbox) {
|
||||
debugPrint('[NodeDataManager] Sandbox mode: fetching from OSM API');
|
||||
|
||||
// Track user-initiated requests for status reporting
|
||||
final requestKey = '${bounds.hashCode}_${profiles.map((p) => p.id).join('_')}_$uploadMode';
|
||||
|
||||
if (isUserInitiated && _userInitiatedRequests.contains(requestKey)) {
|
||||
debugPrint('[NodeDataManager] Sandbox request already in progress for this area');
|
||||
return _cache.getNodesFor(bounds);
|
||||
}
|
||||
|
||||
// Start status tracking for user-initiated requests
|
||||
if (isUserInitiated) {
|
||||
_userInitiatedRequests.add(requestKey);
|
||||
NetworkStatus.instance.setLoading();
|
||||
debugPrint('[NodeDataManager] Starting user-initiated sandbox request');
|
||||
} else {
|
||||
debugPrint('[NodeDataManager] Starting background sandbox request (no status reporting)');
|
||||
}
|
||||
|
||||
try {
|
||||
final nodes = await fetchOsmApiNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: 0,
|
||||
);
|
||||
|
||||
// Add nodes to cache for UI integration (even though we don't rely on cache for subsequent fetches)
|
||||
if (nodes.isNotEmpty) {
|
||||
_cache.addOrUpdateNodes(nodes);
|
||||
_cache.markAreaAsFetched(bounds, nodes);
|
||||
} else {
|
||||
// Mark area as fetched even with no nodes so UI knows we've checked this area
|
||||
_cache.markAreaAsFetched(bounds, []);
|
||||
}
|
||||
|
||||
// Update UI
|
||||
notifyListeners();
|
||||
|
||||
// Set success after the next frame renders, but only for user-initiated requests
|
||||
if (isUserInitiated) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
NetworkStatus.instance.setSuccess();
|
||||
});
|
||||
debugPrint('[NodeDataManager] User-initiated sandbox request completed successfully: ${nodes.length} nodes');
|
||||
}
|
||||
|
||||
return nodes;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[NodeDataManager] Sandbox fetch failed: $e');
|
||||
|
||||
// Only report errors for user-initiated requests
|
||||
if (isUserInitiated) {
|
||||
if (e is RateLimitError) {
|
||||
NetworkStatus.instance.setRateLimited();
|
||||
} else if (e.toString().contains('timeout')) {
|
||||
NetworkStatus.instance.setTimeout();
|
||||
} else {
|
||||
NetworkStatus.instance.setError();
|
||||
}
|
||||
debugPrint('[NodeDataManager] User-initiated sandbox request failed: $e');
|
||||
}
|
||||
|
||||
// Return whatever we have in cache for this area (likely empty for sandbox)
|
||||
return _cache.getNodesFor(bounds);
|
||||
} finally {
|
||||
if (isUserInitiated) {
|
||||
_userInitiatedRequests.remove(requestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Production mode: check cache first
|
||||
if (_cache.hasDataFor(bounds)) {
|
||||
debugPrint('[NodeDataManager] Using cached data for bounds');
|
||||
return _cache.getNodesFor(bounds);
|
||||
}
|
||||
|
||||
// Not cached - need to fetch
|
||||
final requestKey = '${bounds.hashCode}_${profiles.map((p) => p.id).join('_')}_$uploadMode';
|
||||
|
||||
// Only allow one user-initiated request per area at a time
|
||||
if (isUserInitiated && _userInitiatedRequests.contains(requestKey)) {
|
||||
debugPrint('[NodeDataManager] User request already in progress for this area');
|
||||
return _cache.getNodesFor(bounds);
|
||||
}
|
||||
|
||||
// Start status tracking for user-initiated requests only
|
||||
if (isUserInitiated) {
|
||||
_userInitiatedRequests.add(requestKey);
|
||||
NetworkStatus.instance.setLoading();
|
||||
debugPrint('[NodeDataManager] Starting user-initiated request');
|
||||
} else {
|
||||
debugPrint('[NodeDataManager] Starting background request (no status reporting)');
|
||||
}
|
||||
|
||||
try {
|
||||
final nodes = await fetchWithSplitting(bounds, profiles, isUserInitiated: isUserInitiated);
|
||||
|
||||
// Update cache and notify listeners
|
||||
notifyListeners();
|
||||
|
||||
// Set success after the next frame renders, but only for user-initiated requests
|
||||
if (isUserInitiated) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
NetworkStatus.instance.setSuccess();
|
||||
});
|
||||
debugPrint('[NodeDataManager] User-initiated request completed successfully');
|
||||
}
|
||||
|
||||
return nodes;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[NodeDataManager] Fetch failed: $e');
|
||||
|
||||
// Only report errors for user-initiated requests
|
||||
if (isUserInitiated) {
|
||||
if (e is RateLimitError) {
|
||||
NetworkStatus.instance.setRateLimited();
|
||||
} else if (e.toString().contains('timeout')) {
|
||||
NetworkStatus.instance.setTimeout();
|
||||
} else {
|
||||
NetworkStatus.instance.setError();
|
||||
}
|
||||
debugPrint('[NodeDataManager] User-initiated request failed: $e');
|
||||
}
|
||||
|
||||
// Return whatever we have in cache for this area
|
||||
return _cache.getNodesFor(bounds);
|
||||
} finally {
|
||||
if (isUserInitiated) {
|
||||
_userInitiatedRequests.remove(requestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch nodes with automatic area splitting if needed
|
||||
Future<List<OsmNode>> fetchWithSplitting(
|
||||
LatLngBounds bounds,
|
||||
List<NodeProfile> profiles, {
|
||||
int splitDepth = 0,
|
||||
bool isUserInitiated = false,
|
||||
}) async {
|
||||
const maxSplitDepth = 3; // 4^3 = 64 max sub-areas
|
||||
|
||||
try {
|
||||
// Expand bounds slightly to reduce edge effects
|
||||
final expandedBounds = _expandBounds(bounds, 1.2);
|
||||
|
||||
final nodes = await _overpassService.fetchNodes(
|
||||
bounds: expandedBounds,
|
||||
profiles: profiles,
|
||||
);
|
||||
|
||||
// Success - cache the data for the expanded area
|
||||
_cache.markAreaAsFetched(expandedBounds, nodes);
|
||||
return nodes;
|
||||
|
||||
} on NodeLimitError {
|
||||
// Hit node limit or timeout - split area if not too deep
|
||||
if (splitDepth >= maxSplitDepth) {
|
||||
debugPrint('[NodeDataManager] Max split depth reached, giving up');
|
||||
return [];
|
||||
}
|
||||
|
||||
debugPrint('[NodeDataManager] Splitting area (depth: $splitDepth)');
|
||||
|
||||
// Only report splitting status for user-initiated requests
|
||||
if (isUserInitiated && splitDepth == 0) {
|
||||
NetworkStatus.instance.setSplitting();
|
||||
}
|
||||
|
||||
return _fetchSplitAreas(bounds, profiles, splitDepth + 1, isUserInitiated: isUserInitiated);
|
||||
|
||||
} on RateLimitError {
|
||||
// Rate limited - wait and return empty
|
||||
debugPrint('[NodeDataManager] Rate limited, backing off');
|
||||
await Future.delayed(const Duration(seconds: 30));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch data by splitting area into quadrants
|
||||
Future<List<OsmNode>> _fetchSplitAreas(
|
||||
LatLngBounds bounds,
|
||||
List<NodeProfile> profiles,
|
||||
int splitDepth, {
|
||||
bool isUserInitiated = false,
|
||||
}) async {
|
||||
final quadrants = _splitBounds(bounds);
|
||||
final allNodes = <OsmNode>[];
|
||||
|
||||
for (final quadrant in quadrants) {
|
||||
try {
|
||||
final nodes = await fetchWithSplitting(
|
||||
quadrant,
|
||||
profiles,
|
||||
splitDepth: splitDepth,
|
||||
isUserInitiated: isUserInitiated,
|
||||
);
|
||||
allNodes.addAll(nodes);
|
||||
} catch (e) {
|
||||
debugPrint('[NodeDataManager] Quadrant fetch failed: $e');
|
||||
// Continue with other quadrants
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('[NodeDataManager] Split fetch complete: ${allNodes.length} total nodes');
|
||||
return allNodes;
|
||||
}
|
||||
|
||||
/// Split bounds into 4 quadrants
|
||||
List<LatLngBounds> _splitBounds(LatLngBounds bounds) {
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
final centerLng = (bounds.east + bounds.west) / 2;
|
||||
|
||||
return [
|
||||
// Southwest
|
||||
LatLngBounds(LatLng(bounds.south, bounds.west), LatLng(centerLat, centerLng)),
|
||||
// Southeast
|
||||
LatLngBounds(LatLng(bounds.south, centerLng), LatLng(centerLat, bounds.east)),
|
||||
// Northwest
|
||||
LatLngBounds(LatLng(centerLat, bounds.west), LatLng(bounds.north, centerLng)),
|
||||
// Northeast
|
||||
LatLngBounds(LatLng(centerLat, centerLng), LatLng(bounds.north, bounds.east)),
|
||||
];
|
||||
}
|
||||
|
||||
/// Expand bounds by given factor around center point
|
||||
LatLngBounds _expandBounds(LatLngBounds bounds, double factor) {
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
final centerLng = (bounds.east + bounds.west) / 2;
|
||||
|
||||
final latSpan = (bounds.north - bounds.south) * factor / 2;
|
||||
final lngSpan = (bounds.east - bounds.west) * factor / 2;
|
||||
|
||||
return LatLngBounds(
|
||||
LatLng(centerLat - latSpan, centerLng - lngSpan),
|
||||
LatLng(centerLat + latSpan, centerLng + lngSpan),
|
||||
);
|
||||
}
|
||||
|
||||
/// Add or update nodes in cache (for upload queue integration)
|
||||
void addOrUpdateNodes(List<OsmNode> nodes) {
|
||||
_cache.addOrUpdateNodes(nodes);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Remove node from cache (for deletions)
|
||||
void removeNodeById(int nodeId) {
|
||||
_cache.removeNodeById(nodeId);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear cache (when profiles change significantly)
|
||||
void clearCache() {
|
||||
_cache.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Force refresh for current view (manual retry)
|
||||
Future<void> refreshArea({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
}) async {
|
||||
// Clear any cached data for this area
|
||||
_cache.clear();
|
||||
|
||||
// Re-fetch as user-initiated request
|
||||
await getNodesFor(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
isUserInitiated: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// NodeCache compatibility methods
|
||||
OsmNode? getNodeById(int nodeId) => _cache.getNodeById(nodeId);
|
||||
void removePendingEditMarker(int nodeId) => _cache.removePendingEditMarker(nodeId);
|
||||
void removePendingDeletionMarker(int nodeId) => _cache.removePendingDeletionMarker(nodeId);
|
||||
void removeTempNodeById(int tempNodeId) => _cache.removeTempNodeById(tempNodeId);
|
||||
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) =>
|
||||
_cache.findNodesWithinDistance(coord, distanceMeters, excludeNodeId: excludeNodeId);
|
||||
|
||||
/// Check if we have good cache coverage for the given area
|
||||
bool hasGoodCoverageFor(LatLngBounds bounds) {
|
||||
return _cache.hasDataFor(bounds);
|
||||
}
|
||||
|
||||
/// Load all offline nodes into cache (call at app startup)
|
||||
Future<void> preloadOfflineNodes() async {
|
||||
try {
|
||||
final offlineAreaService = OfflineAreaService();
|
||||
|
||||
for (final area in offlineAreaService.offlineAreas) {
|
||||
if (area.status != OfflineAreaStatus.complete) continue;
|
||||
|
||||
// Load nodes from this offline area
|
||||
final nodes = await fetchLocalNodes(
|
||||
bounds: area.bounds,
|
||||
profiles: [], // Empty profiles = load all nodes
|
||||
);
|
||||
|
||||
if (nodes.isNotEmpty) {
|
||||
_cache.addOrUpdateNodes(nodes);
|
||||
// Mark the offline area as having coverage so submit buttons work
|
||||
_cache.markAreaAsFetched(area.bounds, nodes);
|
||||
debugPrint('[NodeDataManager] Preloaded ${nodes.length} offline nodes from area ${area.name}');
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('[NodeDataManager] Error preloading offline nodes: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cache statistics
|
||||
String get cacheStats => _cache.stats.toString();
|
||||
}
|
||||
190
lib/services/node_spatial_cache.dart
Normal file
190
lib/services/node_spatial_cache.dart
Normal file
@@ -0,0 +1,190 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../models/osm_node.dart';
|
||||
|
||||
const Distance _distance = Distance();
|
||||
|
||||
/// Simple spatial cache that tracks which areas have been successfully fetched.
|
||||
/// No temporal expiration - data stays cached until app restart or explicit clear.
|
||||
class NodeSpatialCache {
|
||||
static final NodeSpatialCache _instance = NodeSpatialCache._();
|
||||
factory NodeSpatialCache() => _instance;
|
||||
NodeSpatialCache._();
|
||||
|
||||
final List<CachedArea> _fetchedAreas = [];
|
||||
final Map<int, OsmNode> _nodes = {}; // nodeId -> node
|
||||
|
||||
/// Check if we have cached data covering the given bounds
|
||||
bool hasDataFor(LatLngBounds bounds) {
|
||||
return _fetchedAreas.any((area) => area.bounds.containsBounds(bounds));
|
||||
}
|
||||
|
||||
/// Record that we successfully fetched data for this area
|
||||
void markAreaAsFetched(LatLngBounds bounds, List<OsmNode> nodes) {
|
||||
// Add the fetched area
|
||||
_fetchedAreas.add(CachedArea(bounds, DateTime.now()));
|
||||
|
||||
// Update nodes in cache
|
||||
for (final node in nodes) {
|
||||
_nodes[node.id] = node;
|
||||
}
|
||||
|
||||
debugPrint('[NodeSpatialCache] Cached ${nodes.length} nodes for area ${bounds.south.toStringAsFixed(3)},${bounds.west.toStringAsFixed(3)} to ${bounds.north.toStringAsFixed(3)},${bounds.east.toStringAsFixed(3)}');
|
||||
debugPrint('[NodeSpatialCache] Total areas cached: ${_fetchedAreas.length}, total nodes: ${_nodes.length}');
|
||||
}
|
||||
|
||||
/// Get all cached nodes within the given bounds
|
||||
List<OsmNode> getNodesFor(LatLngBounds bounds) {
|
||||
return _nodes.values
|
||||
.where((node) => bounds.contains(node.coord))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Add or update individual nodes (for upload queue integration)
|
||||
void addOrUpdateNodes(List<OsmNode> nodes) {
|
||||
for (final 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,
|
||||
isConstrained: node.isConstrained,
|
||||
);
|
||||
} else {
|
||||
_nodes[node.id] = node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a node by ID (for deletions)
|
||||
void removeNodeById(int nodeId) {
|
||||
if (_nodes.remove(nodeId) != null) {
|
||||
debugPrint('[NodeSpatialCache] Removed node $nodeId from cache');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a specific node by ID (returns null if not found)
|
||||
OsmNode? getNodeById(int nodeId) {
|
||||
return _nodes[nodeId];
|
||||
}
|
||||
|
||||
/// 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,
|
||||
isConstrained: node.isConstrained,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the _pending_deletion marker from a specific node
|
||||
void removePendingDeletionMarker(int nodeId) {
|
||||
final node = _nodes[nodeId];
|
||||
if (node != null && node.tags.containsKey('_pending_deletion')) {
|
||||
final cleanTags = Map<String, String>.from(node.tags);
|
||||
cleanTags.remove('_pending_deletion');
|
||||
|
||||
_nodes[nodeId] = OsmNode(
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: cleanTags,
|
||||
isConstrained: node.isConstrained,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a specific temporary node by its ID
|
||||
void removeTempNodeById(int tempNodeId) {
|
||||
if (tempNodeId >= 0) {
|
||||
debugPrint('[NodeSpatialCache] Warning: Attempted to remove non-temp node ID $tempNodeId');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_nodes.remove(tempNodeId) != null) {
|
||||
debugPrint('[NodeSpatialCache] Removed temp node $tempNodeId from cache');
|
||||
}
|
||||
}
|
||||
|
||||
/// Find nodes within distance of a coordinate (for proximity warnings)
|
||||
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) {
|
||||
final nearbyNodes = <OsmNode>[];
|
||||
|
||||
for (final node in _nodes.values) {
|
||||
// Skip the excluded node
|
||||
if (excludeNodeId != null && node.id == excludeNodeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip nodes marked for deletion
|
||||
if (node.tags.containsKey('_pending_deletion')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final distanceInMeters = _distance.as(LengthUnit.Meter, coord, node.coord);
|
||||
if (distanceInMeters <= distanceMeters) {
|
||||
nearbyNodes.add(node);
|
||||
}
|
||||
}
|
||||
|
||||
return nearbyNodes;
|
||||
}
|
||||
|
||||
/// Clear all cached data
|
||||
void clear() {
|
||||
_fetchedAreas.clear();
|
||||
_nodes.clear();
|
||||
debugPrint('[NodeSpatialCache] Cache cleared');
|
||||
}
|
||||
|
||||
/// Get cache statistics for debugging
|
||||
CacheStats get stats => CacheStats(
|
||||
areasCount: _fetchedAreas.length,
|
||||
nodesCount: _nodes.length,
|
||||
);
|
||||
}
|
||||
|
||||
/// Represents an area that has been successfully fetched
|
||||
class CachedArea {
|
||||
final LatLngBounds bounds;
|
||||
final DateTime fetchedAt;
|
||||
|
||||
CachedArea(this.bounds, this.fetchedAt);
|
||||
}
|
||||
|
||||
/// Cache statistics for debugging
|
||||
class CacheStats {
|
||||
final int areasCount;
|
||||
final int nodesCount;
|
||||
|
||||
CacheStats({required this.areasCount, required this.nodesCount});
|
||||
|
||||
@override
|
||||
String toString() => 'CacheStats(areas: $areasCount, nodes: $nodesCount)';
|
||||
}
|
||||
|
||||
/// Extension to check if one bounds completely contains another
|
||||
extension LatLngBoundsExtension on LatLngBounds {
|
||||
bool containsBounds(LatLngBounds other) {
|
||||
return north >= other.north &&
|
||||
south <= other.south &&
|
||||
east >= other.east &&
|
||||
west <= other.west;
|
||||
}
|
||||
}
|
||||
138
lib/services/nsi_service.dart
Normal file
138
lib/services/nsi_service.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
/// Service for fetching tag value suggestions from OpenStreetMap Name Suggestion Index
|
||||
class NSIService {
|
||||
static final NSIService _instance = NSIService._();
|
||||
factory NSIService() => _instance;
|
||||
NSIService._();
|
||||
|
||||
static const String _userAgent = 'DeFlock/2.1.0 (OSM surveillance mapping app)';
|
||||
static const Duration _timeout = Duration(seconds: 10);
|
||||
|
||||
// Cache to avoid repeated API calls
|
||||
final Map<String, List<String>> _suggestionCache = {};
|
||||
|
||||
/// Get suggested values for a given OSM tag key
|
||||
/// Returns a list of the most commonly used values, or empty list if none found
|
||||
Future<List<String>> getSuggestionsForTag(String tagKey) async {
|
||||
if (tagKey.trim().isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final cacheKey = tagKey.trim().toLowerCase();
|
||||
|
||||
// Return cached results if available
|
||||
if (_suggestionCache.containsKey(cacheKey)) {
|
||||
return _suggestionCache[cacheKey]!;
|
||||
}
|
||||
|
||||
try {
|
||||
final suggestions = await _fetchSuggestionsForTag(tagKey);
|
||||
_suggestionCache[cacheKey] = suggestions;
|
||||
return suggestions;
|
||||
} catch (e) {
|
||||
debugPrint('[NSIService] Failed to fetch suggestions for $tagKey: $e');
|
||||
// Cache empty result to avoid repeated failures
|
||||
_suggestionCache[cacheKey] = [];
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch tag value suggestions from TagInfo API
|
||||
Future<List<String>> _fetchSuggestionsForTag(String tagKey) async {
|
||||
final uri = Uri.parse('https://taginfo.openstreetmap.org/api/4/key/values')
|
||||
.replace(queryParameters: {
|
||||
'key': tagKey,
|
||||
'format': 'json',
|
||||
'sortname': 'count',
|
||||
'sortorder': 'desc',
|
||||
'page': '1',
|
||||
'rp': '15', // Get top 15 most commonly used values
|
||||
});
|
||||
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: {'User-Agent': _userAgent},
|
||||
).timeout(_timeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('TagInfo API returned status ${response.statusCode}');
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final values = data['data'] as List<dynamic>? ?? [];
|
||||
|
||||
// Extract the most commonly used values that meet our minimum hit threshold
|
||||
final suggestions = <String>[];
|
||||
|
||||
for (final item in values) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
final value = item['value'] as String?;
|
||||
final count = item['count'] as int? ?? 0;
|
||||
|
||||
// Only include suggestions that meet our minimum hit count threshold
|
||||
if (value != null &&
|
||||
value.trim().isNotEmpty &&
|
||||
count >= kNSIMinimumHitCount &&
|
||||
_isValidSuggestion(value)) {
|
||||
suggestions.add(value.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (suggestions.length >= kNSIMaxSuggestions) break;
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/// Filter out common unwanted values that appear in TagInfo but aren't useful suggestions
|
||||
bool _isValidSuggestion(String value) {
|
||||
final lowercaseValue = value.toLowerCase();
|
||||
|
||||
// Filter out obvious non-useful values
|
||||
final unwanted = {
|
||||
'yes', 'no', 'unknown', '?', 'null', 'none', 'n/a', 'na',
|
||||
'todo', 'fixme', 'check', 'verify', 'test', 'temp', 'temporary'
|
||||
};
|
||||
|
||||
if (unwanted.contains(lowercaseValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out very short generic values (except single letters that might be valid)
|
||||
if (value.length == 1 && !RegExp(r'[A-Z]').hasMatch(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Get suggestions for a tag key - returns empty list when offline mode enabled
|
||||
Future<List<String>> getAllSuggestions(String tagKey) async {
|
||||
// Check if app is in offline mode
|
||||
if (AppState.instance.offlineMode) {
|
||||
debugPrint('[NSIService] Offline mode enabled - no suggestions available for $tagKey');
|
||||
return []; // No suggestions when in offline mode - user must input manually
|
||||
}
|
||||
|
||||
// Online mode: try to get suggestions from API
|
||||
try {
|
||||
return await getSuggestionsForTag(tagKey);
|
||||
} catch (e) {
|
||||
debugPrint('[NSIService] API call failed: $e');
|
||||
return []; // No fallback - just return empty list
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the suggestion cache (useful for testing or memory management)
|
||||
void clearCache() {
|
||||
_suggestionCache.clear();
|
||||
}
|
||||
}
|
||||
160
lib/services/nuclear_reset_service.dart
Normal file
160
lib/services/nuclear_reset_service.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'version_service.dart';
|
||||
|
||||
/// Nuclear reset service - clears ALL app data when migrations fail.
|
||||
/// This is the "big hammer" approach for when something goes seriously wrong.
|
||||
class NuclearResetService {
|
||||
static final NuclearResetService _instance = NuclearResetService._();
|
||||
factory NuclearResetService() => _instance;
|
||||
NuclearResetService._();
|
||||
|
||||
/// Completely clear all app data - SharedPreferences, files, caches, everything.
|
||||
/// After this, the app should behave exactly like a fresh install.
|
||||
static Future<void> clearEverything() async {
|
||||
try {
|
||||
debugPrint('[NuclearReset] Starting complete app data wipe...');
|
||||
|
||||
// Clear ALL SharedPreferences
|
||||
await _clearSharedPreferences();
|
||||
|
||||
// Clear ALL files in app directories
|
||||
await _clearFileSystem();
|
||||
|
||||
debugPrint('[NuclearReset] Complete app data wipe finished');
|
||||
} catch (e) {
|
||||
// Even the nuclear option can fail, but we can't do anything about it
|
||||
debugPrint('[NuclearReset] Error during nuclear reset: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all SharedPreferences data
|
||||
static Future<void> _clearSharedPreferences() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.clear();
|
||||
debugPrint('[NuclearReset] Cleared SharedPreferences');
|
||||
} catch (e) {
|
||||
debugPrint('[NuclearReset] Failed to clear SharedPreferences: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all files and directories in app storage
|
||||
static Future<void> _clearFileSystem() async {
|
||||
try {
|
||||
// Clear Documents directory (offline areas, etc.)
|
||||
await _clearDirectory(() => getApplicationDocumentsDirectory(), 'Documents');
|
||||
|
||||
// Clear Cache directory (tile cache, etc.)
|
||||
await _clearDirectory(() => getTemporaryDirectory(), 'Cache');
|
||||
|
||||
// Clear Support directory if it exists (iOS/macOS)
|
||||
if (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) {
|
||||
await _clearDirectory(() => getApplicationSupportDirectory(), 'Support');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[NuclearReset] Failed to clear file system: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear a specific directory, with error handling
|
||||
static Future<void> _clearDirectory(
|
||||
Future<Directory> Function() getDirFunc,
|
||||
String dirName,
|
||||
) async {
|
||||
try {
|
||||
final dir = await getDirFunc();
|
||||
if (dir.existsSync()) {
|
||||
await dir.delete(recursive: true);
|
||||
debugPrint('[NuclearReset] Cleared $dirName directory');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[NuclearReset] Failed to clear $dirName directory: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate error report information (safely, with fallbacks)
|
||||
static Future<String> generateErrorReport(Object error, StackTrace? stackTrace) async {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
// Basic error information (always include this)
|
||||
buffer.writeln('MIGRATION FAILURE ERROR REPORT');
|
||||
buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
|
||||
buffer.writeln('');
|
||||
buffer.writeln('Error: $error');
|
||||
|
||||
if (stackTrace != null) {
|
||||
buffer.writeln('');
|
||||
buffer.writeln('Stack trace:');
|
||||
buffer.writeln(stackTrace.toString());
|
||||
}
|
||||
|
||||
// Try to add enrichment data, but don't fail if it doesn't work
|
||||
await _addEnrichmentData(buffer);
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Add device/app information to error report (with extensive error handling)
|
||||
static Future<void> _addEnrichmentData(StringBuffer buffer) async {
|
||||
try {
|
||||
buffer.writeln('');
|
||||
buffer.writeln('--- System Information ---');
|
||||
|
||||
// App version (should always work)
|
||||
try {
|
||||
buffer.writeln('App Version: ${VersionService().version}');
|
||||
} catch (e) {
|
||||
buffer.writeln('App Version: [Failed to get version: $e]');
|
||||
}
|
||||
|
||||
// Platform information
|
||||
try {
|
||||
if (!kIsWeb) {
|
||||
buffer.writeln('Platform: ${Platform.operatingSystem}');
|
||||
buffer.writeln('OS Version: ${Platform.operatingSystemVersion}');
|
||||
} else {
|
||||
buffer.writeln('Platform: Web');
|
||||
}
|
||||
} catch (e) {
|
||||
buffer.writeln('Platform: [Failed to get platform info: $e]');
|
||||
}
|
||||
|
||||
// Flutter/Dart information
|
||||
try {
|
||||
buffer.writeln('Flutter Mode: ${kDebugMode ? 'Debug' : kProfileMode ? 'Profile' : 'Release'}');
|
||||
} catch (e) {
|
||||
buffer.writeln('Flutter Mode: [Failed to get mode: $e]');
|
||||
}
|
||||
|
||||
// Previous version (if available)
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final lastVersion = prefs.getString('last_seen_version');
|
||||
buffer.writeln('Previous Version: ${lastVersion ?? 'Unknown (fresh install?)'}');
|
||||
} catch (e) {
|
||||
buffer.writeln('Previous Version: [Failed to get: $e]');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
// If enrichment completely fails, just note it
|
||||
buffer.writeln('');
|
||||
buffer.writeln('--- System Information ---');
|
||||
buffer.writeln('[Failed to gather system information: $e]');
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy text to clipboard (safely)
|
||||
static Future<void> copyToClipboard(String text) async {
|
||||
try {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
debugPrint('[NuclearReset] Copied error report to clipboard');
|
||||
} catch (e) {
|
||||
debugPrint('[NuclearReset] Failed to copy to clipboard: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
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 '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 '../models/osm_node.dart';
|
||||
import '../app_state.dart';
|
||||
import 'map_data_provider.dart';
|
||||
import 'package:deflockapp/dev_config.dart';
|
||||
|
||||
/// Service for managing download, storage, and retrieval of offline map areas and cameras.
|
||||
class OfflineAreaService {
|
||||
@@ -29,6 +26,21 @@ class OfflineAreaService {
|
||||
/// Check if any areas are currently downloading
|
||||
bool get hasActiveDownloads => _areas.any((area) => area.status == OfflineAreaStatus.downloading);
|
||||
|
||||
/// Fast check: do we have any completed offline areas for a specific provider/type?
|
||||
/// This allows smart cache routing without expensive filesystem searches.
|
||||
/// Safe to call before initialization - returns false if not yet initialized.
|
||||
bool hasOfflineAreasForProvider(String providerId, String tileTypeId) {
|
||||
if (!_initialized) {
|
||||
return false; // No offline areas loaded yet
|
||||
}
|
||||
|
||||
return _areas.any((area) =>
|
||||
area.status == OfflineAreaStatus.complete &&
|
||||
area.tileProviderId == providerId &&
|
||||
area.tileTypeId == tileTypeId
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancel all active downloads (used when enabling offline mode)
|
||||
Future<void> cancelActiveDownloads() async {
|
||||
final activeAreas = _areas.where((area) => area.status == OfflineAreaStatus.downloading).toList();
|
||||
|
||||
106
lib/services/osm_messages_service.dart
Normal file
106
lib/services/osm_messages_service.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../state/settings_state.dart';
|
||||
|
||||
/// Service for checking OSM user messages
|
||||
class OSMMessagesService {
|
||||
static const _messageCheckCacheDuration = Duration(minutes: 5);
|
||||
|
||||
DateTime? _lastCheck;
|
||||
int? _lastUnreadCount;
|
||||
UploadMode? _lastMode;
|
||||
|
||||
/// Get the number of unread messages for the current user
|
||||
/// Returns null if not logged in, on error, or in simulate mode
|
||||
Future<int?> getUnreadMessageCount({
|
||||
required String? accessToken,
|
||||
required UploadMode uploadMode,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
// No messages in simulate mode
|
||||
if (uploadMode == UploadMode.simulate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// No access token means not logged in
|
||||
if (accessToken == null || accessToken.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cache unless forced refresh or mode changed
|
||||
if (!forceRefresh &&
|
||||
_lastCheck != null &&
|
||||
_lastUnreadCount != null &&
|
||||
_lastMode == uploadMode &&
|
||||
DateTime.now().difference(_lastCheck!) < _messageCheckCacheDuration) {
|
||||
return _lastUnreadCount;
|
||||
}
|
||||
|
||||
try {
|
||||
final apiHost = _getApiHost(uploadMode);
|
||||
final response = await http.get(
|
||||
Uri.parse('$apiHost/api/0.6/user/details.json'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
final user = data['user'];
|
||||
if (user == null) return null;
|
||||
|
||||
// OSM API returns message counts in user details
|
||||
final messages = user['messages'];
|
||||
if (messages == null) return null;
|
||||
|
||||
// Get unread count from correct path: messages.received.unread
|
||||
final received = messages['received'];
|
||||
if (received == null) return null;
|
||||
|
||||
final unreadCount = received['unread'] ?? 0;
|
||||
|
||||
// Update cache
|
||||
_lastCheck = DateTime.now();
|
||||
_lastUnreadCount = unreadCount;
|
||||
_lastMode = uploadMode;
|
||||
|
||||
return unreadCount;
|
||||
|
||||
} catch (e) {
|
||||
// Don't throw - just return null on any error
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the URL to view messages on OSM website
|
||||
String getMessagesUrl(UploadMode uploadMode) {
|
||||
switch (uploadMode) {
|
||||
case UploadMode.production:
|
||||
return 'https://www.openstreetmap.org/messages/inbox';
|
||||
case UploadMode.sandbox:
|
||||
return 'https://master.apis.dev.openstreetmap.org/messages/inbox';
|
||||
case UploadMode.simulate:
|
||||
return 'https://www.openstreetmap.org/messages/inbox';
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the cache (useful when user logs out or changes mode)
|
||||
void clearCache() {
|
||||
_lastCheck = null;
|
||||
_lastUnreadCount = null;
|
||||
_lastMode = null;
|
||||
}
|
||||
|
||||
String _getApiHost(UploadMode uploadMode) {
|
||||
switch (uploadMode) {
|
||||
case UploadMode.production:
|
||||
return 'https://api.openstreetmap.org';
|
||||
case UploadMode.sandbox:
|
||||
return 'https://api06.dev.openstreetmap.org';
|
||||
case UploadMode.simulate:
|
||||
return 'https://api.openstreetmap.org';
|
||||
}
|
||||
}
|
||||
}
|
||||
187
lib/services/overpass_service.dart
Normal file
187
lib/services/overpass_service.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
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 '../models/node_profile.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
/// Simple Overpass API client with proper HTTP retry logic.
|
||||
/// Single responsibility: Make requests, handle network errors, return data.
|
||||
class OverpassService {
|
||||
static const String _endpoint = 'https://overpass-api.de/api/interpreter';
|
||||
|
||||
/// Fetch surveillance nodes from Overpass API with proper retry logic.
|
||||
/// Throws NetworkError for retryable failures, NodeLimitError for area splitting.
|
||||
Future<List<OsmNode>> fetchNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
int maxRetries = 3,
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
final query = _buildQuery(bounds, profiles);
|
||||
|
||||
for (int attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
debugPrint('[OverpassService] Attempt ${attempt + 1}/${maxRetries + 1} for ${profiles.length} profiles');
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(_endpoint),
|
||||
body: {'data': query},
|
||||
).timeout(kOverpassQueryTimeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return _parseResponse(response.body);
|
||||
}
|
||||
|
||||
// Check for specific error types
|
||||
final errorBody = response.body;
|
||||
|
||||
// Node limit error - caller should split area
|
||||
if (response.statusCode == 400 &&
|
||||
(errorBody.contains('too many nodes') && errorBody.contains('50000'))) {
|
||||
debugPrint('[OverpassService] Node limit exceeded, area should be split');
|
||||
throw NodeLimitError('Query exceeded 50k node limit');
|
||||
}
|
||||
|
||||
// Timeout error - also try splitting (complex query)
|
||||
if (errorBody.contains('timeout') ||
|
||||
errorBody.contains('runtime limit exceeded') ||
|
||||
errorBody.contains('Query timed out')) {
|
||||
debugPrint('[OverpassService] Query timeout, area should be split');
|
||||
throw NodeLimitError('Query timed out - area too complex');
|
||||
}
|
||||
|
||||
// Rate limit - throw immediately, don't retry
|
||||
if (response.statusCode == 429 ||
|
||||
errorBody.contains('rate limited') ||
|
||||
errorBody.contains('too many requests')) {
|
||||
debugPrint('[OverpassService] Rate limited by Overpass');
|
||||
throw RateLimitError('Rate limited by Overpass API');
|
||||
}
|
||||
|
||||
// Other HTTP errors - retry with backoff
|
||||
if (attempt < maxRetries) {
|
||||
final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000));
|
||||
debugPrint('[OverpassService] HTTP ${response.statusCode} error, retrying in ${delay.inMilliseconds}ms');
|
||||
await Future.delayed(delay);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw NetworkError('HTTP ${response.statusCode}: $errorBody');
|
||||
|
||||
} catch (e) {
|
||||
// Handle specific error types without retry
|
||||
if (e is NodeLimitError || e is RateLimitError) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
// Network/timeout errors - retry with backoff
|
||||
if (attempt < maxRetries) {
|
||||
final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000));
|
||||
debugPrint('[OverpassService] Network error ($e), retrying in ${delay.inMilliseconds}ms');
|
||||
await Future.delayed(delay);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw NetworkError('Network error after $maxRetries retries: $e');
|
||||
}
|
||||
}
|
||||
|
||||
throw NetworkError('Max retries exceeded');
|
||||
}
|
||||
|
||||
/// Build Overpass QL query for given bounds and profiles
|
||||
String _buildQuery(LatLngBounds bounds, List<NodeProfile> profiles) {
|
||||
final nodeClauses = profiles.map((profile) {
|
||||
// Convert profile tags to Overpass filter format, excluding empty values
|
||||
final tagFilters = profile.tags.entries
|
||||
.where((entry) => entry.value.trim().isNotEmpty)
|
||||
.map((entry) => '["${entry.key}"="${entry.value}"]')
|
||||
.join();
|
||||
|
||||
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
|
||||
}).join('\n ');
|
||||
|
||||
return '''
|
||||
[out:json][timeout:${kOverpassQueryTimeout.inSeconds}];
|
||||
(
|
||||
$nodeClauses
|
||||
);
|
||||
out body;
|
||||
(
|
||||
way(bn);
|
||||
rel(bn);
|
||||
);
|
||||
out meta;
|
||||
''';
|
||||
}
|
||||
|
||||
/// Parse Overpass JSON response into OsmNode objects
|
||||
List<OsmNode> _parseResponse(String responseBody) {
|
||||
final data = jsonDecode(responseBody) as Map<String, dynamic>;
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
|
||||
final nodeElements = <Map<String, dynamic>>[];
|
||||
final constrainedNodeIds = <int>{};
|
||||
|
||||
// First pass: collect surveillance nodes and identify constrained nodes
|
||||
for (final element in elements.whereType<Map<String, dynamic>>()) {
|
||||
final type = element['type'] as String?;
|
||||
|
||||
if (type == 'node') {
|
||||
nodeElements.add(element);
|
||||
} else if (type == 'way' || type == 'relation') {
|
||||
// Mark referenced nodes as constrained
|
||||
final refs = element['nodes'] as List<dynamic>? ??
|
||||
element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? [];
|
||||
|
||||
for (final ref in refs) {
|
||||
final nodeId = ref is int ? ref : int.tryParse(ref.toString());
|
||||
if (nodeId != null) constrainedNodeIds.add(nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: create OsmNode objects
|
||||
final nodes = nodeElements.map((element) {
|
||||
final nodeId = element['id'] as int;
|
||||
return OsmNode(
|
||||
id: nodeId,
|
||||
coord: LatLng(element['lat'], element['lon']),
|
||||
tags: Map<String, String>.from(element['tags'] ?? {}),
|
||||
isConstrained: constrainedNodeIds.contains(nodeId),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
debugPrint('[OverpassService] Parsed ${nodes.length} nodes, ${constrainedNodeIds.length} constrained');
|
||||
return nodes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Error thrown when query exceeds node limits or is too complex - area should be split
|
||||
class NodeLimitError extends Error {
|
||||
final String message;
|
||||
NodeLimitError(this.message);
|
||||
@override
|
||||
String toString() => 'NodeLimitError: $message';
|
||||
}
|
||||
|
||||
/// Error thrown when rate limited - should not retry immediately
|
||||
class RateLimitError extends Error {
|
||||
final String message;
|
||||
RateLimitError(this.message);
|
||||
@override
|
||||
String toString() => 'RateLimitError: $message';
|
||||
}
|
||||
|
||||
/// Error thrown for network/HTTP issues - retryable
|
||||
class NetworkError extends Error {
|
||||
final String message;
|
||||
NetworkError(this.message);
|
||||
@override
|
||||
String toString() => 'NetworkError: $message';
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import 'map_data_submodules/nodes_from_overpass.dart';
|
||||
import 'node_cache.dart';
|
||||
import 'network_status.dart';
|
||||
import '../widgets/camera_provider_with_cache.dart';
|
||||
|
||||
/// Manages pre-fetching larger areas to reduce Overpass API calls.
|
||||
/// Uses zoom level 10 areas and automatically splits if hitting node limits.
|
||||
class PrefetchAreaService {
|
||||
static final PrefetchAreaService _instance = PrefetchAreaService._();
|
||||
factory PrefetchAreaService() => _instance;
|
||||
PrefetchAreaService._();
|
||||
|
||||
// Current pre-fetched area and associated data
|
||||
LatLngBounds? _preFetchedArea;
|
||||
List<NodeProfile>? _preFetchedProfiles;
|
||||
UploadMode? _preFetchedUploadMode;
|
||||
DateTime? _lastFetchTime;
|
||||
bool _preFetchInProgress = false;
|
||||
|
||||
// Debounce timer to avoid rapid requests while user is panning
|
||||
Timer? _debounceTimer;
|
||||
|
||||
// Configuration from dev_config
|
||||
static const double _areaExpansionMultiplier = kPreFetchAreaExpansionMultiplier;
|
||||
static const int _preFetchZoomLevel = kPreFetchZoomLevel;
|
||||
|
||||
/// Check if the given bounds are fully within the current pre-fetched area.
|
||||
bool isWithinPreFetchedArea(LatLngBounds bounds, List<NodeProfile> profiles, UploadMode uploadMode) {
|
||||
if (_preFetchedArea == null || _preFetchedProfiles == null || _preFetchedUploadMode == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if profiles and upload mode match
|
||||
if (_preFetchedUploadMode != uploadMode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_profileListsEqual(_preFetchedProfiles!, profiles)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if bounds are fully contained within pre-fetched area
|
||||
return bounds.north <= _preFetchedArea!.north &&
|
||||
bounds.south >= _preFetchedArea!.south &&
|
||||
bounds.east <= _preFetchedArea!.east &&
|
||||
bounds.west >= _preFetchedArea!.west;
|
||||
}
|
||||
|
||||
/// Check if cached data is stale (older than configured refresh interval).
|
||||
bool isDataStale() {
|
||||
if (_lastFetchTime == null) return true;
|
||||
return DateTime.now().difference(_lastFetchTime!).inSeconds > kDataRefreshIntervalSeconds;
|
||||
}
|
||||
|
||||
/// Request pre-fetch for the given view bounds if not already covered or if data is stale.
|
||||
/// Uses debouncing to avoid rapid requests while user is panning.
|
||||
void requestPreFetchIfNeeded({
|
||||
required LatLngBounds viewBounds,
|
||||
required List<NodeProfile> profiles,
|
||||
required UploadMode uploadMode,
|
||||
}) {
|
||||
// Skip if already in progress
|
||||
if (_preFetchInProgress) {
|
||||
debugPrint('[PrefetchAreaService] Pre-fetch already in progress, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check both spatial and temporal conditions
|
||||
final isWithinArea = isWithinPreFetchedArea(viewBounds, profiles, uploadMode);
|
||||
final isStale = isDataStale();
|
||||
|
||||
if (isWithinArea && !isStale) {
|
||||
debugPrint('[PrefetchAreaService] Current view within fresh pre-fetched area, no fetch needed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStale) {
|
||||
debugPrint('[PrefetchAreaService] Data is stale (>${kDataRefreshIntervalSeconds}s), refreshing');
|
||||
} else {
|
||||
debugPrint('[PrefetchAreaService] Current view outside pre-fetched area, fetching larger area');
|
||||
}
|
||||
|
||||
// Cancel any pending debounced request
|
||||
_debounceTimer?.cancel();
|
||||
|
||||
// Debounce to avoid rapid requests while user is still moving
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 800), () {
|
||||
_startPreFetch(
|
||||
viewBounds: viewBounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Start the actual pre-fetch operation.
|
||||
Future<void> _startPreFetch({
|
||||
required LatLngBounds viewBounds,
|
||||
required List<NodeProfile> profiles,
|
||||
required UploadMode uploadMode,
|
||||
}) async {
|
||||
if (_preFetchInProgress) return;
|
||||
|
||||
_preFetchInProgress = true;
|
||||
|
||||
try {
|
||||
// Calculate expanded area for pre-fetching
|
||||
final preFetchArea = _expandBounds(viewBounds, _areaExpansionMultiplier);
|
||||
|
||||
debugPrint('[PrefetchAreaService] Starting pre-fetch for area: ${preFetchArea.south},${preFetchArea.west} to ${preFetchArea.north},${preFetchArea.east}');
|
||||
|
||||
// Fetch nodes for the expanded area (unlimited - let splitting handle 50k limit)
|
||||
final nodes = await fetchOverpassNodes(
|
||||
bounds: preFetchArea,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: 0, // Unlimited - our splitting system handles the 50k limit gracefully
|
||||
);
|
||||
|
||||
debugPrint('[PrefetchAreaService] Pre-fetch completed: ${nodes.length} nodes retrieved');
|
||||
|
||||
// Update cache with new nodes (fresh data overwrites stale, but preserves underscore tags)
|
||||
if (nodes.isNotEmpty) {
|
||||
NodeCache.instance.addOrUpdate(nodes);
|
||||
}
|
||||
|
||||
// Store the pre-fetched area info and timestamp
|
||||
_preFetchedArea = preFetchArea;
|
||||
_preFetchedProfiles = List.from(profiles);
|
||||
_preFetchedUploadMode = uploadMode;
|
||||
_lastFetchTime = DateTime.now();
|
||||
|
||||
// Report completion to network status (only if user was waiting)
|
||||
NetworkStatus.instance.setSuccess();
|
||||
|
||||
// Notify UI that cache has been updated with fresh data
|
||||
CameraProviderWithCache.instance.refreshDisplay();
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[PrefetchAreaService] Pre-fetch failed: $e');
|
||||
// Report failure to network status (only if user was waiting)
|
||||
if (e.toString().contains('timeout') || e.toString().contains('timed out')) {
|
||||
NetworkStatus.instance.setTimeoutError();
|
||||
} else {
|
||||
NetworkStatus.instance.setNetworkError();
|
||||
}
|
||||
// Don't update pre-fetched area info on failure
|
||||
} finally {
|
||||
_preFetchInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand bounds by the given multiplier, maintaining center point.
|
||||
LatLngBounds _expandBounds(LatLngBounds bounds, double multiplier) {
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
final centerLng = (bounds.east + bounds.west) / 2;
|
||||
|
||||
final latSpan = (bounds.north - bounds.south) * multiplier / 2;
|
||||
final lngSpan = (bounds.east - bounds.west) * multiplier / 2;
|
||||
|
||||
return LatLngBounds(
|
||||
LatLng(centerLat - latSpan, centerLng - lngSpan), // Southwest
|
||||
LatLng(centerLat + latSpan, centerLng + lngSpan), // Northeast
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if two profile lists are equal by comparing IDs.
|
||||
bool _profileListsEqual(List<NodeProfile> list1, List<NodeProfile> list2) {
|
||||
if (list1.length != list2.length) return false;
|
||||
final ids1 = list1.map((p) => p.id).toSet();
|
||||
final ids2 = list2.map((p) => p.id).toSet();
|
||||
return ids1.length == ids2.length && ids1.containsAll(ids2);
|
||||
}
|
||||
|
||||
/// Clear the pre-fetched area (e.g., when profiles change significantly).
|
||||
void clearPreFetchedArea() {
|
||||
_preFetchedArea = null;
|
||||
_preFetchedProfiles = null;
|
||||
_preFetchedUploadMode = null;
|
||||
_lastFetchTime = null;
|
||||
debugPrint('[PrefetchAreaService] Pre-fetched area cleared');
|
||||
}
|
||||
|
||||
/// Dispose of resources.
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
}
|
||||
}
|
||||
120
lib/services/profile_import_service.dart
Normal file
120
lib/services/profile_import_service.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
|
||||
class ProfileImportService {
|
||||
// Maximum size for base64 encoded profile data (approx 50KB decoded)
|
||||
static const int maxBase64Length = 70000;
|
||||
|
||||
/// Parse and validate a profile from a base64-encoded JSON string
|
||||
/// Returns null if parsing/validation fails
|
||||
static NodeProfile? parseProfileFromBase64(String base64Data) {
|
||||
try {
|
||||
// Basic size validation before expensive decode
|
||||
if (base64Data.length > maxBase64Length) {
|
||||
debugPrint('[ProfileImportService] Base64 data too large: ${base64Data.length} characters');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decode base64
|
||||
final jsonBytes = base64Decode(base64Data);
|
||||
final jsonString = utf8.decode(jsonBytes);
|
||||
|
||||
// Parse JSON
|
||||
final jsonData = jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
|
||||
// Validate and sanitize the profile data
|
||||
final sanitizedProfile = _validateAndSanitizeProfile(jsonData);
|
||||
return sanitizedProfile;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[ProfileImportService] Failed to parse profile from base64: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate profile structure and sanitize all string values
|
||||
static NodeProfile? _validateAndSanitizeProfile(Map<String, dynamic> data) {
|
||||
try {
|
||||
// Extract and sanitize required fields
|
||||
final name = _sanitizeString(data['name']);
|
||||
if (name == null || name.isEmpty) {
|
||||
debugPrint('[ProfileImportService] Profile name is required');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract and sanitize tags
|
||||
final tagsData = data['tags'];
|
||||
if (tagsData is! Map<String, dynamic>) {
|
||||
debugPrint('[ProfileImportService] Profile tags must be a map');
|
||||
return null;
|
||||
}
|
||||
|
||||
final sanitizedTags = <String, String>{};
|
||||
for (final entry in tagsData.entries) {
|
||||
final key = _sanitizeString(entry.key);
|
||||
final value = _sanitizeString(entry.value);
|
||||
|
||||
if (key != null && key.isNotEmpty) {
|
||||
// Allow empty values for refinement purposes
|
||||
sanitizedTags[key] = value ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitizedTags.isEmpty) {
|
||||
debugPrint('[ProfileImportService] Profile must have at least one valid tag');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract optional fields with defaults
|
||||
final requiresDirection = data['requiresDirection'] ?? true;
|
||||
final submittable = data['submittable'] ?? true;
|
||||
|
||||
// Parse FOV if provided
|
||||
double? fov;
|
||||
if (data['fov'] != null) {
|
||||
if (data['fov'] is num) {
|
||||
final fovValue = (data['fov'] as num).toDouble();
|
||||
if (fovValue > 0 && fovValue <= 360) {
|
||||
fov = fovValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NodeProfile(
|
||||
id: const Uuid().v4(), // Always generate new ID for imported profiles
|
||||
name: name,
|
||||
tags: sanitizedTags,
|
||||
builtin: false, // Imported profiles are always custom
|
||||
requiresDirection: requiresDirection is bool ? requiresDirection : true,
|
||||
submittable: submittable is bool ? submittable : true,
|
||||
editable: true, // Imported profiles are always editable
|
||||
fov: fov,
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[ProfileImportService] Failed to validate profile: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize a string value by trimming and removing potentially harmful characters
|
||||
static String? _sanitizeString(dynamic value) {
|
||||
if (value == null) return null;
|
||||
|
||||
final str = value.toString().trim();
|
||||
|
||||
// Remove control characters and limit length
|
||||
final sanitized = str.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '');
|
||||
|
||||
// Limit length to prevent abuse
|
||||
const maxLength = 500;
|
||||
if (sanitized.length > maxLength) {
|
||||
return sanitized.substring(0, maxLength);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
@@ -41,9 +41,9 @@ class ProximityAlertService {
|
||||
|
||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
);
|
||||
|
||||
const initSettings = InitializationSettings(
|
||||
@@ -55,12 +55,10 @@ class ProximityAlertService {
|
||||
final initialized = await _notifications!.initialize(initSettings);
|
||||
_isInitialized = initialized ?? false;
|
||||
|
||||
// Request notification permissions (especially important for Android 13+)
|
||||
if (_isInitialized) {
|
||||
await _requestNotificationPermissions();
|
||||
}
|
||||
// Note: We don't request notification permissions here anymore.
|
||||
// Permissions are requested on-demand when user enables proximity alerts.
|
||||
|
||||
debugPrint('[ProximityAlertService] Initialized: $_isInitialized');
|
||||
debugPrint('[ProximityAlertService] Initialized: $_isInitialized (permissions deferred)');
|
||||
} catch (e) {
|
||||
debugPrint('[ProximityAlertService] Failed to initialize: $e');
|
||||
_isInitialized = false;
|
||||
|
||||
@@ -2,6 +2,10 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
class RouteResult {
|
||||
final List<LatLng> waypoints;
|
||||
@@ -21,73 +25,102 @@ class RouteResult {
|
||||
}
|
||||
|
||||
class RoutingService {
|
||||
static const String _baseUrl = 'https://router.project-osrm.org';
|
||||
static const String _baseUrl = 'https://alprwatch.org/api/v1/deflock/directions';
|
||||
static const String _userAgent = 'DeFlock/1.0 (OSM surveillance mapping app)';
|
||||
static const Duration _timeout = Duration(seconds: 15);
|
||||
|
||||
/// Calculate route between two points using OSRM
|
||||
final http.Client _client;
|
||||
|
||||
RoutingService({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
void close() => _client.close();
|
||||
|
||||
// Calculate route between two points using alprwatch
|
||||
Future<RouteResult> calculateRoute({
|
||||
required LatLng start,
|
||||
required LatLng end,
|
||||
String profile = 'driving', // driving, walking, cycling
|
||||
}) async {
|
||||
debugPrint('[RoutingService] Calculating route from $start to $end');
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final avoidanceDistance = prefs.getInt('navigation_avoidance_distance') ?? 250;
|
||||
|
||||
final enabledProfiles = AppState.instance.enabledProfiles.map((p) {
|
||||
final full = p.toJson();
|
||||
final tags = Map<String, String>.from(full['tags'] as Map);
|
||||
tags.removeWhere((key, value) => value.isEmpty);
|
||||
return {
|
||||
'id': full['id'],
|
||||
'name': full['name'],
|
||||
'tags': tags,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
// OSRM uses lng,lat order (opposite of LatLng)
|
||||
final startCoord = '${start.longitude},${start.latitude}';
|
||||
final endCoord = '${end.longitude},${end.latitude}';
|
||||
final uri = Uri.parse(_baseUrl);
|
||||
final params = {
|
||||
'start': {
|
||||
'longitude': start.longitude,
|
||||
'latitude': start.latitude
|
||||
},
|
||||
'end': {
|
||||
'longitude': end.longitude,
|
||||
'latitude': end.latitude
|
||||
},
|
||||
'avoidance_distance': avoidanceDistance,
|
||||
'enabled_profiles': enabledProfiles,
|
||||
'show_exclusion_zone': false, // for debugging: if true, returns a GeoJSON Feature MultiPolygon showing what areas are avoided in calculating the route
|
||||
};
|
||||
|
||||
final uri = Uri.parse('$_baseUrl/route/v1/$profile/$startCoord;$endCoord')
|
||||
.replace(queryParameters: {
|
||||
'overview': 'full', // Get full geometry
|
||||
'geometries': 'polyline', // Use polyline encoding (more compact)
|
||||
'steps': 'false', // Don't need turn-by-turn for now
|
||||
});
|
||||
|
||||
debugPrint('[RoutingService] OSRM request: $uri');
|
||||
debugPrint('[RoutingService] alprwatch request: $uri $params');
|
||||
|
||||
try {
|
||||
final response = await http.get(
|
||||
final response = await _client.post(
|
||||
uri,
|
||||
headers: {
|
||||
'User-Agent': _userAgent,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
).timeout(_timeout);
|
||||
|
||||
body: json.encode(params)
|
||||
).timeout(kNavigationRoutingTimeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[RoutingService] Error response body: ${response.body}');
|
||||
} else {
|
||||
const maxLen = 500;
|
||||
final body = response.body;
|
||||
final truncated = body.length > maxLen
|
||||
? '${body.substring(0, maxLen)}… [truncated]'
|
||||
: body;
|
||||
debugPrint('[RoutingService] Error response body ($maxLen char max): $truncated');
|
||||
}
|
||||
throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}');
|
||||
}
|
||||
|
||||
final data = json.decode(response.body) as Map<String, dynamic>;
|
||||
debugPrint('[RoutingService] alprwatch response data: $data');
|
||||
|
||||
// Check OSRM response status
|
||||
final code = data['code'] as String?;
|
||||
if (code != 'Ok') {
|
||||
final message = data['message'] as String? ?? 'Unknown routing error';
|
||||
throw RoutingException('OSRM error ($code): $message');
|
||||
// Check alprwatch response status
|
||||
final ok = data['ok'] as bool? ?? false;
|
||||
if ( ! ok ) {
|
||||
final message = data['error'] as String? ?? 'Unknown routing error';
|
||||
throw RoutingException('alprwatch error: $message');
|
||||
}
|
||||
|
||||
final routes = data['routes'] as List<dynamic>?;
|
||||
if (routes == null || routes.isEmpty) {
|
||||
final route = data['result']['route'] as Map<String, dynamic>?;
|
||||
if (route == null) {
|
||||
throw RoutingException('No route found between these points');
|
||||
}
|
||||
|
||||
final route = routes[0] as Map<String, dynamic>;
|
||||
final geometry = route['geometry'] as String?;
|
||||
|
||||
final waypoints = (route['coordinates'] as List<dynamic>?)
|
||||
?.map((inner) {
|
||||
final pair = inner as List<dynamic>;
|
||||
if (pair.length != 2) return null;
|
||||
final lng = (pair[0] as num).toDouble();
|
||||
final lat = (pair[1] as num).toDouble();
|
||||
return LatLng(lat, lng);
|
||||
}).whereType<LatLng>().toList() ?? [];
|
||||
final distance = (route['distance'] as num?)?.toDouble() ?? 0.0;
|
||||
final duration = (route['duration'] as num?)?.toDouble() ?? 0.0;
|
||||
|
||||
if (geometry == null) {
|
||||
throw RoutingException('Route geometry missing from response');
|
||||
}
|
||||
|
||||
// Decode polyline geometry to waypoints
|
||||
final waypoints = _decodePolyline(geometry);
|
||||
|
||||
if (waypoints.isEmpty) {
|
||||
throw RoutingException('Failed to decode route geometry');
|
||||
}
|
||||
|
||||
final result = RouteResult(
|
||||
waypoints: waypoints,
|
||||
distanceMeters: distance,
|
||||
@@ -106,52 +139,6 @@ class RoutingService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode OSRM polyline geometry to LatLng waypoints
|
||||
List<LatLng> _decodePolyline(String encoded) {
|
||||
try {
|
||||
final List<LatLng> points = [];
|
||||
int index = 0;
|
||||
int lat = 0;
|
||||
int lng = 0;
|
||||
|
||||
while (index < encoded.length) {
|
||||
int b;
|
||||
int shift = 0;
|
||||
int result = 0;
|
||||
|
||||
// Decode latitude
|
||||
do {
|
||||
b = encoded.codeUnitAt(index++) - 63;
|
||||
result |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
|
||||
final deltaLat = ((result & 1) != 0 ? ~(result >> 1) : (result >> 1));
|
||||
lat += deltaLat;
|
||||
|
||||
shift = 0;
|
||||
result = 0;
|
||||
|
||||
// Decode longitude
|
||||
do {
|
||||
b = encoded.codeUnitAt(index++) - 63;
|
||||
result |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
|
||||
final deltaLng = ((result & 1) != 0 ? ~(result >> 1) : (result >> 1));
|
||||
lng += deltaLng;
|
||||
|
||||
points.add(LatLng(lat / 1E5, lng / 1E5));
|
||||
}
|
||||
|
||||
return points;
|
||||
} catch (e) {
|
||||
debugPrint('[RoutingService] Manual polyline decoding failed: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RoutingException implements Exception {
|
||||
@@ -161,4 +148,4 @@ class RoutingException implements Exception {
|
||||
|
||||
@override
|
||||
String toString() => 'RoutingException: $message';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
@@ -12,19 +13,19 @@ class SearchService {
|
||||
static const Duration _timeout = Duration(seconds: 10);
|
||||
|
||||
/// Search for places using Nominatim geocoding service
|
||||
Future<List<SearchResult>> search(String query) async {
|
||||
Future<List<SearchResult>> search(String query, {LatLngBounds? viewbox}) async {
|
||||
if (query.trim().isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
// Check if query looks like coordinates first
|
||||
final coordResult = _tryParseCoordinates(query.trim());
|
||||
if (coordResult != null) {
|
||||
return [coordResult];
|
||||
}
|
||||
|
||||
|
||||
// Otherwise, use Nominatim API
|
||||
return await _searchNominatim(query.trim());
|
||||
return await _searchNominatim(query.trim(), viewbox: viewbox);
|
||||
}
|
||||
|
||||
/// Try to parse various coordinate formats
|
||||
@@ -52,14 +53,37 @@ class SearchService {
|
||||
}
|
||||
|
||||
/// Search using Nominatim API
|
||||
Future<List<SearchResult>> _searchNominatim(String query) async {
|
||||
final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: {
|
||||
Future<List<SearchResult>> _searchNominatim(String query, {LatLngBounds? viewbox}) async {
|
||||
final params = {
|
||||
'q': query,
|
||||
'format': 'json',
|
||||
'limit': _maxResults.toString(),
|
||||
'addressdetails': '1',
|
||||
'extratags': '1',
|
||||
});
|
||||
};
|
||||
|
||||
if (viewbox != null) {
|
||||
double round1(double v) => (v * 10).round() / 10;
|
||||
var west = round1(viewbox.west);
|
||||
var east = round1(viewbox.east);
|
||||
var south = round1(viewbox.south);
|
||||
var north = round1(viewbox.north);
|
||||
|
||||
if (east - west < 0.5) {
|
||||
final mid = (east + west) / 2;
|
||||
west = mid - 0.25;
|
||||
east = mid + 0.25;
|
||||
}
|
||||
if (north - south < 0.5) {
|
||||
final mid = (north + south) / 2;
|
||||
south = mid - 0.25;
|
||||
north = mid + 0.25;
|
||||
}
|
||||
|
||||
params['viewbox'] = '$west,$north,$east,$south';
|
||||
}
|
||||
|
||||
final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: params);
|
||||
|
||||
debugPrint('[SearchService] Searching Nominatim: $uri');
|
||||
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
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 '../app_state.dart';
|
||||
import 'map_data_provider.dart';
|
||||
import 'network_status.dart';
|
||||
|
||||
/// Simple HTTP client that routes tile requests through the centralized MapDataProvider.
|
||||
/// This ensures all tile fetching (offline/online routing, retries, etc.) is in one place.
|
||||
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 {
|
||||
// Extract tile coordinates from our custom URL scheme
|
||||
final tileCoords = _extractTileCoords(request.url);
|
||||
if (tileCoords != null) {
|
||||
final z = tileCoords['z']!;
|
||||
final x = tileCoords['x']!;
|
||||
final y = tileCoords['y']!;
|
||||
return _handleTileRequest(z, x, y);
|
||||
}
|
||||
|
||||
// Pass through non-tile requests
|
||||
return _inner.send(request);
|
||||
}
|
||||
|
||||
/// Extract z/x/y coordinates from our fake domain: https://tiles.local/provider/type/z/x/y
|
||||
/// We ignore the provider/type in the URL since we use current AppState for actual fetching
|
||||
Map<String, int>? _extractTileCoords(Uri url) {
|
||||
if (url.host != 'tiles.local') return null;
|
||||
|
||||
final pathSegments = url.pathSegments;
|
||||
if (pathSegments.length != 5) return null;
|
||||
|
||||
// pathSegments[0] = providerId (for cache separation only)
|
||||
// pathSegments[1] = tileTypeId (for cache separation only)
|
||||
final z = int.tryParse(pathSegments[2]);
|
||||
final x = int.tryParse(pathSegments[3]);
|
||||
final y = int.tryParse(pathSegments[4]);
|
||||
|
||||
if (z != null && x != null && y != null) {
|
||||
return {'z': z, 'x': x, 'y': y};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Serve tile with proper cache headers
|
||||
return http.StreamedResponse(
|
||||
Stream.value(tileBytes),
|
||||
200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=604800',
|
||||
'Expires': _httpDateFormat(DateTime.now().add(Duration(days: 7))),
|
||||
'Last-Modified': _httpDateFormat(DateTime.now().subtract(Duration(hours: 1))),
|
||||
},
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[SimpleTileService] Could not get tile $z/$x/$y: $e');
|
||||
|
||||
// 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) {
|
||||
// Only report tile completion if we were in loading state (user-initiated)
|
||||
if (NetworkStatus.instance.currentStatus == NetworkStatusType.waiting) {
|
||||
NetworkStatus.instance.setSuccess();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear any queued tile requests when map view changes
|
||||
void clearTileQueue() {
|
||||
_mapDataProvider.clearTileQueue();
|
||||
}
|
||||
|
||||
/// Clear only tile requests that are no longer visible in the current bounds
|
||||
void clearStaleRequests(LatLngBounds currentBounds) {
|
||||
_mapDataProvider.clearTileQueueSelective(currentBounds);
|
||||
}
|
||||
|
||||
/// Format date for HTTP headers (RFC 7231)
|
||||
String _httpDateFormat(DateTime date) {
|
||||
final utc = date.toUtc();
|
||||
final weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
final weekday = weekdays[utc.weekday - 1];
|
||||
final day = utc.day.toString().padLeft(2, '0');
|
||||
final month = months[utc.month - 1];
|
||||
final year = utc.year;
|
||||
final hour = utc.hour.toString().padLeft(2, '0');
|
||||
final minute = utc.minute.toString().padLeft(2, '0');
|
||||
final second = utc.second.toString().padLeft(2, '0');
|
||||
|
||||
return '$weekday, $day $month $year $hour:$minute:$second GMT';
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
_inner.close();
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
@@ -1,130 +1,108 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/suspected_location.dart';
|
||||
import 'suspected_location_service.dart';
|
||||
|
||||
/// Lightweight entry with pre-calculated centroid for efficient bounds checking
|
||||
class SuspectedLocationEntry {
|
||||
final Map<String, dynamic> rawData;
|
||||
final LatLng centroid;
|
||||
|
||||
SuspectedLocationEntry({required this.rawData, required this.centroid});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'rawData': rawData,
|
||||
'centroid': [centroid.latitude, centroid.longitude],
|
||||
};
|
||||
|
||||
factory SuspectedLocationEntry.fromJson(Map<String, dynamic> json) {
|
||||
final centroidList = json['centroid'] as List;
|
||||
return SuspectedLocationEntry(
|
||||
rawData: Map<String, dynamic>.from(json['rawData']),
|
||||
centroid: LatLng(
|
||||
(centroidList[0] as num).toDouble(),
|
||||
(centroidList[1] as num).toDouble(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'suspected_location_database.dart';
|
||||
|
||||
class SuspectedLocationCache extends ChangeNotifier {
|
||||
static final SuspectedLocationCache _instance = SuspectedLocationCache._();
|
||||
factory SuspectedLocationCache() => _instance;
|
||||
SuspectedLocationCache._();
|
||||
|
||||
static const String _prefsKeyProcessedData = 'suspected_locations_processed_data';
|
||||
static const String _prefsKeyLastFetch = 'suspected_locations_last_fetch';
|
||||
final SuspectedLocationDatabase _database = SuspectedLocationDatabase();
|
||||
|
||||
List<SuspectedLocationEntry> _processedEntries = [];
|
||||
DateTime? _lastFetchTime;
|
||||
final Map<String, List<SuspectedLocation>> _boundsCache = {};
|
||||
// Simple cache: just hold the currently visible locations
|
||||
List<SuspectedLocation> _currentLocations = [];
|
||||
String? _currentBoundsKey;
|
||||
bool _isLoading = false;
|
||||
|
||||
/// Get suspected locations within specific bounds (cached)
|
||||
List<SuspectedLocation> getLocationsForBounds(LatLngBounds bounds) {
|
||||
/// Get suspected locations within specific bounds (async version)
|
||||
Future<List<SuspectedLocation>> getLocationsForBounds(LatLngBounds bounds) async {
|
||||
if (!SuspectedLocationService().isEnabled) {
|
||||
debugPrint('[SuspectedLocationCache] Service not enabled');
|
||||
return [];
|
||||
}
|
||||
|
||||
final boundsKey = '${bounds.north.toStringAsFixed(4)},${bounds.south.toStringAsFixed(4)},${bounds.east.toStringAsFixed(4)},${bounds.west.toStringAsFixed(4)}';
|
||||
final boundsKey = _getBoundsKey(bounds);
|
||||
|
||||
// debugPrint('[SuspectedLocationCache] Getting locations for bounds: $boundsKey, processed entries count: ${_processedEntries.length}');
|
||||
|
||||
// Check cache first
|
||||
if (_boundsCache.containsKey(boundsKey)) {
|
||||
debugPrint('[SuspectedLocationCache] Using cached result: ${_boundsCache[boundsKey]!.length} locations');
|
||||
return _boundsCache[boundsKey]!;
|
||||
// If this is the same bounds we're already showing, return current cache
|
||||
if (boundsKey == _currentBoundsKey) {
|
||||
return _currentLocations;
|
||||
}
|
||||
|
||||
// Filter processed entries for this bounds (very fast since centroids are pre-calculated)
|
||||
final locations = <SuspectedLocation>[];
|
||||
int inBoundsCount = 0;
|
||||
|
||||
for (final entry in _processedEntries) {
|
||||
// Quick bounds check using pre-calculated centroid
|
||||
final lat = entry.centroid.latitude;
|
||||
final lng = entry.centroid.longitude;
|
||||
try {
|
||||
// Query database for locations in bounds
|
||||
final locations = await _database.getLocationsInBounds(bounds);
|
||||
|
||||
if (lat <= bounds.north && lat >= bounds.south &&
|
||||
lng <= bounds.east && lng >= bounds.west) {
|
||||
try {
|
||||
// Only create SuspectedLocation object if it's in bounds
|
||||
final location = SuspectedLocation.fromCsvRow(entry.rawData);
|
||||
locations.add(location);
|
||||
inBoundsCount++;
|
||||
} catch (e) {
|
||||
// Skip invalid entries
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Update cache
|
||||
_currentLocations = locations;
|
||||
_currentBoundsKey = boundsKey;
|
||||
|
||||
return locations;
|
||||
} catch (e) {
|
||||
debugPrint('[SuspectedLocationCache] Error querying database: $e');
|
||||
return [];
|
||||
}
|
||||
|
||||
// debugPrint('[SuspectedLocationCache] Checked ${_processedEntries.length} entries, $inBoundsCount in bounds, result: ${locations.length} locations');
|
||||
|
||||
// Cache the result
|
||||
_boundsCache[boundsKey] = locations;
|
||||
|
||||
// Limit cache size to prevent memory issues
|
||||
if (_boundsCache.length > 100) {
|
||||
final oldestKey = _boundsCache.keys.first;
|
||||
_boundsCache.remove(oldestKey);
|
||||
}
|
||||
|
||||
return locations;
|
||||
}
|
||||
|
||||
/// Load processed data from storage
|
||||
Future<void> loadFromStorage() async {
|
||||
/// Get suspected locations within specific bounds (synchronous version for UI)
|
||||
/// Returns current cache immediately, triggers async update if bounds changed
|
||||
List<SuspectedLocation> getLocationsForBoundsSync(LatLngBounds bounds) {
|
||||
if (!SuspectedLocationService().isEnabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final boundsKey = _getBoundsKey(bounds);
|
||||
|
||||
// If bounds haven't changed, return current cache immediately
|
||||
if (boundsKey == _currentBoundsKey) {
|
||||
return _currentLocations;
|
||||
}
|
||||
|
||||
// Bounds changed - trigger async update but keep showing current cache
|
||||
if (!_isLoading) {
|
||||
_isLoading = true;
|
||||
_updateCacheAsync(bounds, boundsKey);
|
||||
}
|
||||
|
||||
// Return current cache (keeps suspected locations visible during map movement)
|
||||
return _currentLocations;
|
||||
}
|
||||
|
||||
/// Simple async update - no complex caching, just swap when done
|
||||
void _updateCacheAsync(LatLngBounds bounds, String boundsKey) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final locations = await _database.getLocationsInBounds(bounds);
|
||||
|
||||
// Load last fetch time
|
||||
final lastFetchMs = prefs.getInt(_prefsKeyLastFetch);
|
||||
if (lastFetchMs != null) {
|
||||
_lastFetchTime = DateTime.fromMillisecondsSinceEpoch(lastFetchMs);
|
||||
}
|
||||
|
||||
// Load processed data
|
||||
final processedDataString = prefs.getString(_prefsKeyProcessedData);
|
||||
if (processedDataString != null) {
|
||||
final List<dynamic> processedDataList = jsonDecode(processedDataString);
|
||||
_processedEntries = processedDataList
|
||||
.map((json) => SuspectedLocationEntry.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
debugPrint('[SuspectedLocationCache] Loaded ${_processedEntries.length} processed entries from storage');
|
||||
// Only update if this is still the most recent request
|
||||
if (boundsKey == _getBoundsKey(bounds) || _currentBoundsKey == null) {
|
||||
_currentLocations = locations;
|
||||
_currentBoundsKey = boundsKey;
|
||||
notifyListeners(); // Trigger UI update
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[SuspectedLocationCache] Error loading from storage: $e');
|
||||
_processedEntries.clear();
|
||||
_lastFetchTime = null;
|
||||
debugPrint('[SuspectedLocationCache] Error updating cache: $e');
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Process raw CSV data and save to storage (calculates centroids once)
|
||||
/// Generate cache key for bounds
|
||||
String _getBoundsKey(LatLngBounds bounds) {
|
||||
return '${bounds.north.toStringAsFixed(4)},${bounds.south.toStringAsFixed(4)},${bounds.east.toStringAsFixed(4)},${bounds.west.toStringAsFixed(4)}';
|
||||
}
|
||||
|
||||
/// Initialize the cache (ensures database is ready)
|
||||
Future<void> loadFromStorage() async {
|
||||
try {
|
||||
await _database.init();
|
||||
debugPrint('[SuspectedLocationCache] Database initialized successfully');
|
||||
} catch (e) {
|
||||
debugPrint('[SuspectedLocationCache] Error initializing database: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Process raw CSV data and save to database
|
||||
Future<void> processAndSave(
|
||||
List<Map<String, dynamic>> rawData,
|
||||
DateTime fetchTime,
|
||||
@@ -132,96 +110,39 @@ class SuspectedLocationCache extends ChangeNotifier {
|
||||
try {
|
||||
debugPrint('[SuspectedLocationCache] Processing ${rawData.length} raw entries...');
|
||||
|
||||
final processedEntries = <SuspectedLocationEntry>[];
|
||||
int validCount = 0;
|
||||
int errorCount = 0;
|
||||
int zeroCoordCount = 0;
|
||||
// Clear cache since data will change
|
||||
_currentLocations = [];
|
||||
_currentBoundsKey = null;
|
||||
_isLoading = false;
|
||||
|
||||
for (int i = 0; i < rawData.length; i++) {
|
||||
final rowData = rawData[i];
|
||||
|
||||
// Log progress every 1000 entries for debugging
|
||||
if (i % 1000 == 0) {
|
||||
debugPrint('[SuspectedLocationCache] Processed ${i + 1}/${rawData.length} entries...');
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a temporary SuspectedLocation to extract the centroid
|
||||
final tempLocation = SuspectedLocation.fromCsvRow(rowData);
|
||||
|
||||
// Only save if we have a valid centroid (not at 0,0)
|
||||
if (tempLocation.centroid.latitude != 0 || tempLocation.centroid.longitude != 0) {
|
||||
processedEntries.add(SuspectedLocationEntry(
|
||||
rawData: rowData,
|
||||
centroid: tempLocation.centroid,
|
||||
));
|
||||
validCount++;
|
||||
} else {
|
||||
zeroCoordCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Insert data into database in batch
|
||||
await _database.insertBatch(rawData, fetchTime);
|
||||
|
||||
debugPrint('[SuspectedLocationCache] Processing complete - Valid: $validCount, Zero coords: $zeroCoordCount, Errors: $errorCount');
|
||||
final totalCount = await _database.getTotalCount();
|
||||
debugPrint('[SuspectedLocationCache] Processed and saved $totalCount entries to database');
|
||||
|
||||
_processedEntries = processedEntries;
|
||||
_lastFetchTime = fetchTime;
|
||||
|
||||
// Clear bounds cache since data changed
|
||||
_boundsCache.clear();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Save processed data
|
||||
final processedDataString = jsonEncode(processedEntries.map((e) => e.toJson()).toList());
|
||||
await prefs.setString(_prefsKeyProcessedData, processedDataString);
|
||||
|
||||
// Save last fetch time
|
||||
await prefs.setInt(_prefsKeyLastFetch, fetchTime.millisecondsSinceEpoch);
|
||||
|
||||
// Log coordinate ranges for debugging
|
||||
if (processedEntries.isNotEmpty) {
|
||||
double minLat = processedEntries.first.centroid.latitude;
|
||||
double maxLat = minLat;
|
||||
double minLng = processedEntries.first.centroid.longitude;
|
||||
double maxLng = minLng;
|
||||
|
||||
for (final entry in processedEntries) {
|
||||
final lat = entry.centroid.latitude;
|
||||
final lng = entry.centroid.longitude;
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
if (lng < minLng) minLng = lng;
|
||||
if (lng > maxLng) maxLng = lng;
|
||||
}
|
||||
|
||||
debugPrint('[SuspectedLocationCache] Coordinate ranges - Lat: $minLat to $maxLat, Lng: $minLng to $maxLng');
|
||||
}
|
||||
|
||||
debugPrint('[SuspectedLocationCache] Processed and saved $validCount valid entries (${processedEntries.length} total)');
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('[SuspectedLocationCache] Error processing and saving: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all cached data
|
||||
void clear() {
|
||||
_processedEntries.clear();
|
||||
_boundsCache.clear();
|
||||
_lastFetchTime = null;
|
||||
Future<void> clear() async {
|
||||
_currentLocations = [];
|
||||
_currentBoundsKey = null;
|
||||
_isLoading = false;
|
||||
await _database.clearAllData();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Get last fetch time
|
||||
DateTime? get lastFetchTime => _lastFetchTime;
|
||||
Future<DateTime?> get lastFetchTime => _database.getLastFetchTime();
|
||||
|
||||
/// Get total count of processed entries
|
||||
int get totalCount => _processedEntries.length;
|
||||
Future<int> get totalCount => _database.getTotalCount();
|
||||
|
||||
/// Check if we have data
|
||||
bool get hasData => _processedEntries.isNotEmpty;
|
||||
Future<bool> get hasData => _database.hasData();
|
||||
}
|
||||
328
lib/services/suspected_location_database.dart
Normal file
328
lib/services/suspected_location_database.dart
Normal file
@@ -0,0 +1,328 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
import '../models/suspected_location.dart';
|
||||
|
||||
/// Database service for suspected location data
|
||||
/// Replaces the SharedPreferences-based cache to handle large datasets efficiently
|
||||
class SuspectedLocationDatabase {
|
||||
static final SuspectedLocationDatabase _instance = SuspectedLocationDatabase._();
|
||||
factory SuspectedLocationDatabase() => _instance;
|
||||
SuspectedLocationDatabase._();
|
||||
|
||||
Database? _database;
|
||||
static const String _dbName = 'suspected_locations.db';
|
||||
static const int _dbVersion = 1;
|
||||
|
||||
// Table and column names
|
||||
static const String _tableName = 'suspected_locations';
|
||||
static const String _columnTicketNo = 'ticket_no';
|
||||
static const String _columnCentroidLat = 'centroid_lat';
|
||||
static const String _columnCentroidLng = 'centroid_lng';
|
||||
static const String _columnBounds = 'bounds';
|
||||
static const String _columnGeoJson = 'geo_json';
|
||||
static const String _columnAllFields = 'all_fields';
|
||||
|
||||
// Metadata table for tracking last fetch time
|
||||
static const String _metaTableName = 'metadata';
|
||||
static const String _metaColumnKey = 'key';
|
||||
static const String _metaColumnValue = 'value';
|
||||
static const String _lastFetchKey = 'last_fetch_time';
|
||||
|
||||
/// Initialize the database
|
||||
Future<void> init() async {
|
||||
if (_database != null) return;
|
||||
|
||||
try {
|
||||
final dbPath = await getDatabasesPath();
|
||||
final fullPath = path.join(dbPath, _dbName);
|
||||
|
||||
debugPrint('[SuspectedLocationDatabase] Initializing database at $fullPath');
|
||||
|
||||
_database = await openDatabase(
|
||||
fullPath,
|
||||
version: _dbVersion,
|
||||
onCreate: _createTables,
|
||||
onUpgrade: _upgradeTables,
|
||||
);
|
||||
|
||||
debugPrint('[SuspectedLocationDatabase] Database initialized successfully');
|
||||
} catch (e) {
|
||||
debugPrint('[SuspectedLocationDatabase] Error initializing database: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create database tables
|
||||
Future<void> _createTables(Database db, int version) async {
|
||||
debugPrint('[SuspectedLocationDatabase] Creating tables...');
|
||||
|
||||
// Main suspected locations table
|
||||
await db.execute('''
|
||||
CREATE TABLE $_tableName (
|
||||
$_columnTicketNo TEXT PRIMARY KEY,
|
||||
$_columnCentroidLat REAL NOT NULL,
|
||||
$_columnCentroidLng REAL NOT NULL,
|
||||
$_columnBounds TEXT,
|
||||
$_columnGeoJson TEXT,
|
||||
$_columnAllFields TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
// Create spatial indexes for efficient bounds queries
|
||||
// Separate indexes for lat and lng for better query optimization
|
||||
await db.execute('''
|
||||
CREATE INDEX idx_lat ON $_tableName ($_columnCentroidLat)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE INDEX idx_lng ON $_tableName ($_columnCentroidLng)
|
||||
''');
|
||||
// Composite index for combined lat/lng queries
|
||||
await db.execute('''
|
||||
CREATE INDEX idx_lat_lng ON $_tableName ($_columnCentroidLat, $_columnCentroidLng)
|
||||
''');
|
||||
|
||||
// Metadata table for tracking last fetch time and other info
|
||||
await db.execute('''
|
||||
CREATE TABLE $_metaTableName (
|
||||
$_metaColumnKey TEXT PRIMARY KEY,
|
||||
$_metaColumnValue TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
debugPrint('[SuspectedLocationDatabase] Tables created successfully');
|
||||
}
|
||||
|
||||
/// Handle database upgrades
|
||||
Future<void> _upgradeTables(Database db, int oldVersion, int newVersion) async {
|
||||
debugPrint('[SuspectedLocationDatabase] Upgrading database from version $oldVersion to $newVersion');
|
||||
// Future migrations would go here
|
||||
}
|
||||
|
||||
/// Get database instance, initializing if needed
|
||||
Future<Database> get database async {
|
||||
if (_database == null) {
|
||||
await init();
|
||||
}
|
||||
return _database!;
|
||||
}
|
||||
|
||||
/// Clear all data and recreate tables
|
||||
Future<void> clearAllData() async {
|
||||
try {
|
||||
final db = await database;
|
||||
|
||||
debugPrint('[SuspectedLocationDatabase] Clearing all data...');
|
||||
|
||||
// Drop and recreate tables (simpler than DELETE for large datasets)
|
||||
// Indexes are automatically dropped with tables
|
||||
await db.execute('DROP TABLE IF EXISTS $_tableName');
|
||||
await db.execute('DROP TABLE IF EXISTS $_metaTableName');
|
||||
await _createTables(db, _dbVersion);
|
||||
|
||||
debugPrint('[SuspectedLocationDatabase] All data cleared successfully');
|
||||
} catch (e) {
|
||||
debugPrint('[SuspectedLocationDatabase] Error clearing data: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert suspected locations in batch
|
||||
Future<void> insertBatch(List<Map<String, dynamic>> rawDataList, DateTime fetchTime) async {
|
||||
try {
|
||||
final db = await database;
|
||||
|
||||
debugPrint('[SuspectedLocationDatabase] Starting batch insert of ${rawDataList.length} entries...');
|
||||
|
||||
// Clear existing data first
|
||||
await clearAllData();
|
||||
|
||||
// Process entries in batches to avoid memory issues
|
||||
const batchSize = 1000;
|
||||
int validCount = 0;
|
||||
int errorCount = 0;
|
||||
|
||||
// Start transaction for better performance
|
||||
await db.transaction((txn) async {
|
||||
for (int i = 0; i < rawDataList.length; i += batchSize) {
|
||||
final batch = txn.batch();
|
||||
final endIndex = (i + batchSize < rawDataList.length) ? i + batchSize : rawDataList.length;
|
||||
final currentBatch = rawDataList.sublist(i, endIndex);
|
||||
|
||||
for (final rowData in currentBatch) {
|
||||
try {
|
||||
// Create temporary SuspectedLocation to extract centroid and bounds
|
||||
final tempLocation = SuspectedLocation.fromCsvRow(rowData);
|
||||
|
||||
// Skip entries with zero coordinates
|
||||
if (tempLocation.centroid.latitude == 0 && tempLocation.centroid.longitude == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prepare data for database insertion
|
||||
final dbRow = {
|
||||
_columnTicketNo: tempLocation.ticketNo,
|
||||
_columnCentroidLat: tempLocation.centroid.latitude,
|
||||
_columnCentroidLng: tempLocation.centroid.longitude,
|
||||
_columnBounds: tempLocation.bounds.isNotEmpty
|
||||
? jsonEncode(tempLocation.bounds.map((p) => [p.latitude, p.longitude]).toList())
|
||||
: null,
|
||||
_columnGeoJson: tempLocation.geoJson != null ? jsonEncode(tempLocation.geoJson!) : null,
|
||||
_columnAllFields: jsonEncode(tempLocation.allFields),
|
||||
};
|
||||
|
||||
batch.insert(_tableName, dbRow, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
validCount++;
|
||||
|
||||
} catch (e) {
|
||||
errorCount++;
|
||||
// Skip invalid entries
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Commit this batch
|
||||
await batch.commit(noResult: true);
|
||||
|
||||
// Log progress every few batches
|
||||
if ((i ~/ batchSize) % 5 == 0) {
|
||||
debugPrint('[SuspectedLocationDatabase] Processed ${i + currentBatch.length}/${rawDataList.length} entries...');
|
||||
}
|
||||
}
|
||||
|
||||
// Insert metadata
|
||||
await txn.insert(
|
||||
_metaTableName,
|
||||
{
|
||||
_metaColumnKey: _lastFetchKey,
|
||||
_metaColumnValue: fetchTime.millisecondsSinceEpoch.toString(),
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
});
|
||||
|
||||
debugPrint('[SuspectedLocationDatabase] Batch insert complete - Valid: $validCount, Errors: $errorCount');
|
||||
} catch (e) {
|
||||
debugPrint('[SuspectedLocationDatabase] Error in batch insert: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get suspected locations within bounding box
|
||||
Future<List<SuspectedLocation>> getLocationsInBounds(LatLngBounds bounds) async {
|
||||
try {
|
||||
final db = await database;
|
||||
|
||||
// Query with spatial bounds (simple lat/lng box filtering)
|
||||
final result = await db.query(
|
||||
_tableName,
|
||||
where: '''
|
||||
$_columnCentroidLat <= ? AND $_columnCentroidLat >= ? AND
|
||||
$_columnCentroidLng <= ? AND $_columnCentroidLng >= ?
|
||||
''',
|
||||
whereArgs: [bounds.north, bounds.south, bounds.east, bounds.west],
|
||||
);
|
||||
|
||||
// Convert database rows to SuspectedLocation objects
|
||||
final locations = <SuspectedLocation>[];
|
||||
for (final row in result) {
|
||||
try {
|
||||
final allFields = Map<String, dynamic>.from(jsonDecode(row[_columnAllFields] as String));
|
||||
|
||||
// Reconstruct bounds if available
|
||||
List<LatLng> boundsList = [];
|
||||
final boundsJson = row[_columnBounds] as String?;
|
||||
if (boundsJson != null) {
|
||||
final boundsData = jsonDecode(boundsJson) as List;
|
||||
boundsList = boundsData.map((b) => LatLng(
|
||||
(b[0] as num).toDouble(),
|
||||
(b[1] as num).toDouble(),
|
||||
)).toList();
|
||||
}
|
||||
|
||||
// Reconstruct GeoJSON if available
|
||||
Map<String, dynamic>? geoJson;
|
||||
final geoJsonString = row[_columnGeoJson] as String?;
|
||||
if (geoJsonString != null) {
|
||||
geoJson = Map<String, dynamic>.from(jsonDecode(geoJsonString));
|
||||
}
|
||||
|
||||
final location = SuspectedLocation(
|
||||
ticketNo: row[_columnTicketNo] as String,
|
||||
centroid: LatLng(
|
||||
row[_columnCentroidLat] as double,
|
||||
row[_columnCentroidLng] as double,
|
||||
),
|
||||
bounds: boundsList,
|
||||
geoJson: geoJson,
|
||||
allFields: allFields,
|
||||
);
|
||||
|
||||
locations.add(location);
|
||||
} catch (e) {
|
||||
// Skip invalid database entries
|
||||
debugPrint('[SuspectedLocationDatabase] Error parsing row: $e');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return locations;
|
||||
} catch (e) {
|
||||
debugPrint('[SuspectedLocationDatabase] Error querying bounds: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get last fetch time
|
||||
Future<DateTime?> getLastFetchTime() async {
|
||||
try {
|
||||
final db = await database;
|
||||
|
||||
final result = await db.query(
|
||||
_metaTableName,
|
||||
where: '$_metaColumnKey = ?',
|
||||
whereArgs: [_lastFetchKey],
|
||||
);
|
||||
|
||||
if (result.isNotEmpty) {
|
||||
final value = result.first[_metaColumnValue] as String;
|
||||
return DateTime.fromMillisecondsSinceEpoch(int.parse(value));
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('[SuspectedLocationDatabase] Error getting last fetch time: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get total count of entries
|
||||
Future<int> getTotalCount() async {
|
||||
try {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery('SELECT COUNT(*) as count FROM $_tableName');
|
||||
return Sqflite.firstIntValue(result) ?? 0;
|
||||
} catch (e) {
|
||||
debugPrint('[SuspectedLocationDatabase] Error getting total count: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if database has data
|
||||
Future<bool> hasData() async {
|
||||
final count = await getTotalCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/// Close database connection
|
||||
Future<void> close() async {
|
||||
if (_database != null) {
|
||||
await _database!.close();
|
||||
_database = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -18,13 +16,13 @@ class SuspectedLocationService {
|
||||
|
||||
static const String _prefsKeyEnabled = 'suspected_locations_enabled';
|
||||
static const Duration _maxAge = Duration(days: 7);
|
||||
static const Duration _timeout = Duration(seconds: 30);
|
||||
static const Duration _timeout = Duration(minutes: 5); // Increased for large CSV files (100MB+)
|
||||
|
||||
final SuspectedLocationCache _cache = SuspectedLocationCache();
|
||||
bool _isEnabled = false;
|
||||
|
||||
/// Get last fetch time
|
||||
DateTime? get lastFetchTime => _cache.lastFetchTime;
|
||||
Future<DateTime?> get lastFetchTime => _cache.lastFetchTime;
|
||||
|
||||
/// Check if suspected locations are enabled
|
||||
bool get isEnabled => _isEnabled;
|
||||
@@ -37,11 +35,12 @@ class SuspectedLocationService {
|
||||
await _cache.loadFromStorage();
|
||||
|
||||
// Only auto-fetch if enabled, data is stale or missing, and we are not offline
|
||||
if (_isEnabled && _shouldRefresh() && !offlineMode) {
|
||||
if (_isEnabled && (await _shouldRefresh()) && !offlineMode) {
|
||||
debugPrint('[SuspectedLocationService] Auto-refreshing CSV data on startup (older than $_maxAge or missing)');
|
||||
await _fetchData();
|
||||
} else if (_isEnabled && _shouldRefresh() && offlineMode) {
|
||||
debugPrint('[SuspectedLocationService] Skipping auto-refresh due to offline mode - data is ${_cache.lastFetchTime != null ? 'outdated' : 'missing'}');
|
||||
} else if (_isEnabled && (await _shouldRefresh()) && offlineMode) {
|
||||
final lastFetch = await _cache.lastFetchTime;
|
||||
debugPrint('[SuspectedLocationService] Skipping auto-refresh due to offline mode - data is ${lastFetch != null ? 'outdated' : 'missing'}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,36 +52,37 @@ class SuspectedLocationService {
|
||||
|
||||
// If disabling, clear the cache
|
||||
if (!enabled) {
|
||||
_cache.clear();
|
||||
await _cache.clear();
|
||||
}
|
||||
// Note: If enabling and no data, the state layer will call fetchDataIfNeeded()
|
||||
}
|
||||
|
||||
/// Check if cache has any data
|
||||
bool get hasData => _cache.hasData;
|
||||
Future<bool> get hasData => _cache.hasData;
|
||||
|
||||
/// Get last fetch time
|
||||
DateTime? get lastFetch => _cache.lastFetchTime;
|
||||
Future<DateTime?> get lastFetch => _cache.lastFetchTime;
|
||||
|
||||
/// Fetch data if needed (for enabling suspected locations when no data exists)
|
||||
Future<bool> fetchDataIfNeeded() async {
|
||||
if (!_shouldRefresh()) {
|
||||
Future<bool> fetchDataIfNeeded({void Function(double)? onProgress}) async {
|
||||
if (!(await _shouldRefresh())) {
|
||||
debugPrint('[SuspectedLocationService] Data is fresh, skipping fetch');
|
||||
return true; // Already have fresh data
|
||||
}
|
||||
return await _fetchData();
|
||||
return await _fetchData(onProgress: onProgress);
|
||||
}
|
||||
|
||||
/// Force refresh the data (for manual refresh button)
|
||||
Future<bool> forceRefresh() async {
|
||||
return await _fetchData();
|
||||
Future<bool> forceRefresh({void Function(double)? onProgress}) async {
|
||||
return await _fetchData(onProgress: onProgress);
|
||||
}
|
||||
|
||||
/// Check if data should be refreshed
|
||||
bool _shouldRefresh() {
|
||||
if (!_cache.hasData) return true;
|
||||
if (_cache.lastFetchTime == null) return true;
|
||||
return DateTime.now().difference(_cache.lastFetchTime!) > _maxAge;
|
||||
Future<bool> _shouldRefresh() async {
|
||||
if (!(await _cache.hasData)) return true;
|
||||
final lastFetch = await _cache.lastFetchTime;
|
||||
if (lastFetch == null) return true;
|
||||
return DateTime.now().difference(lastFetch) > _maxAge;
|
||||
}
|
||||
|
||||
/// Load settings from shared preferences
|
||||
@@ -100,111 +100,175 @@ class SuspectedLocationService {
|
||||
}
|
||||
|
||||
/// Fetch data from the CSV URL
|
||||
Future<bool> _fetchData() async {
|
||||
try {
|
||||
debugPrint('[SuspectedLocationService] Fetching CSV data from $kSuspectedLocationsCsvUrl');
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse(kSuspectedLocationsCsvUrl),
|
||||
headers: {
|
||||
'User-Agent': 'DeFlock/1.0 (OSM surveillance mapping app)',
|
||||
},
|
||||
).timeout(_timeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
debugPrint('[SuspectedLocationService] HTTP error ${response.statusCode}');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse CSV with proper field separator and quote handling
|
||||
final csvData = await compute(_parseCSV, response.body);
|
||||
debugPrint('[SuspectedLocationService] Parsed ${csvData.length} rows from CSV');
|
||||
|
||||
if (csvData.isEmpty) {
|
||||
debugPrint('[SuspectedLocationService] Empty CSV data');
|
||||
return false;
|
||||
}
|
||||
|
||||
// First row should be headers
|
||||
final headers = csvData.first.map((h) => h.toString().toLowerCase()).toList();
|
||||
debugPrint('[SuspectedLocationService] Headers: $headers');
|
||||
final dataRows = csvData.skip(1);
|
||||
debugPrint('[SuspectedLocationService] Data rows count: ${dataRows.length}');
|
||||
|
||||
// Find required column indices - we only need ticket_no and location
|
||||
final ticketNoIndex = headers.indexOf('ticket_no');
|
||||
final locationIndex = headers.indexOf('location');
|
||||
|
||||
debugPrint('[SuspectedLocationService] Column indices - ticket_no: $ticketNoIndex, location: $locationIndex');
|
||||
|
||||
if (ticketNoIndex == -1 || locationIndex == -1) {
|
||||
debugPrint('[SuspectedLocationService] Required columns not found in CSV. Headers: $headers');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse rows and store all data dynamically
|
||||
final List<Map<String, dynamic>> rawDataList = [];
|
||||
int rowIndex = 0;
|
||||
int validRows = 0;
|
||||
for (final row in dataRows) {
|
||||
rowIndex++;
|
||||
try {
|
||||
final Map<String, dynamic> rowData = {};
|
||||
Future<bool> _fetchData({void Function(double)? onProgress}) async {
|
||||
const maxRetries = 3;
|
||||
|
||||
for (int attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
debugPrint('[SuspectedLocationService] Fetching CSV data from $kSuspectedLocationsCsvUrl (attempt $attempt/$maxRetries)');
|
||||
if (attempt == 1) {
|
||||
debugPrint('[SuspectedLocationService] This may take up to ${_timeout.inMinutes} minutes for large datasets...');
|
||||
}
|
||||
|
||||
// Use streaming download for progress tracking
|
||||
final request = http.Request('GET', Uri.parse(kSuspectedLocationsCsvUrl));
|
||||
request.headers['User-Agent'] = 'DeFlock/1.0 (OSM surveillance mapping app)';
|
||||
|
||||
final client = http.Client();
|
||||
final streamedResponse = await client.send(request).timeout(_timeout);
|
||||
|
||||
if (streamedResponse.statusCode != 200) {
|
||||
debugPrint('[SuspectedLocationService] HTTP error ${streamedResponse.statusCode}');
|
||||
client.close();
|
||||
throw Exception('HTTP ${streamedResponse.statusCode}');
|
||||
}
|
||||
|
||||
final contentLength = streamedResponse.contentLength;
|
||||
debugPrint('[SuspectedLocationService] Starting download of ${contentLength != null ? '$contentLength bytes' : 'unknown size'}...');
|
||||
|
||||
// Download with progress tracking
|
||||
final chunks = <List<int>>[];
|
||||
int downloadedBytes = 0;
|
||||
|
||||
await for (final chunk in streamedResponse.stream) {
|
||||
chunks.add(chunk);
|
||||
downloadedBytes += chunk.length;
|
||||
|
||||
// Store all columns dynamically
|
||||
for (int i = 0; i < headers.length && i < row.length; i++) {
|
||||
final headerName = headers[i];
|
||||
final cellValue = row[i];
|
||||
if (cellValue != null) {
|
||||
rowData[headerName] = cellValue;
|
||||
// Report progress if we know the total size
|
||||
if (contentLength != null && onProgress != null) {
|
||||
try {
|
||||
final progress = downloadedBytes / contentLength;
|
||||
onProgress(progress.clamp(0.0, 1.0));
|
||||
} catch (e) {
|
||||
// Don't let progress callback errors break the download
|
||||
debugPrint('[SuspectedLocationService] Progress callback error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Basic validation - must have ticket_no and location
|
||||
if (rowData['ticket_no']?.toString().isNotEmpty == true &&
|
||||
rowData['location']?.toString().isNotEmpty == true) {
|
||||
rawDataList.add(rowData);
|
||||
validRows++;
|
||||
}
|
||||
|
||||
client.close();
|
||||
|
||||
// Combine chunks into single response body
|
||||
final bodyBytes = chunks.expand((chunk) => chunk).toList();
|
||||
final responseBody = String.fromCharCodes(bodyBytes);
|
||||
|
||||
debugPrint('[SuspectedLocationService] Downloaded $downloadedBytes bytes, parsing CSV...');
|
||||
|
||||
// Parse CSV with proper field separator and quote handling
|
||||
final csvData = await compute(_parseCSV, responseBody);
|
||||
debugPrint('[SuspectedLocationService] Parsed ${csvData.length} rows from CSV');
|
||||
|
||||
if (csvData.isEmpty) {
|
||||
debugPrint('[SuspectedLocationService] Empty CSV data');
|
||||
throw Exception('Empty CSV data');
|
||||
}
|
||||
|
||||
// First row should be headers
|
||||
final headers = csvData.first.map((h) => h.toString().toLowerCase()).toList();
|
||||
debugPrint('[SuspectedLocationService] Headers: $headers');
|
||||
final dataRows = csvData.skip(1);
|
||||
debugPrint('[SuspectedLocationService] Data rows count: ${dataRows.length}');
|
||||
|
||||
// Find required column indices - we only need ticket_no and location
|
||||
final ticketNoIndex = headers.indexOf('ticket_no');
|
||||
final locationIndex = headers.indexOf('location');
|
||||
|
||||
debugPrint('[SuspectedLocationService] Column indices - ticket_no: $ticketNoIndex, location: $locationIndex');
|
||||
|
||||
if (ticketNoIndex == -1 || locationIndex == -1) {
|
||||
debugPrint('[SuspectedLocationService] Required columns not found in CSV. Headers: $headers');
|
||||
throw Exception('Required columns not found in CSV');
|
||||
}
|
||||
|
||||
|
||||
// Parse rows and store all data dynamically
|
||||
final List<Map<String, dynamic>> rawDataList = [];
|
||||
int rowIndex = 0;
|
||||
int validRows = 0;
|
||||
for (final row in dataRows) {
|
||||
rowIndex++;
|
||||
try {
|
||||
final Map<String, dynamic> rowData = {};
|
||||
|
||||
// Store all columns dynamically
|
||||
for (int i = 0; i < headers.length && i < row.length; i++) {
|
||||
final headerName = headers[i];
|
||||
final cellValue = row[i];
|
||||
if (cellValue != null) {
|
||||
rowData[headerName] = cellValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Basic validation - must have ticket_no and location
|
||||
if (rowData['ticket_no']?.toString().isNotEmpty == true &&
|
||||
rowData['location']?.toString().isNotEmpty == true) {
|
||||
rawDataList.add(rowData);
|
||||
validRows++;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
// Skip rows that can't be parsed
|
||||
debugPrint('[SuspectedLocationService] Error parsing row $rowIndex: $e');
|
||||
continue;
|
||||
}
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
// Skip rows that can't be parsed
|
||||
debugPrint('[SuspectedLocationService] Error parsing row $rowIndex: $e');
|
||||
continue;
|
||||
}
|
||||
|
||||
debugPrint('[SuspectedLocationService] Parsed $validRows valid rows from ${dataRows.length} total rows');
|
||||
|
||||
final fetchTime = DateTime.now();
|
||||
|
||||
// Process raw data and save (calculates centroids once)
|
||||
await _cache.processAndSave(rawDataList, fetchTime);
|
||||
|
||||
debugPrint('[SuspectedLocationService] Successfully fetched and stored $validRows valid raw entries (${rawDataList.length} total)');
|
||||
return true;
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[SuspectedLocationService] Attempt $attempt failed: $e');
|
||||
|
||||
if (attempt == maxRetries) {
|
||||
debugPrint('[SuspectedLocationService] All $maxRetries attempts failed');
|
||||
debugPrint('[SuspectedLocationService] Stack trace: $stackTrace');
|
||||
return false;
|
||||
} else {
|
||||
// Wait before retrying (exponential backoff)
|
||||
final delay = Duration(seconds: attempt * 10);
|
||||
debugPrint('[SuspectedLocationService] Retrying in ${delay.inSeconds} seconds...');
|
||||
await Future.delayed(delay);
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('[SuspectedLocationService] Parsed $validRows valid rows from ${dataRows.length} total rows');
|
||||
|
||||
final fetchTime = DateTime.now();
|
||||
|
||||
// Process raw data and save (calculates centroids once)
|
||||
await _cache.processAndSave(rawDataList, fetchTime);
|
||||
|
||||
debugPrint('[SuspectedLocationService] Successfully fetched and stored $validRows valid raw entries (${rawDataList.length} total)');
|
||||
return true;
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[SuspectedLocationService] Error fetching data: $e');
|
||||
debugPrint('[SuspectedLocationService] Stack trace: $stackTrace');
|
||||
return false;
|
||||
}
|
||||
|
||||
return false; // Should never reach here
|
||||
}
|
||||
|
||||
/// Get suspected locations within a bounding box
|
||||
List<SuspectedLocation> getLocationsInBounds({
|
||||
/// Get suspected locations within a bounding box (async)
|
||||
Future<List<SuspectedLocation>> getLocationsInBounds({
|
||||
required double north,
|
||||
required double south,
|
||||
required double east,
|
||||
required double west,
|
||||
}) async {
|
||||
return await _cache.getLocationsForBounds(LatLngBounds(
|
||||
LatLng(north, west),
|
||||
LatLng(south, east),
|
||||
));
|
||||
}
|
||||
|
||||
/// Get suspected locations within a bounding box (sync, for UI)
|
||||
List<SuspectedLocation> getLocationsInBoundsSync({
|
||||
required double north,
|
||||
required double south,
|
||||
required double east,
|
||||
required double west,
|
||||
}) {
|
||||
return _cache.getLocationsForBounds(LatLngBounds(
|
||||
return _cache.getLocationsForBoundsSync(LatLngBounds(
|
||||
LatLng(north, west),
|
||||
LatLng(south, east),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Simple CSV parser for compute() - must be top-level function
|
||||
List<List<dynamic>> _parseCSV(String csvBody) {
|
||||
return const CsvToListConverter(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
|
||||
@@ -1,64 +1,100 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/pending_upload.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../state/settings_state.dart';
|
||||
import 'version_service.dart';
|
||||
import '../app_state.dart';
|
||||
|
||||
class UploadResult {
|
||||
final bool success;
|
||||
final String? errorMessage;
|
||||
final String? changesetId; // For changeset creation results
|
||||
final int? nodeId; // For node operation results
|
||||
final bool changesetNotFound; // Special flag for 404 case during close
|
||||
|
||||
UploadResult.success({
|
||||
this.changesetId,
|
||||
this.nodeId,
|
||||
}) : success = true, errorMessage = null, changesetNotFound = false;
|
||||
|
||||
UploadResult.failure({
|
||||
required this.errorMessage,
|
||||
this.changesetNotFound = false,
|
||||
this.changesetId,
|
||||
this.nodeId,
|
||||
}) : success = false;
|
||||
|
||||
// Legacy compatibility for simulate mode and full upload method
|
||||
bool get isFullySuccessful => success;
|
||||
bool get changesetCreateSuccess => success;
|
||||
bool get nodeOperationSuccess => success;
|
||||
bool get changesetCloseSuccess => success;
|
||||
bool get hasOrphanedChangeset => changesetId != null && !success;
|
||||
}
|
||||
|
||||
class Uploader {
|
||||
Uploader(this.accessToken, this.onSuccess, {this.uploadMode = UploadMode.production});
|
||||
Uploader(this.accessToken, this.onSuccess, this.onError, {this.uploadMode = UploadMode.production});
|
||||
|
||||
final String accessToken;
|
||||
final void Function(int nodeId) onSuccess;
|
||||
final void Function(String errorMessage) onError;
|
||||
final UploadMode uploadMode;
|
||||
|
||||
Future<bool> upload(PendingUpload p) async {
|
||||
// Create changeset (step 1 of 3)
|
||||
Future<UploadResult> createChangeset(PendingUpload p) async {
|
||||
try {
|
||||
print('Uploader: Starting upload for node at ${p.coord.latitude}, ${p.coord.longitude}');
|
||||
debugPrint('[Uploader] Creating changeset for ${p.operation.name} operation...');
|
||||
|
||||
// Safety check: create, modify, and extract operations MUST have profiles
|
||||
if ((p.operation == UploadOperation.create || p.operation == UploadOperation.modify || p.operation == UploadOperation.extract) && p.profile == null) {
|
||||
print('Uploader: ERROR - ${p.operation.name} operation attempted without profile data');
|
||||
return false;
|
||||
final errorMsg = 'Missing profile data for ${p.operation.name} operation';
|
||||
debugPrint('[Uploader] ERROR - $errorMsg');
|
||||
return UploadResult.failure(errorMessage: errorMsg);
|
||||
}
|
||||
|
||||
// 1. open changeset
|
||||
String action;
|
||||
switch (p.operation) {
|
||||
case UploadOperation.create:
|
||||
action = 'Add';
|
||||
break;
|
||||
case UploadOperation.modify:
|
||||
action = 'Update';
|
||||
break;
|
||||
case UploadOperation.delete:
|
||||
action = 'Delete';
|
||||
break;
|
||||
case UploadOperation.extract:
|
||||
action = 'Extract';
|
||||
break;
|
||||
}
|
||||
// Generate appropriate comment based on operation type
|
||||
final profileName = p.profile?.name ?? 'surveillance';
|
||||
// Use the user's changeset comment, with XML sanitization
|
||||
final sanitizedComment = _sanitizeXmlText(p.changesetComment);
|
||||
final csXml = '''
|
||||
<osm>
|
||||
<changeset>
|
||||
<tag k="created_by" v="$kClientName ${VersionService().version}"/>
|
||||
<tag k="comment" v="$action $profileName surveillance node"/>
|
||||
<tag k="comment" v="$sanitizedComment"/>
|
||||
</changeset>
|
||||
</osm>''';
|
||||
print('Uploader: Creating changeset...');
|
||||
|
||||
debugPrint('[Uploader] Creating changeset...');
|
||||
final csResp = await _put('/api/0.6/changeset/create', csXml);
|
||||
print('Uploader: Changeset response: ${csResp.statusCode} - ${csResp.body}');
|
||||
debugPrint('[Uploader] Changeset response: ${csResp.statusCode} - ${csResp.body}');
|
||||
|
||||
if (csResp.statusCode != 200) {
|
||||
print('Uploader: Failed to create changeset');
|
||||
return false;
|
||||
final errorMsg = 'Failed to create changeset: HTTP ${csResp.statusCode} - ${csResp.body}';
|
||||
debugPrint('[Uploader] $errorMsg');
|
||||
return UploadResult.failure(errorMessage: errorMsg);
|
||||
}
|
||||
|
||||
final csId = csResp.body.trim();
|
||||
print('Uploader: Created changeset ID: $csId');
|
||||
debugPrint('[Uploader] Created changeset ID: $csId');
|
||||
|
||||
return UploadResult.success(changesetId: csId);
|
||||
|
||||
} on TimeoutException catch (e) {
|
||||
final errorMsg = 'Changeset creation timed out after ${kUploadHttpTimeout.inSeconds}s: $e';
|
||||
debugPrint('[Uploader] $errorMsg');
|
||||
return UploadResult.failure(errorMessage: errorMsg);
|
||||
} catch (e) {
|
||||
final errorMsg = 'Changeset creation failed with unexpected error: $e';
|
||||
debugPrint('[Uploader] $errorMsg');
|
||||
return UploadResult.failure(errorMessage: errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. create, update, or delete node
|
||||
// Perform node operation (step 2 of 3)
|
||||
Future<UploadResult> performNodeOperation(PendingUpload p, String changesetId) async {
|
||||
try {
|
||||
debugPrint('[Uploader] Performing ${p.operation.name} operation with changeset $changesetId');
|
||||
|
||||
final http.Response nodeResp;
|
||||
final String nodeId;
|
||||
|
||||
@@ -70,34 +106,36 @@ class Uploader {
|
||||
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
|
||||
final nodeXml = '''
|
||||
<osm>
|
||||
<node changeset="$csId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
<node changeset="$changesetId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
$tagsXml
|
||||
</node>
|
||||
</osm>''';
|
||||
print('Uploader: Creating new node...');
|
||||
debugPrint('[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...');
|
||||
debugPrint('[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}');
|
||||
debugPrint('[Uploader] Current node response: ${currentNodeResp.statusCode}');
|
||||
if (currentNodeResp.statusCode != 200) {
|
||||
print('Uploader: Failed to fetch current node');
|
||||
return false;
|
||||
final errorMsg = 'Failed to fetch node ${p.originalNodeId}: HTTP ${currentNodeResp.statusCode} - ${currentNodeResp.body}';
|
||||
debugPrint('[Uploader] $errorMsg');
|
||||
return UploadResult.failure(errorMessage: errorMsg, changesetId: changesetId);
|
||||
}
|
||||
|
||||
// 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 errorMsg = 'Could not parse version from node XML: ${currentNodeXml.length > 200 ? '${currentNodeXml.substring(0, 200)}...' : currentNodeXml}';
|
||||
debugPrint('[Uploader] $errorMsg');
|
||||
return UploadResult.failure(errorMessage: errorMsg, changesetId: changesetId);
|
||||
}
|
||||
final currentVersion = versionMatch.group(1)!;
|
||||
print('Uploader: Current node version: $currentVersion');
|
||||
debugPrint('[Uploader] Current node version: $currentVersion');
|
||||
|
||||
// Update existing node with version
|
||||
final mergedTags = p.getCombinedTags();
|
||||
@@ -105,86 +143,181 @@ class Uploader {
|
||||
'<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}">
|
||||
<node changeset="$changesetId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
$tagsXml
|
||||
</node>
|
||||
</osm>''';
|
||||
print('Uploader: Updating node ${p.originalNodeId}...');
|
||||
debugPrint('[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...');
|
||||
// First, fetch the current node to get its version
|
||||
debugPrint('[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}');
|
||||
debugPrint('[Uploader] Current node response: ${currentNodeResp.statusCode}');
|
||||
if (currentNodeResp.statusCode != 200) {
|
||||
print('Uploader: Failed to fetch current node');
|
||||
return false;
|
||||
final errorMsg = 'Failed to fetch node ${p.originalNodeId} for deletion: HTTP ${currentNodeResp.statusCode} - ${currentNodeResp.body}';
|
||||
debugPrint('[Uploader] $errorMsg');
|
||||
return UploadResult.failure(errorMessage: errorMsg, changesetId: changesetId);
|
||||
}
|
||||
|
||||
// Parse version and tags from the response XML
|
||||
// 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 errorMsg = 'Could not parse version from node XML for deletion: ${currentNodeXml.length > 200 ? '${currentNodeXml.substring(0, 200)}...' : currentNodeXml}';
|
||||
debugPrint('[Uploader] $errorMsg');
|
||||
return UploadResult.failure(errorMessage: errorMsg, changesetId: changesetId);
|
||||
}
|
||||
final currentVersion = versionMatch.group(1)!;
|
||||
print('Uploader: Current node version: $currentVersion');
|
||||
debugPrint('[Uploader] Current node version: $currentVersion');
|
||||
|
||||
// Delete node - OSM requires current tags and coordinates
|
||||
// Delete node - OSM requires current coordinates but empty tags
|
||||
final nodeXml = '''
|
||||
<osm>
|
||||
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
<node changeset="$changesetId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
</node>
|
||||
</osm>''';
|
||||
print('Uploader: Deleting node ${p.originalNodeId}...');
|
||||
debugPrint('[Uploader] Deleting node ${p.originalNodeId}...');
|
||||
nodeResp = await _delete('/api/0.6/node/${p.originalNodeId}', nodeXml);
|
||||
nodeId = p.originalNodeId.toString();
|
||||
break;
|
||||
|
||||
case UploadOperation.extract:
|
||||
// Extract creates a new node with tags from the original node
|
||||
// The new node is created at the session's target coordinates
|
||||
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}">
|
||||
<node changeset="$changesetId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
$tagsXml
|
||||
</node>
|
||||
</osm>''';
|
||||
print('Uploader: Extracting node from ${p.originalNodeId} to create new node...');
|
||||
debugPrint('[Uploader] Extracting node from ${p.originalNodeId} to create new node...');
|
||||
nodeResp = await _put('/api/0.6/node/create', nodeXml);
|
||||
nodeId = nodeResp.body.trim();
|
||||
break;
|
||||
}
|
||||
|
||||
print('Uploader: Node response: ${nodeResp.statusCode} - ${nodeResp.body}');
|
||||
debugPrint('[Uploader] Node response: ${nodeResp.statusCode} - ${nodeResp.body}');
|
||||
if (nodeResp.statusCode != 200) {
|
||||
print('Uploader: Failed to ${p.operation.name} node');
|
||||
return false;
|
||||
final errorMsg = 'Failed to ${p.operation.name} node: HTTP ${nodeResp.statusCode} - ${nodeResp.body}';
|
||||
debugPrint('[Uploader] $errorMsg');
|
||||
// Note: changeset is included so caller knows to close it
|
||||
return UploadResult.failure(errorMessage: errorMsg, changesetId: changesetId);
|
||||
}
|
||||
print('Uploader: ${p.operation.name.capitalize()} node ID: $nodeId');
|
||||
|
||||
// 3. close changeset
|
||||
print('Uploader: Closing changeset...');
|
||||
final closeResp = await _put('/api/0.6/changeset/$csId/close', '');
|
||||
print('Uploader: Close response: ${closeResp.statusCode}');
|
||||
|
||||
print('Uploader: Upload successful!');
|
||||
|
||||
final nodeIdInt = int.parse(nodeId);
|
||||
debugPrint('[Uploader] ${p.operation.name.capitalize()} node ID: $nodeIdInt');
|
||||
|
||||
// Notify success callback for immediate UI feedback
|
||||
onSuccess(nodeIdInt);
|
||||
return true;
|
||||
|
||||
return UploadResult.success(nodeId: nodeIdInt);
|
||||
|
||||
} on TimeoutException catch (e) {
|
||||
final errorMsg = 'Node operation timed out after ${kUploadHttpTimeout.inSeconds}s: $e';
|
||||
debugPrint('[Uploader] $errorMsg');
|
||||
return UploadResult.failure(errorMessage: errorMsg, changesetId: changesetId);
|
||||
} catch (e) {
|
||||
print('Uploader: Upload failed with error: $e');
|
||||
return false;
|
||||
final errorMsg = 'Node operation failed with unexpected error: $e';
|
||||
debugPrint('[Uploader] $errorMsg');
|
||||
return UploadResult.failure(errorMessage: errorMsg, changesetId: changesetId);
|
||||
}
|
||||
}
|
||||
|
||||
// Close changeset (step 3 of 3)
|
||||
Future<UploadResult> closeChangeset(String changesetId) async {
|
||||
try {
|
||||
debugPrint('[Uploader] Closing changeset $changesetId...');
|
||||
final closeResp = await _put('/api/0.6/changeset/$changesetId/close', '');
|
||||
debugPrint('[Uploader] Close response: ${closeResp.statusCode} - ${closeResp.body}');
|
||||
|
||||
switch (closeResp.statusCode) {
|
||||
case 200:
|
||||
debugPrint('[Uploader] Changeset closed successfully');
|
||||
return UploadResult.success();
|
||||
|
||||
case 409:
|
||||
// Conflict - check if changeset is already closed
|
||||
if (closeResp.body.toLowerCase().contains('already closed') ||
|
||||
closeResp.body.toLowerCase().contains('closed at')) {
|
||||
debugPrint('[Uploader] Changeset already closed');
|
||||
return UploadResult.success();
|
||||
} else {
|
||||
// Other conflict - keep retrying
|
||||
final errorMsg = 'Changeset close conflict: HTTP ${closeResp.statusCode} - ${closeResp.body}';
|
||||
return UploadResult.failure(errorMessage: errorMsg);
|
||||
}
|
||||
|
||||
case 404:
|
||||
// Changeset not found - this suggests the upload may not have worked
|
||||
debugPrint('[Uploader] Changeset not found - marking for full retry');
|
||||
return UploadResult.failure(
|
||||
errorMessage: 'Changeset not found: HTTP 404',
|
||||
changesetNotFound: true,
|
||||
);
|
||||
|
||||
default:
|
||||
// Other errors - keep retrying
|
||||
final errorMsg = 'Failed to close changeset $changesetId: HTTP ${closeResp.statusCode} - ${closeResp.body}';
|
||||
return UploadResult.failure(errorMessage: errorMsg);
|
||||
}
|
||||
} on TimeoutException catch (e) {
|
||||
final errorMsg = 'Changeset close timed out after ${kUploadHttpTimeout.inSeconds}s: $e';
|
||||
return UploadResult.failure(errorMessage: errorMsg);
|
||||
} catch (e) {
|
||||
final errorMsg = 'Changeset close failed with unexpected error: $e';
|
||||
return UploadResult.failure(errorMessage: errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy full upload method (primarily for simulate mode compatibility)
|
||||
Future<UploadResult> upload(PendingUpload p) async {
|
||||
debugPrint('[Uploader] Starting full upload for ${p.operation.name} at ${p.coord.latitude}, ${p.coord.longitude}');
|
||||
|
||||
// Step 1: Create changeset
|
||||
final createResult = await createChangeset(p);
|
||||
if (!createResult.success) {
|
||||
onError(createResult.errorMessage!);
|
||||
return createResult;
|
||||
}
|
||||
|
||||
final changesetId = createResult.changesetId!;
|
||||
|
||||
// Step 2: Perform node operation
|
||||
final nodeResult = await performNodeOperation(p, changesetId);
|
||||
if (!nodeResult.success) {
|
||||
onError(nodeResult.errorMessage!);
|
||||
// Note: nodeResult includes changesetId for caller to close if needed
|
||||
return nodeResult;
|
||||
}
|
||||
|
||||
// Step 3: Close changeset
|
||||
final closeResult = await closeChangeset(changesetId);
|
||||
if (!closeResult.success) {
|
||||
// Node operation succeeded but changeset close failed
|
||||
// Don't call onError since node operation worked
|
||||
debugPrint('[Uploader] Node operation succeeded but changeset close failed');
|
||||
return UploadResult.failure(
|
||||
errorMessage: closeResult.errorMessage,
|
||||
changesetNotFound: closeResult.changesetNotFound,
|
||||
changesetId: changesetId,
|
||||
nodeId: nodeResult.nodeId,
|
||||
);
|
||||
}
|
||||
|
||||
// All steps successful
|
||||
debugPrint('[Uploader] Full upload completed successfully');
|
||||
return UploadResult.success(
|
||||
changesetId: changesetId,
|
||||
nodeId: nodeResult.nodeId,
|
||||
);
|
||||
}
|
||||
|
||||
String get _host {
|
||||
switch (uploadMode) {
|
||||
case UploadMode.sandbox:
|
||||
@@ -198,35 +331,43 @@ class Uploader {
|
||||
Future<http.Response> _get(String path) => http.get(
|
||||
Uri.https(_host, path),
|
||||
headers: _headers,
|
||||
);
|
||||
|
||||
Future<http.Response> _post(String path, String body) => http.post(
|
||||
Uri.https(_host, path),
|
||||
headers: _headers,
|
||||
body: body,
|
||||
);
|
||||
).timeout(kUploadHttpTimeout);
|
||||
|
||||
Future<http.Response> _put(String path, String body) => http.put(
|
||||
Uri.https(_host, path),
|
||||
headers: _headers,
|
||||
body: body,
|
||||
);
|
||||
).timeout(kUploadHttpTimeout);
|
||||
|
||||
Future<http.Response> _delete(String path, String body) => http.delete(
|
||||
Uri.https(_host, path),
|
||||
headers: _headers,
|
||||
body: body,
|
||||
);
|
||||
).timeout(kUploadHttpTimeout);
|
||||
|
||||
Map<String, String> get _headers => {
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
'Content-Type': 'text/xml',
|
||||
};
|
||||
|
||||
/// Sanitize text for safe inclusion in XML attributes and content
|
||||
/// Removes or escapes characters that could break XML parsing
|
||||
String _sanitizeXmlText(String input) {
|
||||
return input
|
||||
.replaceAll('&', '&') // Must be first to avoid double-escaping
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
.replaceAll('\n', ' ') // Replace newlines with spaces
|
||||
.replaceAll('\r', ' ') // Replace carriage returns with spaces
|
||||
.replaceAll('\t', ' ') // Replace tabs with spaces
|
||||
.trim(); // Remove leading/trailing whitespace
|
||||
}
|
||||
}
|
||||
|
||||
extension StringExtension on String {
|
||||
String capitalize() {
|
||||
return "${this[0].toUpperCase()}${substring(1)}";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class AuthState extends ChangeNotifier {
|
||||
_username = await _auth.restoreLogin();
|
||||
}
|
||||
} catch (e) {
|
||||
print("AuthState: Error during auth initialization: $e");
|
||||
debugPrint("AuthState: Error during auth initialization: $e");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class AuthState extends ChangeNotifier {
|
||||
try {
|
||||
_username = await _auth.login();
|
||||
} catch (e) {
|
||||
print("AuthState: Login error: $e");
|
||||
debugPrint("AuthState: Login error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
@@ -49,7 +49,7 @@ class AuthState extends ChangeNotifier {
|
||||
_username = null;
|
||||
}
|
||||
} catch (e) {
|
||||
print("AuthState: Auth refresh error: $e");
|
||||
debugPrint("AuthState: Auth refresh error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
@@ -59,7 +59,7 @@ class AuthState extends ChangeNotifier {
|
||||
try {
|
||||
_username = await _auth.forceLogin();
|
||||
} catch (e) {
|
||||
print("AuthState: Forced login error: $e");
|
||||
debugPrint("AuthState: Forced login error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
@@ -69,7 +69,7 @@ class AuthState extends ChangeNotifier {
|
||||
try {
|
||||
return await _auth.isLoggedIn();
|
||||
} catch (e) {
|
||||
print("AuthState: Token validation error: $e");
|
||||
debugPrint("AuthState: Token validation error: $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ class AuthState extends ChangeNotifier {
|
||||
}
|
||||
} catch (e) {
|
||||
_username = null;
|
||||
print("AuthState: Mode change user restoration error: $e");
|
||||
debugPrint("AuthState: Mode change user restoration error: $e");
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
59
lib/state/messages_state.dart
Normal file
59
lib/state/messages_state.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/osm_messages_service.dart';
|
||||
import 'settings_state.dart';
|
||||
|
||||
/// State management for OSM message notifications
|
||||
class MessagesState extends ChangeNotifier {
|
||||
final OSMMessagesService _messagesService = OSMMessagesService();
|
||||
|
||||
int? _unreadCount;
|
||||
bool _isChecking = false;
|
||||
|
||||
// Getters
|
||||
int? get unreadCount => _unreadCount;
|
||||
bool get hasUnreadMessages => (_unreadCount ?? 0) > 0;
|
||||
bool get isChecking => _isChecking;
|
||||
|
||||
/// Check for unread messages
|
||||
Future<void> checkMessages({
|
||||
required String? accessToken,
|
||||
required UploadMode uploadMode,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
if (_isChecking) return; // Prevent concurrent checks
|
||||
|
||||
_isChecking = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final count = await _messagesService.getUnreadMessageCount(
|
||||
accessToken: accessToken,
|
||||
uploadMode: uploadMode,
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
|
||||
if (_unreadCount != count) {
|
||||
_unreadCount = count;
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently handle errors - messages are not critical
|
||||
debugPrint('MessagesState: Error checking messages: $e');
|
||||
} finally {
|
||||
_isChecking = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the URL to view messages
|
||||
String getMessagesUrl(UploadMode uploadMode) {
|
||||
return _messagesService.getMessagesUrl(uploadMode);
|
||||
}
|
||||
|
||||
/// Clear message state (when user logs out or changes mode)
|
||||
void clearMessages() {
|
||||
_unreadCount = null;
|
||||
_messagesService.clearCache();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/search_result.dart';
|
||||
import '../services/search_service.dart';
|
||||
import '../services/routing_service.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
/// Simplified navigation modes - brutalist approach
|
||||
enum AppNavigationMode {
|
||||
@@ -30,7 +32,8 @@ class NavigationState extends ChangeNotifier {
|
||||
bool _isSearchLoading = false;
|
||||
List<SearchResult> _searchResults = [];
|
||||
String _lastQuery = '';
|
||||
|
||||
LatLngBounds? _searchViewbox;
|
||||
|
||||
// Location state
|
||||
LatLng? _provisionalPinLocation;
|
||||
String? _provisionalPinAddress;
|
||||
@@ -75,20 +78,50 @@ class NavigationState extends ChangeNotifier {
|
||||
bool get showSearchButton => _mode == AppNavigationMode.normal;
|
||||
bool get showRouteButton => _mode == AppNavigationMode.routeActive;
|
||||
|
||||
/// BRUTALIST: Single entry point to search mode
|
||||
void enterSearchMode(LatLng mapCenter) {
|
||||
debugPrint('[NavigationState] enterSearchMode - current mode: $_mode');
|
||||
/// Check if the start and end locations are too close together
|
||||
bool get areRoutePointsTooClose {
|
||||
if (!_isSettingSecondPoint || _provisionalPinLocation == null) return false;
|
||||
|
||||
final firstPoint = _nextPointIsStart ? _routeEnd : _routeStart;
|
||||
if (firstPoint == null) return false;
|
||||
|
||||
final distance = const Distance().as(LengthUnit.Meter, firstPoint, _provisionalPinLocation!);
|
||||
return distance < kNavigationMinRouteDistance;
|
||||
}
|
||||
|
||||
/// Get distance from first navigation point to provisional location during second point selection
|
||||
double? get distanceFromFirstPoint {
|
||||
if (!_isSettingSecondPoint || _provisionalPinLocation == null) return null;
|
||||
|
||||
final firstPoint = _nextPointIsStart ? _routeEnd : _routeStart;
|
||||
if (firstPoint == null) return null;
|
||||
|
||||
return const Distance().as(LengthUnit.Meter, firstPoint, _provisionalPinLocation!);
|
||||
}
|
||||
|
||||
/// Check if distance between points would likely cause timeout issues
|
||||
bool get distanceExceedsWarningThreshold {
|
||||
final distance = distanceFromFirstPoint;
|
||||
if (distance == null) return false;
|
||||
|
||||
return distance > kNavigationDistanceWarningThreshold;
|
||||
}
|
||||
|
||||
/// BRUTALIST: Single entry point to search mode
|
||||
void enterSearchMode(LatLng mapCenter, {LatLngBounds? viewbox}) {
|
||||
debugPrint('[NavigationState] enterSearchMode - current mode: $_mode');
|
||||
|
||||
if (_mode != AppNavigationMode.normal) {
|
||||
debugPrint('[NavigationState] Cannot enter search mode - not in normal mode');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
_mode = AppNavigationMode.search;
|
||||
_provisionalPinLocation = mapCenter;
|
||||
_provisionalPinAddress = null;
|
||||
_searchViewbox = viewbox;
|
||||
_clearSearchResults();
|
||||
|
||||
|
||||
debugPrint('[NavigationState] Entered search mode');
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -119,7 +152,8 @@ class NavigationState extends ChangeNotifier {
|
||||
_showingOverview = false;
|
||||
_nextPointIsStart = false;
|
||||
_routingError = null;
|
||||
|
||||
_searchViewbox = null;
|
||||
|
||||
// Clear search
|
||||
_clearSearchResults();
|
||||
|
||||
@@ -195,8 +229,15 @@ class NavigationState extends ChangeNotifier {
|
||||
_routeEndAddress = _provisionalPinAddress;
|
||||
}
|
||||
|
||||
// BRUTALIST FIX: Set calculating state BEFORE clearing isSettingSecondPoint
|
||||
// to prevent UI from briefly showing route buttons again
|
||||
_isSettingSecondPoint = false;
|
||||
_isCalculating = true;
|
||||
_routingError = null; // Clear any previous errors
|
||||
|
||||
// Notify listeners immediately to update UI before async calculation starts
|
||||
notifyListeners();
|
||||
|
||||
_calculateRoute();
|
||||
}
|
||||
|
||||
@@ -209,11 +250,11 @@ class NavigationState extends ChangeNotifier {
|
||||
_calculateRoute();
|
||||
}
|
||||
|
||||
/// Calculate route using OSRM
|
||||
/// Calculate route using alprwatch
|
||||
void _calculateRoute() {
|
||||
if (_routeStart == null || _routeEnd == null) return;
|
||||
|
||||
debugPrint('[NavigationState] Calculating route with OSRM...');
|
||||
|
||||
debugPrint('[NavigationState] Calculating route with alprwatch...');
|
||||
_isCalculating = true;
|
||||
_routingError = null;
|
||||
notifyListeners();
|
||||
@@ -221,7 +262,6 @@ class NavigationState extends ChangeNotifier {
|
||||
_routingService.calculateRoute(
|
||||
start: _routeStart!,
|
||||
end: _routeEnd!,
|
||||
profile: 'driving', // Could make this configurable later
|
||||
).then((routeResult) {
|
||||
if (!_isCalculating) return; // Canceled while calculating
|
||||
|
||||
@@ -231,7 +271,7 @@ class NavigationState extends ChangeNotifier {
|
||||
_showingOverview = true;
|
||||
_provisionalPinLocation = null; // Hide provisional pin
|
||||
|
||||
debugPrint('[NavigationState] OSRM route calculated: ${routeResult.toString()}');
|
||||
debugPrint('[NavigationState] alprwatch route calculated: ${routeResult.toString()}');
|
||||
notifyListeners();
|
||||
|
||||
}).catchError((error) {
|
||||
@@ -300,21 +340,21 @@ class NavigationState extends ChangeNotifier {
|
||||
_clearSearchResults();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (query.trim() == _lastQuery.trim()) return;
|
||||
|
||||
|
||||
_setSearchLoading(true);
|
||||
_lastQuery = query.trim();
|
||||
|
||||
|
||||
try {
|
||||
final results = await _searchService.search(query.trim());
|
||||
final results = await _searchService.search(query.trim(), viewbox: _searchViewbox);
|
||||
_searchResults = results;
|
||||
debugPrint('[NavigationState] Found ${results.length} results');
|
||||
} catch (e) {
|
||||
debugPrint('[NavigationState] Search failed: $e');
|
||||
_searchResults = [];
|
||||
}
|
||||
|
||||
|
||||
_setSearchLoading(false);
|
||||
}
|
||||
|
||||
@@ -336,4 +376,10 @@ class NavigationState extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_routingService.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,13 @@ class ProfileState extends ChangeNotifier {
|
||||
|
||||
final List<NodeProfile> _profiles = [];
|
||||
final Set<NodeProfile> _enabled = {};
|
||||
|
||||
// Callback for when a profile is deleted (used to clear stale sessions)
|
||||
void Function(NodeProfile)? _onProfileDeleted;
|
||||
|
||||
void setProfileDeletedCallback(void Function(NodeProfile) callback) {
|
||||
_onProfileDeleted = callback;
|
||||
}
|
||||
|
||||
// Getters
|
||||
List<NodeProfile> get profiles => List.unmodifiable(_profiles);
|
||||
@@ -78,6 +85,10 @@ class ProfileState extends ChangeNotifier {
|
||||
}
|
||||
_saveEnabledProfiles();
|
||||
ProfileService().save(_profiles);
|
||||
|
||||
// Notify about profile deletion so other parts can clean up
|
||||
_onProfileDeleted?.call(p);
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/search_result.dart';
|
||||
import '../services/search_service.dart';
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../models/pending_upload.dart'; // For UploadOperation enum
|
||||
|
||||
// ------------------ AddNodeSession ------------------
|
||||
class AddNodeSession {
|
||||
@@ -12,47 +13,81 @@ class AddNodeSession {
|
||||
LatLng? target;
|
||||
List<double> directions; // All directions [90, 180, 270]
|
||||
int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°)
|
||||
Map<String, String> refinedTags; // User-selected values for empty profile tags
|
||||
Map<String, String> additionalExistingTags; // For consistency (always empty for new nodes)
|
||||
String changesetComment; // User-editable changeset comment
|
||||
|
||||
AddNodeSession({
|
||||
this.profile,
|
||||
double initialDirection = 0,
|
||||
this.operatorProfile,
|
||||
this.target,
|
||||
Map<String, String>? refinedTags,
|
||||
Map<String, String>? additionalExistingTags,
|
||||
String? changesetComment,
|
||||
}) : directions = [initialDirection],
|
||||
currentDirectionIndex = 0;
|
||||
currentDirectionIndex = 0,
|
||||
refinedTags = refinedTags ?? {},
|
||||
additionalExistingTags = additionalExistingTags ?? {}, // Always empty for new nodes
|
||||
changesetComment = changesetComment ?? '';
|
||||
|
||||
// Slider always shows the current direction being edited
|
||||
double get directionDegrees => directions[currentDirectionIndex];
|
||||
set directionDegrees(double value) => directions[currentDirectionIndex] = value;
|
||||
double get directionDegrees => directions.isNotEmpty && currentDirectionIndex >= 0
|
||||
? directions[currentDirectionIndex]
|
||||
: 0.0;
|
||||
set directionDegrees(double value) {
|
||||
if (directions.isNotEmpty && currentDirectionIndex >= 0) {
|
||||
directions[currentDirectionIndex] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------ EditNodeSession ------------------
|
||||
class EditNodeSession {
|
||||
final OsmNode originalNode; // The original node being edited
|
||||
final bool originalHadDirections; // Whether original node had any directions
|
||||
NodeProfile? profile;
|
||||
OperatorProfile? operatorProfile;
|
||||
LatLng target; // Current position (can be dragged)
|
||||
List<double> directions; // All directions [90, 180, 270]
|
||||
int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°)
|
||||
bool extractFromWay; // True if user wants to extract this constrained node
|
||||
Map<String, String> refinedTags; // User-selected values for empty profile tags
|
||||
Map<String, String> additionalExistingTags; // Tags that exist on node but not in profile
|
||||
String changesetComment; // User-editable changeset comment
|
||||
|
||||
EditNodeSession({
|
||||
required this.originalNode,
|
||||
required this.originalHadDirections,
|
||||
this.profile,
|
||||
this.operatorProfile,
|
||||
required double initialDirection,
|
||||
required this.target,
|
||||
this.extractFromWay = false,
|
||||
Map<String, String>? refinedTags,
|
||||
Map<String, String>? additionalExistingTags,
|
||||
String? changesetComment,
|
||||
}) : directions = [initialDirection],
|
||||
currentDirectionIndex = 0;
|
||||
currentDirectionIndex = 0,
|
||||
refinedTags = refinedTags ?? {},
|
||||
additionalExistingTags = additionalExistingTags ?? {},
|
||||
changesetComment = changesetComment ?? '';
|
||||
|
||||
// Slider always shows the current direction being edited
|
||||
double get directionDegrees => directions[currentDirectionIndex];
|
||||
set directionDegrees(double value) => directions[currentDirectionIndex] = value;
|
||||
double get directionDegrees => directions.isNotEmpty && currentDirectionIndex >= 0
|
||||
? directions[currentDirectionIndex]
|
||||
: 0.0;
|
||||
set directionDegrees(double value) {
|
||||
if (directions.isNotEmpty && currentDirectionIndex >= 0) {
|
||||
directions[currentDirectionIndex] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SessionState extends ChangeNotifier {
|
||||
AddNodeSession? _session;
|
||||
EditNodeSession? _editSession;
|
||||
OperatorProfile? _detectedOperatorProfile; // Persists across profile changes
|
||||
|
||||
// Getters
|
||||
AddNodeSession? get session => _session;
|
||||
@@ -60,51 +95,116 @@ class SessionState extends ChangeNotifier {
|
||||
|
||||
void startAddSession(List<NodeProfile> enabledProfiles) {
|
||||
// Start with no profile selected - force user to choose
|
||||
_session = AddNodeSession();
|
||||
_session = AddNodeSession(
|
||||
changesetComment: 'Add surveillance node', // Default comment, will be updated when profile is selected
|
||||
);
|
||||
_editSession = null; // Clear any edit session
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void startEditSession(OsmNode node, List<NodeProfile> enabledProfiles) {
|
||||
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
void startEditSession(OsmNode node, List<NodeProfile> enabledProfiles, List<OperatorProfile> operatorProfiles) {
|
||||
// Always create and pre-select the temporary "existing tags" profile (now empty)
|
||||
final existingTagsProfile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
// Try to find a matching profile based on the node's tags
|
||||
NodeProfile? matchingProfile;
|
||||
// Detect and store operator profile (persists across profile changes)
|
||||
_detectedOperatorProfile = OperatorProfile.createExistingOperatorProfile(node, operatorProfiles);
|
||||
|
||||
// Attempt to find a match by comparing tags
|
||||
for (final profile in submittableProfiles) {
|
||||
if (_profileMatchesTags(profile, node.tags)) {
|
||||
matchingProfile = profile;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Initialize edit session with all existing directions, or empty list if none
|
||||
final existingDirections = node.directionDeg.isNotEmpty ? node.directionDeg : <double>[];
|
||||
final initialDirection = existingDirections.isNotEmpty ? existingDirections.first : 0.0;
|
||||
final originalHadDirections = existingDirections.isNotEmpty;
|
||||
|
||||
// Start with no profile selected if no match found - force user to choose
|
||||
// Initialize edit session with all existing directions
|
||||
final existingDirections = node.directionDeg.isNotEmpty ? node.directionDeg : [0.0];
|
||||
// Since the "existing tags" profile is now empty, all existing node tags
|
||||
// (minus special ones) should go into additionalExistingTags
|
||||
final initialAdditionalTags = _calculateAdditionalExistingTags(existingTagsProfile, node);
|
||||
|
||||
// Auto-populate refined tags (empty profile means no refined tags initially)
|
||||
final initialRefinedTags = _calculateRefinedTags(existingTagsProfile, node);
|
||||
|
||||
_editSession = EditNodeSession(
|
||||
originalNode: node,
|
||||
profile: matchingProfile,
|
||||
initialDirection: existingDirections.first,
|
||||
originalHadDirections: originalHadDirections,
|
||||
profile: existingTagsProfile,
|
||||
operatorProfile: _detectedOperatorProfile,
|
||||
initialDirection: initialDirection,
|
||||
target: node.coord,
|
||||
additionalExistingTags: initialAdditionalTags,
|
||||
refinedTags: initialRefinedTags,
|
||||
changesetComment: 'Update a surveillance node', // Default comment for existing tags profile
|
||||
);
|
||||
|
||||
// Replace the default single direction with all existing directions
|
||||
// Replace the default single direction with all existing directions (or empty list)
|
||||
_editSession!.directions = List<double>.from(existingDirections);
|
||||
_editSession!.currentDirectionIndex = 0; // Start editing the first direction
|
||||
_editSession!.currentDirectionIndex = existingDirections.isNotEmpty ? 0 : -1; // -1 indicates no directions
|
||||
_session = null; // Clear any add session
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool _profileMatchesTags(NodeProfile profile, Map<String, String> tags) {
|
||||
// Simple matching: check if all profile tags are present in node tags
|
||||
/// Calculate additional existing tags for a given profile change
|
||||
Map<String, String> _calculateAdditionalExistingTags(NodeProfile? newProfile, OsmNode originalNode) {
|
||||
final additionalTags = <String, String>{};
|
||||
|
||||
// Skip if no profile
|
||||
if (newProfile == null) {
|
||||
return additionalTags;
|
||||
}
|
||||
|
||||
// Get tags from the original node that are not in the selected profile
|
||||
final profileTagKeys = newProfile.tags.keys.toSet();
|
||||
final originalTags = originalNode.tags;
|
||||
|
||||
for (final entry in originalTags.entries) {
|
||||
final key = entry.key;
|
||||
final value = entry.value;
|
||||
|
||||
// Skip tags that are handled elsewhere
|
||||
if (_shouldSkipTag(key)) continue;
|
||||
|
||||
// Skip tags that exist in the selected profile
|
||||
if (profileTagKeys.contains(key)) continue;
|
||||
|
||||
// Include this tag as an additional existing tag
|
||||
additionalTags[key] = value;
|
||||
}
|
||||
|
||||
return additionalTags;
|
||||
}
|
||||
|
||||
/// Auto-populate refined tags with existing values from the original node
|
||||
Map<String, String> _calculateRefinedTags(NodeProfile? profile, OsmNode originalNode) {
|
||||
final refinedTags = <String, String>{};
|
||||
|
||||
if (profile == null) return refinedTags;
|
||||
|
||||
// For each empty-value tag in the profile, check if original node has a value
|
||||
for (final entry in profile.tags.entries) {
|
||||
if (tags[entry.key] != entry.value) {
|
||||
return false;
|
||||
final tagKey = entry.key;
|
||||
final profileValue = entry.value;
|
||||
|
||||
// Only auto-populate if profile tag value is empty
|
||||
if (profileValue.trim().isEmpty) {
|
||||
final existingValue = originalNode.tags[tagKey];
|
||||
if (existingValue != null && existingValue.trim().isNotEmpty) {
|
||||
refinedTags[tagKey] = existingValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
return refinedTags;
|
||||
}
|
||||
|
||||
/// Check if a tag should be skipped from additional existing tags
|
||||
bool _shouldSkipTag(String key) {
|
||||
// Skip direction tags (handled separately)
|
||||
if (key == 'direction' || key == 'camera:direction') return true;
|
||||
|
||||
// Skip operator tags (handled by operator profile)
|
||||
if (key == 'operator' || key.startsWith('operator:')) return true;
|
||||
|
||||
// Skip internal cache tags
|
||||
if (key.startsWith('_')) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void updateSession({
|
||||
@@ -112,6 +212,10 @@ class SessionState extends ChangeNotifier {
|
||||
NodeProfile? profile,
|
||||
OperatorProfile? operatorProfile,
|
||||
LatLng? target,
|
||||
Map<String, String>? refinedTags,
|
||||
Map<String, String>? additionalExistingTags,
|
||||
String? changesetComment,
|
||||
bool updateOperatorProfile = false,
|
||||
}) {
|
||||
if (_session == null) return;
|
||||
|
||||
@@ -122,9 +226,15 @@ class SessionState extends ChangeNotifier {
|
||||
}
|
||||
if (profile != null && profile != _session!.profile) {
|
||||
_session!.profile = profile;
|
||||
// Regenerate changeset comment when profile changes
|
||||
_session!.changesetComment = _generateDefaultChangesetComment(
|
||||
profile: profile,
|
||||
operation: UploadOperation.create,
|
||||
);
|
||||
dirty = true;
|
||||
}
|
||||
if (operatorProfile != _session!.operatorProfile) {
|
||||
// Only update operator profile when explicitly requested
|
||||
if (updateOperatorProfile && operatorProfile != _session!.operatorProfile) {
|
||||
_session!.operatorProfile = operatorProfile;
|
||||
dirty = true;
|
||||
}
|
||||
@@ -132,6 +242,18 @@ class SessionState extends ChangeNotifier {
|
||||
_session!.target = target;
|
||||
dirty = true;
|
||||
}
|
||||
if (refinedTags != null) {
|
||||
_session!.refinedTags = Map<String, String>.from(refinedTags);
|
||||
dirty = true;
|
||||
}
|
||||
if (additionalExistingTags != null) {
|
||||
_session!.additionalExistingTags = Map<String, String>.from(additionalExistingTags);
|
||||
dirty = true;
|
||||
}
|
||||
if (changesetComment != null) {
|
||||
_session!.changesetComment = changesetComment;
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) notifyListeners();
|
||||
}
|
||||
|
||||
@@ -141,6 +263,10 @@ class SessionState extends ChangeNotifier {
|
||||
OperatorProfile? operatorProfile,
|
||||
LatLng? target,
|
||||
bool? extractFromWay,
|
||||
Map<String, String>? refinedTags,
|
||||
Map<String, String>? additionalExistingTags,
|
||||
String? changesetComment,
|
||||
bool updateOperatorProfile = false,
|
||||
}) {
|
||||
if (_editSession == null) return;
|
||||
|
||||
@@ -153,11 +279,42 @@ class SessionState extends ChangeNotifier {
|
||||
dirty = true;
|
||||
}
|
||||
if (profile != null && profile != _editSession!.profile) {
|
||||
final oldProfile = _editSession!.profile;
|
||||
_editSession!.profile = profile;
|
||||
|
||||
// Handle direction requirements when profile changes
|
||||
_handleDirectionRequirementsOnProfileChange(oldProfile, profile);
|
||||
|
||||
// When profile changes and operator profile not being explicitly updated,
|
||||
// restore the detected operator profile (if any)
|
||||
if (!updateOperatorProfile && _detectedOperatorProfile != null) {
|
||||
_editSession!.operatorProfile = _detectedOperatorProfile;
|
||||
}
|
||||
|
||||
// Calculate additional existing tags for non-existing-tags profiles
|
||||
// Only do this if additionalExistingTags wasn't explicitly provided
|
||||
if (additionalExistingTags == null) {
|
||||
_editSession!.additionalExistingTags = _calculateAdditionalExistingTags(profile, _editSession!.originalNode);
|
||||
}
|
||||
|
||||
// Auto-populate refined tags with existing values for empty profile tags
|
||||
// Only do this if refinedTags wasn't explicitly provided
|
||||
if (refinedTags == null) {
|
||||
_editSession!.refinedTags = _calculateRefinedTags(profile, _editSession!.originalNode);
|
||||
}
|
||||
|
||||
// Regenerate changeset comment when profile changes
|
||||
final operation = _editSession!.extractFromWay ? UploadOperation.extract : UploadOperation.modify;
|
||||
_editSession!.changesetComment = _generateDefaultChangesetComment(
|
||||
profile: profile,
|
||||
operation: operation,
|
||||
);
|
||||
|
||||
dirty = true;
|
||||
}
|
||||
if (operatorProfile != _editSession!.operatorProfile) {
|
||||
_editSession!.operatorProfile = operatorProfile;
|
||||
// Only update operator profile when explicitly requested
|
||||
if (updateOperatorProfile && operatorProfile != _editSession!.operatorProfile) {
|
||||
_editSession!.operatorProfile = operatorProfile; // This can be null
|
||||
dirty = true;
|
||||
}
|
||||
if (target != null && target != _editSession!.target) {
|
||||
@@ -174,6 +331,18 @@ class SessionState extends ChangeNotifier {
|
||||
}
|
||||
dirty = true;
|
||||
}
|
||||
if (refinedTags != null) {
|
||||
_editSession!.refinedTags = Map<String, String>.from(refinedTags);
|
||||
dirty = true;
|
||||
}
|
||||
if (additionalExistingTags != null) {
|
||||
_editSession!.additionalExistingTags = Map<String, String>.from(additionalExistingTags);
|
||||
dirty = true;
|
||||
}
|
||||
if (changesetComment != null) {
|
||||
_editSession!.changesetComment = changesetComment;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
if (dirty) notifyListeners();
|
||||
|
||||
@@ -206,18 +375,28 @@ class SessionState extends ChangeNotifier {
|
||||
|
||||
// Remove currently selected direction
|
||||
void removeDirection() {
|
||||
if (_session != null && _session!.directions.length > 1) {
|
||||
_session!.directions.removeAt(_session!.currentDirectionIndex);
|
||||
if (_session!.currentDirectionIndex >= _session!.directions.length) {
|
||||
_session!.currentDirectionIndex = _session!.directions.length - 1;
|
||||
if (_session != null && _session!.directions.isNotEmpty) {
|
||||
// For add sessions, keep minimum of 1 direction
|
||||
if (_session!.directions.length > 1) {
|
||||
_session!.directions.removeAt(_session!.currentDirectionIndex);
|
||||
if (_session!.currentDirectionIndex >= _session!.directions.length) {
|
||||
_session!.currentDirectionIndex = _session!.directions.length - 1;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
notifyListeners();
|
||||
} else if (_editSession != null && _editSession!.directions.length > 1) {
|
||||
_editSession!.directions.removeAt(_editSession!.currentDirectionIndex);
|
||||
if (_editSession!.currentDirectionIndex >= _editSession!.directions.length) {
|
||||
_editSession!.currentDirectionIndex = _editSession!.directions.length - 1;
|
||||
} else if (_editSession != null && _editSession!.directions.isNotEmpty) {
|
||||
// For edit sessions, use minimum calculation
|
||||
final minDirections = _getMinimumDirections();
|
||||
|
||||
if (_editSession!.directions.length > minDirections) {
|
||||
_editSession!.directions.removeAt(_editSession!.currentDirectionIndex);
|
||||
if (_editSession!.directions.isEmpty) {
|
||||
_editSession!.currentDirectionIndex = -1; // No directions
|
||||
} else if (_editSession!.currentDirectionIndex >= _editSession!.directions.length) {
|
||||
_editSession!.currentDirectionIndex = _editSession!.directions.length - 1;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +405,7 @@ class SessionState extends ChangeNotifier {
|
||||
if (_session != null && _session!.directions.length > 1) {
|
||||
_session!.currentDirectionIndex = (_session!.currentDirectionIndex + 1) % _session!.directions.length;
|
||||
notifyListeners();
|
||||
} else if (_editSession != null && _editSession!.directions.length > 1) {
|
||||
} else if (_editSession != null && _editSession!.directions.length > 1 && _editSession!.currentDirectionIndex >= 0) {
|
||||
_editSession!.currentDirectionIndex = (_editSession!.currentDirectionIndex + 1) % _editSession!.directions.length;
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -241,6 +420,7 @@ class SessionState extends ChangeNotifier {
|
||||
|
||||
void cancelEditSession() {
|
||||
_editSession = null;
|
||||
_detectedOperatorProfile = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -258,7 +438,59 @@ class SessionState extends ChangeNotifier {
|
||||
|
||||
final session = _editSession!;
|
||||
_editSession = null;
|
||||
_detectedOperatorProfile = null;
|
||||
notifyListeners();
|
||||
return session;
|
||||
}
|
||||
|
||||
/// Get the minimum number of directions required for current session state
|
||||
int _getMinimumDirections() {
|
||||
if (_editSession == null) return 1;
|
||||
|
||||
// Minimum = 0 only if original node had no directions
|
||||
// Allow preserving the original state (directionless nodes can stay directionless)
|
||||
return _editSession!.originalHadDirections ? 1 : 0;
|
||||
}
|
||||
|
||||
/// Check if remove direction button should be enabled for edit session
|
||||
bool get canRemoveDirection {
|
||||
if (_editSession == null || _editSession!.directions.isEmpty) return false;
|
||||
return _editSession!.directions.length > _getMinimumDirections();
|
||||
}
|
||||
|
||||
/// Handle direction requirements when profile changes in edit session
|
||||
void _handleDirectionRequirementsOnProfileChange(NodeProfile? oldProfile, NodeProfile newProfile) {
|
||||
if (_editSession == null) return;
|
||||
|
||||
final minimum = _getMinimumDirections();
|
||||
|
||||
// Ensure we meet the minimum (add direction if needed)
|
||||
if (_editSession!.directions.length < minimum) {
|
||||
_editSession!.directions = [0.0];
|
||||
_editSession!.currentDirectionIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a default changeset comment for a submission
|
||||
/// Handles special case of `<Existing tags>` profile by using "a" instead
|
||||
String _generateDefaultChangesetComment({
|
||||
required NodeProfile? profile,
|
||||
required UploadOperation operation,
|
||||
}) {
|
||||
// Handle temp profiles with brackets by using "a"
|
||||
final profileName = profile?.name.startsWith('<') == true && profile?.name.endsWith('>') == true
|
||||
? 'a'
|
||||
: profile?.name ?? 'surveillance';
|
||||
|
||||
switch (operation) {
|
||||
case UploadOperation.create:
|
||||
return 'Add $profileName surveillance node';
|
||||
case UploadOperation.modify:
|
||||
return 'Update $profileName surveillance node';
|
||||
case UploadOperation.delete:
|
||||
return 'Delete $profileName surveillance node';
|
||||
case UploadOperation.extract:
|
||||
return 'Extract $profileName surveillance node';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,15 @@ enum FollowMeMode {
|
||||
rotating, // Follow position and rotation based on heading
|
||||
}
|
||||
|
||||
// Enum for distance units
|
||||
enum DistanceUnit {
|
||||
metric, // kilometers, meters
|
||||
imperial, // miles, feet
|
||||
}
|
||||
|
||||
class SettingsState extends ChangeNotifier {
|
||||
static const String _offlineModePrefsKey = 'offline_mode';
|
||||
static const String _maxCamerasPrefsKey = 'max_cameras';
|
||||
static const String _maxNodesPrefsKey = 'max_nodes';
|
||||
static const String _uploadModePrefsKey = 'upload_mode';
|
||||
static const String _tileProvidersPrefsKey = 'tile_providers';
|
||||
static const String _selectedTileTypePrefsKey = 'selected_tile_type';
|
||||
@@ -29,10 +35,12 @@ class SettingsState extends ChangeNotifier {
|
||||
static const String _networkStatusIndicatorEnabledPrefsKey = 'network_status_indicator_enabled';
|
||||
static const String _suspectedLocationMinDistancePrefsKey = 'suspected_location_min_distance';
|
||||
static const String _pauseQueueProcessingPrefsKey = 'pause_queue_processing';
|
||||
static const String _navigationAvoidanceDistancePrefsKey = 'navigation_avoidance_distance';
|
||||
static const String _distanceUnitPrefsKey = 'distance_unit';
|
||||
|
||||
bool _offlineMode = false;
|
||||
bool _pauseQueueProcessing = false;
|
||||
int _maxCameras = 250;
|
||||
int _maxNodes = kDefaultMaxNodes;
|
||||
UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production;
|
||||
FollowMeMode _followMeMode = FollowMeMode.follow;
|
||||
bool _proximityAlertsEnabled = false;
|
||||
@@ -41,11 +49,13 @@ class SettingsState extends ChangeNotifier {
|
||||
int _suspectedLocationMinDistance = 100; // meters
|
||||
List<TileProvider> _tileProviders = [];
|
||||
String _selectedTileTypeId = '';
|
||||
int _navigationAvoidanceDistance = 250; // meters
|
||||
DistanceUnit _distanceUnit = DistanceUnit.metric;
|
||||
|
||||
// Getters
|
||||
bool get offlineMode => _offlineMode;
|
||||
bool get pauseQueueProcessing => _pauseQueueProcessing;
|
||||
int get maxCameras => _maxCameras;
|
||||
int get maxNodes => _maxNodes;
|
||||
UploadMode get uploadMode => _uploadMode;
|
||||
FollowMeMode get followMeMode => _followMeMode;
|
||||
bool get proximityAlertsEnabled => _proximityAlertsEnabled;
|
||||
@@ -54,6 +64,8 @@ class SettingsState extends ChangeNotifier {
|
||||
int get suspectedLocationMinDistance => _suspectedLocationMinDistance;
|
||||
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
|
||||
String get selectedTileTypeId => _selectedTileTypeId;
|
||||
int get navigationAvoidanceDistance => _navigationAvoidanceDistance;
|
||||
DistanceUnit get distanceUnit => _distanceUnit;
|
||||
|
||||
/// Get the currently selected tile type
|
||||
TileType? get selectedTileType {
|
||||
@@ -98,9 +110,20 @@ class SettingsState extends ChangeNotifier {
|
||||
// Load queue processing setting
|
||||
_pauseQueueProcessing = prefs.getBool(_pauseQueueProcessingPrefsKey) ?? false;
|
||||
|
||||
// Load max cameras
|
||||
if (prefs.containsKey(_maxCamerasPrefsKey)) {
|
||||
_maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250;
|
||||
// Load max nodes
|
||||
_maxNodes = prefs.getInt(_maxNodesPrefsKey) ?? kDefaultMaxNodes;
|
||||
|
||||
// Load navigation avoidance distance
|
||||
if (prefs.containsKey(_navigationAvoidanceDistancePrefsKey)) {
|
||||
_navigationAvoidanceDistance = prefs.getInt(_navigationAvoidanceDistancePrefsKey) ?? 250;
|
||||
}
|
||||
|
||||
// Load distance unit
|
||||
if (prefs.containsKey(_distanceUnitPrefsKey)) {
|
||||
final unitIndex = prefs.getInt(_distanceUnitPrefsKey) ?? 0;
|
||||
if (unitIndex >= 0 && unitIndex < DistanceUnit.values.length) {
|
||||
_distanceUnit = DistanceUnit.values[unitIndex];
|
||||
}
|
||||
}
|
||||
|
||||
// Load proximity alerts settings
|
||||
@@ -225,11 +248,11 @@ class SettingsState extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
set maxCameras(int n) {
|
||||
set maxNodes(int n) {
|
||||
if (n < 10) n = 10; // minimum
|
||||
_maxCameras = n;
|
||||
_maxNodes = n;
|
||||
SharedPreferences.getInstance().then((prefs) {
|
||||
prefs.setInt(_maxCamerasPrefsKey, n);
|
||||
prefs.setInt(_maxNodesPrefsKey, n);
|
||||
});
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -353,4 +376,24 @@ class SettingsState extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
// Set distance for avoidance of nodes during navigation
|
||||
Future<void> setNavigationAvoidanceDistance(int distance) async {
|
||||
if (_navigationAvoidanceDistance != distance) {
|
||||
_navigationAvoidanceDistance = distance;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_navigationAvoidanceDistancePrefsKey, distance);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set distance unit (metric or imperial)
|
||||
Future<void> setDistanceUnit(DistanceUnit unit) async {
|
||||
if (_distanceUnit != unit) {
|
||||
_distanceUnit = unit;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_distanceUnitPrefsKey, unit.index);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,18 +8,34 @@ class SuspectedLocationState extends ChangeNotifier {
|
||||
|
||||
SuspectedLocation? _selectedLocation;
|
||||
bool _isLoading = false;
|
||||
double? _downloadProgress; // 0.0 to 1.0, null when not downloading
|
||||
|
||||
/// Currently selected suspected location (for detail view)
|
||||
SuspectedLocation? get selectedLocation => _selectedLocation;
|
||||
|
||||
/// Get suspected locations in bounds (this should be called by the map view)
|
||||
List<SuspectedLocation> getLocationsInBounds({
|
||||
/// Get suspected locations in bounds (async)
|
||||
Future<List<SuspectedLocation>> getLocationsInBounds({
|
||||
required double north,
|
||||
required double south,
|
||||
required double east,
|
||||
required double west,
|
||||
}) async {
|
||||
return await _service.getLocationsInBounds(
|
||||
north: north,
|
||||
south: south,
|
||||
east: east,
|
||||
west: west,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get suspected locations in bounds (sync, for UI)
|
||||
List<SuspectedLocation> getLocationsInBoundsSync({
|
||||
required double north,
|
||||
required double south,
|
||||
required double east,
|
||||
required double west,
|
||||
}) {
|
||||
return _service.getLocationsInBounds(
|
||||
return _service.getLocationsInBoundsSync(
|
||||
north: north,
|
||||
south: south,
|
||||
east: east,
|
||||
@@ -32,9 +48,12 @@ class SuspectedLocationState extends ChangeNotifier {
|
||||
|
||||
/// Whether currently loading data
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
/// Download progress (0.0 to 1.0), null when not downloading
|
||||
double? get downloadProgress => _downloadProgress;
|
||||
|
||||
/// Last time data was fetched
|
||||
DateTime? get lastFetchTime => _service.lastFetchTime;
|
||||
Future<DateTime?> get lastFetchTime => _service.lastFetchTime;
|
||||
|
||||
/// Initialize the state
|
||||
Future<void> init({bool offlineMode = false}) async {
|
||||
@@ -47,7 +66,7 @@ class SuspectedLocationState extends ChangeNotifier {
|
||||
await _service.setEnabled(enabled);
|
||||
|
||||
// If enabling and no data exists, fetch it now
|
||||
if (enabled && !_service.hasData) {
|
||||
if (enabled && !(await _service.hasData)) {
|
||||
await _fetchData();
|
||||
}
|
||||
|
||||
@@ -57,13 +76,15 @@ class SuspectedLocationState extends ChangeNotifier {
|
||||
/// Manually refresh the data (force refresh)
|
||||
Future<bool> refreshData() async {
|
||||
_isLoading = true;
|
||||
_downloadProgress = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final success = await _service.forceRefresh();
|
||||
final success = await _service.forceRefresh(onProgress: _updateDownloadProgress);
|
||||
return success;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
_downloadProgress = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -71,16 +92,24 @@ class SuspectedLocationState extends ChangeNotifier {
|
||||
/// Internal method to fetch data if needed with loading state management
|
||||
Future<bool> _fetchData() async {
|
||||
_isLoading = true;
|
||||
_downloadProgress = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final success = await _service.fetchDataIfNeeded();
|
||||
final success = await _service.fetchDataIfNeeded(onProgress: _updateDownloadProgress);
|
||||
return success;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
_downloadProgress = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Update download progress
|
||||
void _updateDownloadProgress(double progress) {
|
||||
_downloadProgress = progress;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Select a suspected location for detail view
|
||||
void selectLocation(SuspectedLocation location) {
|
||||
|
||||
@@ -7,32 +7,129 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../models/pending_upload.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../services/node_cache.dart';
|
||||
import '../services/map_data_provider.dart';
|
||||
import '../services/uploader.dart';
|
||||
import '../widgets/camera_provider_with_cache.dart';
|
||||
import '../widgets/node_provider_with_cache.dart';
|
||||
import '../dev_config.dart';
|
||||
import 'settings_state.dart';
|
||||
import 'session_state.dart';
|
||||
|
||||
class UploadQueueState extends ChangeNotifier {
|
||||
/// Helper to access the map data provider instance
|
||||
MapDataProvider get _nodeCache => MapDataProvider();
|
||||
final List<PendingUpload> _queue = [];
|
||||
Timer? _uploadTimer;
|
||||
int _activeUploadCount = 0;
|
||||
|
||||
// Getters
|
||||
int get pendingCount => _queue.length;
|
||||
List<PendingUpload> get pendingUploads => List.unmodifiable(_queue);
|
||||
int get activeUploadCount => _activeUploadCount;
|
||||
|
||||
// Initialize by loading queue from storage
|
||||
// Initialize by loading queue from storage and repopulate cache with pending nodes
|
||||
Future<void> init() async {
|
||||
await _loadQueue();
|
||||
debugPrint('[UploadQueue] Loaded ${_queue.length} items from storage');
|
||||
_repopulateCacheFromQueue();
|
||||
}
|
||||
|
||||
// Repopulate the cache with pending nodes from the queue on startup
|
||||
void _repopulateCacheFromQueue() {
|
||||
debugPrint('[UploadQueue] Repopulating cache from ${_queue.length} queue items');
|
||||
final nodesToAdd = <OsmNode>[];
|
||||
|
||||
for (final upload in _queue) {
|
||||
// Skip completed uploads - they should already be in OSM and will be fetched normally
|
||||
if (upload.isComplete) {
|
||||
debugPrint('[UploadQueue] Skipping completed upload at ${upload.coord}');
|
||||
continue;
|
||||
}
|
||||
|
||||
debugPrint('[UploadQueue] Processing ${upload.operation} upload at ${upload.coord}');
|
||||
|
||||
if (upload.isDeletion) {
|
||||
// For deletions: mark the original node as pending deletion if it exists in cache
|
||||
if (upload.originalNodeId != null) {
|
||||
final existingNode = _nodeCache.getNodeById(upload.originalNodeId!);
|
||||
if (existingNode != null) {
|
||||
final deletionTags = Map<String, String>.from(existingNode.tags);
|
||||
deletionTags['_pending_deletion'] = 'true';
|
||||
|
||||
final nodeWithDeletionTag = OsmNode(
|
||||
id: upload.originalNodeId!,
|
||||
coord: existingNode.coord,
|
||||
tags: deletionTags,
|
||||
);
|
||||
nodesToAdd.add(nodeWithDeletionTag);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For creates, edits, and extracts: recreate temp node if needed
|
||||
// Generate new temp ID if not already stored (for backward compatibility)
|
||||
final tempId = upload.tempNodeId ?? -DateTime.now().millisecondsSinceEpoch - _queue.indexOf(upload);
|
||||
|
||||
final tags = upload.getCombinedTags();
|
||||
tags['_pending_upload'] = 'true';
|
||||
tags['_temp_id'] = tempId.toString();
|
||||
|
||||
// Store temp ID for future cleanup if not already set
|
||||
upload.tempNodeId ??= tempId;
|
||||
|
||||
if (upload.isEdit) {
|
||||
// For edits: also mark original with _pending_edit if it exists
|
||||
if (upload.originalNodeId != null) {
|
||||
final existingOriginal = _nodeCache.getNodeById(upload.originalNodeId!);
|
||||
if (existingOriginal != null) {
|
||||
final originalTags = Map<String, String>.from(existingOriginal.tags);
|
||||
originalTags['_pending_edit'] = 'true';
|
||||
|
||||
final originalWithEdit = OsmNode(
|
||||
id: upload.originalNodeId!,
|
||||
coord: existingOriginal.coord,
|
||||
tags: originalTags,
|
||||
);
|
||||
nodesToAdd.add(originalWithEdit);
|
||||
}
|
||||
}
|
||||
|
||||
// Add connection line marker
|
||||
tags['_original_node_id'] = upload.originalNodeId.toString();
|
||||
} else if (upload.operation == UploadOperation.extract) {
|
||||
// For extracts: add connection line marker
|
||||
tags['_original_node_id'] = upload.originalNodeId.toString();
|
||||
}
|
||||
|
||||
final tempNode = OsmNode(
|
||||
id: tempId,
|
||||
coord: upload.coord,
|
||||
tags: tags,
|
||||
);
|
||||
nodesToAdd.add(tempNode);
|
||||
}
|
||||
}
|
||||
|
||||
if (nodesToAdd.isNotEmpty) {
|
||||
_nodeCache.addOrUpdate(nodesToAdd);
|
||||
debugPrint('[UploadQueue] Repopulated cache with ${nodesToAdd.length} pending nodes from queue');
|
||||
|
||||
// Save queue if we updated any temp IDs for backward compatibility
|
||||
_saveQueue();
|
||||
|
||||
// Notify node provider to update the map
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Add a completed session to the upload queue
|
||||
void addFromSession(AddNodeSession session, {required UploadMode uploadMode}) {
|
||||
final upload = PendingUpload(
|
||||
coord: session.target!,
|
||||
direction: _formatDirectionsAsString(session.directions),
|
||||
direction: _formatDirectionsForSubmission(session.directions, session.profile),
|
||||
profile: session.profile!, // Safe to use ! because commitSession() checks for null
|
||||
operatorProfile: session.operatorProfile,
|
||||
refinedTags: session.refinedTags,
|
||||
additionalExistingTags: session.additionalExistingTags, // Always empty for new nodes
|
||||
changesetComment: session.changesetComment,
|
||||
uploadMode: uploadMode,
|
||||
operation: UploadOperation.create,
|
||||
);
|
||||
@@ -46,6 +143,10 @@ class UploadQueueState extends ChangeNotifier {
|
||||
final tempId = -DateTime.now().millisecondsSinceEpoch;
|
||||
final tags = upload.getCombinedTags();
|
||||
tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction
|
||||
tags['_temp_id'] = tempId.toString(); // Store temp ID for specific removal
|
||||
|
||||
// Store the temp ID in the upload for cleanup purposes
|
||||
upload.tempNodeId = tempId;
|
||||
|
||||
final tempNode = OsmNode(
|
||||
id: tempId,
|
||||
@@ -53,9 +154,9 @@ class UploadQueueState extends ChangeNotifier {
|
||||
tags: tags,
|
||||
);
|
||||
|
||||
NodeCache.instance.addOrUpdate([tempNode]);
|
||||
_nodeCache.addOrUpdate([tempNode]);
|
||||
// Notify node provider to update the map
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -82,9 +183,12 @@ class UploadQueueState extends ChangeNotifier {
|
||||
|
||||
final upload = PendingUpload(
|
||||
coord: coordToUse,
|
||||
direction: _formatDirectionsAsString(session.directions),
|
||||
direction: _formatDirectionsForSubmission(session.directions, session.profile),
|
||||
profile: session.profile!, // Safe to use ! because commitEditSession() checks for null
|
||||
operatorProfile: session.operatorProfile,
|
||||
refinedTags: session.refinedTags,
|
||||
additionalExistingTags: session.additionalExistingTags,
|
||||
changesetComment: session.changesetComment,
|
||||
uploadMode: uploadMode,
|
||||
operation: operation,
|
||||
originalNodeId: session.originalNode.id, // Track which node we're editing
|
||||
@@ -100,6 +204,10 @@ class UploadQueueState extends ChangeNotifier {
|
||||
final extractedTags = upload.getCombinedTags();
|
||||
extractedTags['_pending_upload'] = 'true'; // Mark as pending upload
|
||||
extractedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing
|
||||
extractedTags['_temp_id'] = tempId.toString(); // Store temp ID for specific removal
|
||||
|
||||
// Store the temp ID in the upload for cleanup purposes
|
||||
upload.tempNodeId = tempId;
|
||||
|
||||
final extractedNode = OsmNode(
|
||||
id: tempId,
|
||||
@@ -107,7 +215,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
tags: extractedTags,
|
||||
);
|
||||
|
||||
NodeCache.instance.addOrUpdate([extractedNode]);
|
||||
_nodeCache.addOrUpdate([extractedNode]);
|
||||
} else {
|
||||
// For modify: mark original with grey ring and create new temp node
|
||||
// 1. Mark the original node with _pending_edit (grey ring) at original location
|
||||
@@ -125,6 +233,10 @@ class UploadQueueState extends ChangeNotifier {
|
||||
final editedTags = upload.getCombinedTags();
|
||||
editedTags['_pending_upload'] = 'true'; // Mark as pending upload
|
||||
editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing
|
||||
editedTags['_temp_id'] = tempId.toString(); // Store temp ID for specific removal
|
||||
|
||||
// Store the temp ID in the upload for cleanup purposes
|
||||
upload.tempNodeId = tempId;
|
||||
|
||||
final editedNode = OsmNode(
|
||||
id: tempId,
|
||||
@@ -132,10 +244,10 @@ class UploadQueueState extends ChangeNotifier {
|
||||
tags: editedTags,
|
||||
);
|
||||
|
||||
NodeCache.instance.addOrUpdate([originalNode, editedNode]);
|
||||
_nodeCache.addOrUpdate([originalNode, editedNode]);
|
||||
}
|
||||
// Notify node provider to update the map
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -146,6 +258,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
coord: node.coord,
|
||||
direction: node.directionDeg.isNotEmpty ? node.directionDeg.first : 0, // Direction not used for deletions but required for API
|
||||
profile: null, // No profile needed for deletions - just delete by node ID
|
||||
changesetComment: 'Delete a surveillance node', // Default comment for deletions
|
||||
uploadMode: uploadMode,
|
||||
operation: UploadOperation.delete,
|
||||
originalNodeId: node.id,
|
||||
@@ -164,27 +277,41 @@ class UploadQueueState extends ChangeNotifier {
|
||||
tags: deletionTags,
|
||||
);
|
||||
|
||||
NodeCache.instance.addOrUpdate([nodeWithDeletionTag]);
|
||||
_nodeCache.addOrUpdate([nodeWithDeletionTag]);
|
||||
// Notify node provider to update the map
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearQueue() {
|
||||
// Clean up all pending nodes from cache before clearing queue
|
||||
for (final upload in _queue) {
|
||||
_cleanupPendingNodeFromCache(upload);
|
||||
}
|
||||
|
||||
_queue.clear();
|
||||
_saveQueue();
|
||||
|
||||
// Notify node provider to update the map
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void removeFromQueue(PendingUpload upload) {
|
||||
// Clean up pending node from cache before removing from queue
|
||||
_cleanupPendingNodeFromCache(upload);
|
||||
|
||||
_queue.remove(upload);
|
||||
_saveQueue();
|
||||
|
||||
// Notify node provider to update the map
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void retryUpload(PendingUpload upload) {
|
||||
upload.error = false;
|
||||
upload.clearError();
|
||||
upload.attempts = 0;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
@@ -202,50 +329,325 @@ class UploadQueueState extends ChangeNotifier {
|
||||
// No uploads if queue is empty, offline mode is enabled, or queue processing is paused
|
||||
if (_queue.isEmpty || offlineMode || pauseQueueProcessing) return;
|
||||
|
||||
_uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async {
|
||||
_uploadTimer = Timer.periodic(kUploadQueueProcessingInterval, (t) async {
|
||||
if (_queue.isEmpty || offlineMode || pauseQueueProcessing) {
|
||||
_uploadTimer?.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the first queue item that is NOT in error state and act on that
|
||||
final item = _queue.where((pu) => !pu.error).cast<PendingUpload?>().firstOrNull;
|
||||
if (item == null) return;
|
||||
// Check if we can start more uploads (concurrency limit check)
|
||||
if (_activeUploadCount >= kMaxConcurrentUploads) {
|
||||
debugPrint('[UploadQueue] At concurrency limit ($_activeUploadCount/$kMaxConcurrentUploads), waiting for uploads to complete');
|
||||
return;
|
||||
}
|
||||
|
||||
// Retrieve access after every tick (accounts for re-login)
|
||||
// Process any expired items
|
||||
final uploadingItems = _queue.where((pu) => pu.uploadState == UploadState.uploading).toList();
|
||||
final closingItems = _queue.where((pu) => pu.uploadState == UploadState.closingChangeset).toList();
|
||||
|
||||
for (final uploadingItem in uploadingItems) {
|
||||
if (uploadingItem.hasChangesetExpired) {
|
||||
debugPrint('[UploadQueue] Changeset expired during node submission - marking as failed');
|
||||
uploadingItem.setError('Could not submit node within 59 minutes - changeset expired');
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
for (final closingItem in closingItems) {
|
||||
if (closingItem.hasChangesetExpired) {
|
||||
debugPrint('[UploadQueue] Changeset expired during close - trusting OSM auto-close (node was submitted successfully)');
|
||||
_markAsCompleting(closingItem, submittedNodeId: closingItem.submittedNodeId!);
|
||||
}
|
||||
}
|
||||
|
||||
// Find next pending item to start
|
||||
final pendingItems = _queue.where((pu) => pu.uploadState == UploadState.pending).toList();
|
||||
|
||||
if (pendingItems.isEmpty) {
|
||||
// Check if queue is effectively empty
|
||||
final hasActiveItems = _queue.any((pu) =>
|
||||
pu.uploadState == UploadState.creatingChangeset ||
|
||||
pu.uploadState == UploadState.uploading ||
|
||||
pu.uploadState == UploadState.closingChangeset
|
||||
);
|
||||
|
||||
if (!hasActiveItems) {
|
||||
debugPrint('[UploadQueue] No active items remaining, stopping uploader');
|
||||
_uploadTimer?.cancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Retrieve access token
|
||||
final access = await getAccessToken();
|
||||
if (access == null) return; // not logged in
|
||||
|
||||
bool ok;
|
||||
debugPrint('[UploadQueue] Processing item with uploadMode: ${item.uploadMode}');
|
||||
if (item.uploadMode == UploadMode.simulate) {
|
||||
// Simulate successful upload without calling real API
|
||||
debugPrint('[UploadQueue] Simulating upload (no real API call)');
|
||||
await Future.delayed(const Duration(seconds: 1)); // Simulate network delay
|
||||
ok = true;
|
||||
// Simulate a node ID for simulate mode
|
||||
_markAsCompleting(item, simulatedNodeId: DateTime.now().millisecondsSinceEpoch);
|
||||
} else {
|
||||
// Real upload -- use the upload mode that was saved when this item was queued
|
||||
debugPrint('[UploadQueue] Real upload to: ${item.uploadMode}');
|
||||
final up = Uploader(access, (nodeId) {
|
||||
_markAsCompleting(item, submittedNodeId: nodeId);
|
||||
}, uploadMode: item.uploadMode);
|
||||
ok = await up.upload(item);
|
||||
}
|
||||
if (!ok) {
|
||||
item.attempts++;
|
||||
if (item.attempts >= 3) {
|
||||
// Mark as error and stop the uploader. User can manually retry.
|
||||
item.error = true;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadTimer?.cancel();
|
||||
} else {
|
||||
await Future.delayed(const Duration(seconds: 20));
|
||||
// Start processing the next pending upload
|
||||
final item = pendingItems.first;
|
||||
debugPrint('[UploadQueue] Starting new upload processing for item at ${item.coord} ($_activeUploadCount/$kMaxConcurrentUploads active)');
|
||||
|
||||
_activeUploadCount++;
|
||||
_processIndividualUpload(item, access);
|
||||
});
|
||||
}
|
||||
|
||||
// Process an individual upload through all three stages
|
||||
Future<void> _processIndividualUpload(PendingUpload item, String accessToken) async {
|
||||
try {
|
||||
debugPrint('[UploadQueue] Starting individual upload processing for ${item.operation.name} at ${item.coord}');
|
||||
|
||||
// Stage 1: Create changeset
|
||||
await _processCreateChangeset(item, accessToken);
|
||||
if (item.uploadState == UploadState.error) return;
|
||||
|
||||
// Stage 2: Node operation with retry logic
|
||||
bool nodeOperationCompleted = false;
|
||||
while (!nodeOperationCompleted && !item.hasChangesetExpired && item.uploadState != UploadState.error) {
|
||||
await _processNodeOperation(item, accessToken);
|
||||
|
||||
if (item.uploadState == UploadState.closingChangeset) {
|
||||
// Node operation succeeded
|
||||
nodeOperationCompleted = true;
|
||||
} else if (item.uploadState == UploadState.uploading && !item.isReadyForNodeSubmissionRetry) {
|
||||
// Need to wait before retry
|
||||
final delay = item.nextNodeSubmissionRetryDelay;
|
||||
debugPrint('[UploadQueue] Waiting ${delay.inSeconds}s before node submission retry');
|
||||
await Future.delayed(delay);
|
||||
} else if (item.uploadState == UploadState.error) {
|
||||
// Failed permanently
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!nodeOperationCompleted) return; // Failed or expired
|
||||
|
||||
// Stage 3: Close changeset with retry logic
|
||||
bool changesetClosed = false;
|
||||
while (!changesetClosed && !item.hasChangesetExpired && item.uploadState != UploadState.error) {
|
||||
await _processChangesetClose(item, accessToken);
|
||||
|
||||
if (item.uploadState == UploadState.complete) {
|
||||
// Changeset close succeeded
|
||||
changesetClosed = true;
|
||||
} else if (item.uploadState == UploadState.closingChangeset && !item.isReadyForChangesetCloseRetry) {
|
||||
// Need to wait before retry
|
||||
final delay = item.nextChangesetCloseRetryDelay;
|
||||
debugPrint('[UploadQueue] Waiting ${delay.inSeconds}s before changeset close retry');
|
||||
await Future.delayed(delay);
|
||||
} else if (item.uploadState == UploadState.error) {
|
||||
// Failed permanently
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changesetClosed && item.hasChangesetExpired) {
|
||||
// Trust OSM auto-close if we ran out of time
|
||||
debugPrint('[UploadQueue] Upload completed but changeset close timed out - trusting OSM auto-close');
|
||||
if (item.submittedNodeId != null) {
|
||||
_markAsCompleting(item, submittedNodeId: item.submittedNodeId!);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[UploadQueue] Unexpected error in individual upload processing: $e');
|
||||
item.setError('Unexpected error: $e');
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
} finally {
|
||||
// Always decrement the active upload count
|
||||
_activeUploadCount--;
|
||||
debugPrint('[UploadQueue] Individual upload processing finished ($_activeUploadCount/$kMaxConcurrentUploads active)');
|
||||
}
|
||||
}
|
||||
|
||||
// Process changeset creation (step 1 of 3)
|
||||
Future<void> _processCreateChangeset(PendingUpload item, String access) async {
|
||||
item.markAsCreatingChangeset();
|
||||
_saveQueue();
|
||||
notifyListeners(); // Show "Creating changeset..." immediately
|
||||
|
||||
if (item.uploadMode == UploadMode.simulate) {
|
||||
// Simulate successful upload without calling real API
|
||||
debugPrint('[UploadQueue] Simulating changeset creation (no real API call)');
|
||||
await Future.delayed(const Duration(milliseconds: 500)); // Simulate network delay
|
||||
|
||||
// Move to node operation phase
|
||||
item.markChangesetCreated('simulate-changeset-${DateTime.now().millisecondsSinceEpoch}');
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// Real changeset creation
|
||||
debugPrint('[UploadQueue] Creating changeset for ${item.operation.name} operation');
|
||||
final up = Uploader(access, (nodeId) {}, (errorMessage) {}, uploadMode: item.uploadMode);
|
||||
final result = await up.createChangeset(item);
|
||||
|
||||
if (result.success) {
|
||||
// Changeset created successfully - move to node operation phase
|
||||
debugPrint('[UploadQueue] Changeset ${result.changesetId} created successfully');
|
||||
item.markChangesetCreated(result.changesetId!);
|
||||
_saveQueue();
|
||||
notifyListeners(); // Show "Uploading node..." next
|
||||
} else {
|
||||
// Changeset creation failed
|
||||
item.attempts++;
|
||||
_saveQueue();
|
||||
notifyListeners(); // Show attempt count immediately
|
||||
|
||||
if (item.attempts >= 3) {
|
||||
item.setError(result.errorMessage ?? 'Changeset creation failed after 3 attempts');
|
||||
_saveQueue();
|
||||
notifyListeners(); // Show error state immediately
|
||||
} else {
|
||||
// Reset to pending for retry
|
||||
item.uploadState = UploadState.pending;
|
||||
_saveQueue();
|
||||
notifyListeners(); // Show pending state for retry
|
||||
await Future.delayed(const Duration(seconds: 20));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process node operation (step 2 of 3)
|
||||
Future<void> _processNodeOperation(PendingUpload item, String access) async {
|
||||
if (item.changesetId == null) {
|
||||
debugPrint('[UploadQueue] ERROR: No changeset ID for node operation');
|
||||
item.setError('Missing changeset ID for node operation');
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if 59-minute window has expired
|
||||
if (item.hasChangesetExpired) {
|
||||
debugPrint('[UploadQueue] Changeset expired, could not submit node within 59 minutes');
|
||||
item.setError('Could not submit node within 59 minutes - changeset expired');
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('[UploadQueue] Processing node operation with changeset ${item.changesetId} (attempt ${item.nodeSubmissionAttempts + 1})');
|
||||
|
||||
if (item.uploadMode == UploadMode.simulate) {
|
||||
// Simulate successful node operation without calling real API
|
||||
debugPrint('[UploadQueue] Simulating node operation (no real API call)');
|
||||
await Future.delayed(const Duration(milliseconds: 500)); // Simulate network delay
|
||||
|
||||
// Store appropriate simulated node ID based on operation type
|
||||
if (item.operation == UploadOperation.modify) {
|
||||
// For edits, keep the original node ID (same as production behavior)
|
||||
item.submittedNodeId = item.originalNodeId!;
|
||||
} else {
|
||||
// For creates and extracts, generate new simulated ID
|
||||
item.submittedNodeId = DateTime.now().millisecondsSinceEpoch;
|
||||
}
|
||||
item.markNodeOperationComplete();
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// Real node operation
|
||||
final up = Uploader(access, (nodeId) {
|
||||
// This callback is called when node operation succeeds
|
||||
item.submittedNodeId = nodeId;
|
||||
}, (errorMessage) {
|
||||
// Error handling is done below
|
||||
}, uploadMode: item.uploadMode);
|
||||
|
||||
final result = await up.performNodeOperation(item, item.changesetId!);
|
||||
|
||||
item.incrementNodeSubmissionAttempts(); // Record this attempt
|
||||
_saveQueue();
|
||||
notifyListeners(); // Show attempt count immediately
|
||||
|
||||
if (result.success) {
|
||||
// Node operation succeeded - move to changeset close phase
|
||||
debugPrint('[UploadQueue] Node operation succeeded after ${item.nodeSubmissionAttempts} attempts, node ID: ${result.nodeId}');
|
||||
item.submittedNodeId = result.nodeId;
|
||||
item.markNodeOperationComplete();
|
||||
_saveQueue();
|
||||
notifyListeners(); // Show "Closing changeset..." next
|
||||
} else {
|
||||
// Node operation failed - will retry within 59-minute window
|
||||
debugPrint('[UploadQueue] Node operation failed (attempt ${item.nodeSubmissionAttempts}): ${result.errorMessage}');
|
||||
|
||||
// Check if we have time for another retry
|
||||
if (item.hasChangesetExpired) {
|
||||
debugPrint('[UploadQueue] Changeset expired during retry, marking as failed');
|
||||
item.setError('Could not submit node within 59 minutes - ${result.errorMessage}');
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
} else {
|
||||
// Still have time, will retry after backoff delay
|
||||
final nextDelay = item.nextNodeSubmissionRetryDelay;
|
||||
final timeLeft = item.timeUntilAutoClose;
|
||||
debugPrint('[UploadQueue] Will retry node submission in $nextDelay, ${timeLeft?.inMinutes}m remaining');
|
||||
// No state change needed - attempt count was already updated above
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process changeset close operation (step 3 of 3)
|
||||
Future<void> _processChangesetClose(PendingUpload item, String access) async {
|
||||
if (item.changesetId == null) {
|
||||
debugPrint('[UploadQueue] ERROR: No changeset ID for closing');
|
||||
item.setError('Missing changeset ID');
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if 59-minute window has expired - if so, mark as complete (trust OSM auto-close)
|
||||
if (item.hasChangesetExpired) {
|
||||
debugPrint('[UploadQueue] Changeset expired - trusting OSM auto-close (node was submitted successfully)');
|
||||
_markAsCompleting(item, submittedNodeId: item.submittedNodeId!);
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('[UploadQueue] Attempting to close changeset ${item.changesetId} (attempt ${item.changesetCloseAttempts + 1})');
|
||||
|
||||
if (item.uploadMode == UploadMode.simulate) {
|
||||
// Simulate successful changeset close without calling real API
|
||||
debugPrint('[UploadQueue] Simulating changeset close (no real API call)');
|
||||
await Future.delayed(const Duration(milliseconds: 300)); // Simulate network delay
|
||||
|
||||
// Mark as complete
|
||||
_markAsCompleting(item, submittedNodeId: item.submittedNodeId!);
|
||||
return;
|
||||
}
|
||||
|
||||
// Real changeset close
|
||||
final up = Uploader(access, (nodeId) {}, (errorMessage) {}, uploadMode: item.uploadMode);
|
||||
final result = await up.closeChangeset(item.changesetId!);
|
||||
|
||||
item.incrementChangesetCloseAttempts(); // This records the attempt time
|
||||
_saveQueue();
|
||||
notifyListeners(); // Show attempt count immediately
|
||||
|
||||
if (result.success) {
|
||||
// Changeset closed successfully
|
||||
debugPrint('[UploadQueue] Changeset close succeeded after ${item.changesetCloseAttempts} attempts');
|
||||
_markAsCompleting(item, submittedNodeId: item.submittedNodeId!);
|
||||
// _markAsCompleting handles its own save/notify
|
||||
} else if (result.changesetNotFound) {
|
||||
// Changeset not found - this suggests the upload may not have worked, start over with full retry
|
||||
debugPrint('[UploadQueue] Changeset not found during close, marking for full retry');
|
||||
item.setError(result.errorMessage ?? 'Changeset not found');
|
||||
_saveQueue();
|
||||
notifyListeners(); // Show error state immediately
|
||||
} else {
|
||||
// Changeset close failed - will retry after exponential backoff delay
|
||||
// Note: This will NEVER error out - will keep trying until 59-minute window expires
|
||||
final nextDelay = item.nextChangesetCloseRetryDelay;
|
||||
final timeLeft = item.timeUntilAutoClose;
|
||||
debugPrint('[UploadQueue] Changeset close failed (attempt ${item.changesetCloseAttempts}), will retry in $nextDelay, ${timeLeft?.inMinutes}m remaining');
|
||||
debugPrint('[UploadQueue] Error: ${result.errorMessage}');
|
||||
// No additional state change needed - attempt count was already updated above
|
||||
}
|
||||
}
|
||||
|
||||
void stopUploader() {
|
||||
@@ -254,7 +656,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
|
||||
// Mark an item as completing (shows checkmark) and schedule removal after 1 second
|
||||
void _markAsCompleting(PendingUpload item, {int? submittedNodeId, int? simulatedNodeId}) {
|
||||
item.completing = true;
|
||||
item.markAsComplete();
|
||||
|
||||
// Store the submitted node ID for cleanup purposes
|
||||
if (submittedNodeId != null) {
|
||||
@@ -275,7 +677,9 @@ class UploadQueueState extends ChangeNotifier {
|
||||
debugPrint('[UploadQueue] Simulated deletion, removing fake node ID: $simulatedNodeId from cache');
|
||||
_handleSuccessfulDeletion(item);
|
||||
} else {
|
||||
debugPrint('[UploadQueue] Simulated upload, fake node ID: $simulatedNodeId');
|
||||
debugPrint('[UploadQueue] Simulated upload successful, updating cache with fake node ID: $simulatedNodeId');
|
||||
// Update cache with simulated node ID, same as production mode
|
||||
_updateCacheWithRealNodeId(item, simulatedNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,41 +706,96 @@ class UploadQueueState extends ChangeNotifier {
|
||||
);
|
||||
|
||||
// Add/update the cache with the real node
|
||||
NodeCache.instance.addOrUpdate([realNode]);
|
||||
_nodeCache.addOrUpdate([realNode]);
|
||||
|
||||
// Clean up any temp nodes at the same coordinate
|
||||
NodeCache.instance.removeTempNodesByCoordinate(item.coord);
|
||||
// Clean up the specific temp node for this upload
|
||||
if (item.tempNodeId != null) {
|
||||
_nodeCache.removeTempNodeById(item.tempNodeId!);
|
||||
}
|
||||
|
||||
// For modify operations, clean up the original node's _pending_edit marker
|
||||
// For extract operations, we don't modify the original node so leave it unchanged
|
||||
if (item.isEdit && item.originalNodeId != null) {
|
||||
// Remove the _pending_edit marker from the original node in cache
|
||||
// The next Overpass fetch will provide the authoritative data anyway
|
||||
NodeCache.instance.removePendingEditMarker(item.originalNodeId!);
|
||||
_nodeCache.removePendingEditMarker(item.originalNodeId!);
|
||||
}
|
||||
|
||||
// Notify node provider to update the map
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
}
|
||||
|
||||
// Handle successful deletion by removing the node from cache
|
||||
void _handleSuccessfulDeletion(PendingUpload item) {
|
||||
if (item.originalNodeId != null) {
|
||||
// Remove the node from cache entirely
|
||||
NodeCache.instance.removeNodeById(item.originalNodeId!);
|
||||
_nodeCache.removeNodeById(item.originalNodeId!);
|
||||
|
||||
// Notify node provider to update the map
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to format multiple directions as a string or number
|
||||
dynamic _formatDirectionsAsString(List<double> directions) {
|
||||
// Helper method to format multiple directions for submission, supporting profile FOV
|
||||
dynamic _formatDirectionsForSubmission(List<double> directions, NodeProfile? profile) {
|
||||
if (directions.isEmpty) return 0.0;
|
||||
|
||||
// If profile has FOV, convert center directions to range notation
|
||||
if (profile?.fov != null && profile!.fov! > 0) {
|
||||
final ranges = directions.map((center) =>
|
||||
_formatDirectionWithFov(center, profile.fov!)
|
||||
).toList();
|
||||
|
||||
return ranges.length == 1 ? ranges.first : ranges.join(';');
|
||||
}
|
||||
|
||||
// No profile FOV: use original format (single number or semicolon-separated)
|
||||
if (directions.length == 1) return directions.first;
|
||||
return directions.map((d) => d.round().toString()).join(';');
|
||||
}
|
||||
|
||||
// Convert a center direction and FOV to range notation (e.g., 180° center with 90° FOV -> "135-225")
|
||||
String _formatDirectionWithFov(double center, double fov) {
|
||||
// Handle 360-degree FOV as special case
|
||||
if (fov >= 360) {
|
||||
return '0-360';
|
||||
}
|
||||
|
||||
final halfFov = fov / 2;
|
||||
final start = (center - halfFov + 360) % 360;
|
||||
final end = (center + halfFov) % 360;
|
||||
|
||||
return '${start.round()}-${end.round()}';
|
||||
}
|
||||
|
||||
// Clean up pending nodes from cache when queue items are deleted/cleared
|
||||
void _cleanupPendingNodeFromCache(PendingUpload upload) {
|
||||
if (upload.isDeletion) {
|
||||
// For deletions: remove the _pending_deletion marker from the original node
|
||||
if (upload.originalNodeId != null) {
|
||||
_nodeCache.removePendingDeletionMarker(upload.originalNodeId!);
|
||||
}
|
||||
} else if (upload.isEdit) {
|
||||
// For edits: remove the specific temp node and the _pending_edit marker from original
|
||||
if (upload.tempNodeId != null) {
|
||||
_nodeCache.removeTempNodeById(upload.tempNodeId!);
|
||||
}
|
||||
if (upload.originalNodeId != null) {
|
||||
_nodeCache.removePendingEditMarker(upload.originalNodeId!);
|
||||
}
|
||||
} else if (upload.operation == UploadOperation.extract) {
|
||||
// For extracts: remove the specific temp node (leave original unchanged)
|
||||
if (upload.tempNodeId != null) {
|
||||
_nodeCache.removeTempNodeById(upload.tempNodeId!);
|
||||
}
|
||||
} else {
|
||||
// For creates: remove the specific temp node
|
||||
if (upload.tempNodeId != null) {
|
||||
_nodeCache.removeTempNodeById(upload.tempNodeId!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Queue persistence ----------
|
||||
Future<void> _saveQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -354,6 +813,17 @@ class UploadQueueState extends ChangeNotifier {
|
||||
..addAll(list.map((e) => PendingUpload.fromJson(e)));
|
||||
}
|
||||
|
||||
// Public method for migration purposes
|
||||
Future<void> reloadQueue() async {
|
||||
await _loadQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Public method to manually trigger cache repopulation (useful for debugging or after cache clears)
|
||||
void repopulateCacheFromQueue() {
|
||||
_repopulateCacheFromQueue();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_uploadTimer?.cancel();
|
||||
|
||||
@@ -1,30 +1,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/node_cache.dart';
|
||||
import '../services/map_data_provider.dart';
|
||||
import '../services/node_data_manager.dart';
|
||||
import '../services/changelog_service.dart';
|
||||
import 'refine_tags_sheet.dart';
|
||||
import 'proximity_warning_dialog.dart';
|
||||
import 'submission_guide_dialog.dart';
|
||||
import 'positioning_tutorial_overlay.dart';
|
||||
|
||||
class AddNodeSheet extends StatelessWidget {
|
||||
class AddNodeSheet extends StatefulWidget {
|
||||
const AddNodeSheet({super.key, required this.session});
|
||||
|
||||
final AddNodeSession session;
|
||||
|
||||
@override
|
||||
State<AddNodeSheet> createState() => _AddNodeSheetState();
|
||||
}
|
||||
|
||||
class _AddNodeSheetState extends State<AddNodeSheet> {
|
||||
bool _showTutorial = false;
|
||||
bool _isCheckingTutorial = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkTutorialStatus();
|
||||
// Listen to node data manager for cache updates
|
||||
NodeDataManager().addListener(_onCacheUpdated);
|
||||
}
|
||||
|
||||
void _onCacheUpdated() {
|
||||
// Rebuild when cache updates (e.g., when new data loads)
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _checkTutorialStatus() async {
|
||||
final hasCompleted = await ChangelogService().hasCompletedPositioningTutorial();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showTutorial = !hasCompleted;
|
||||
_isCheckingTutorial = false;
|
||||
});
|
||||
|
||||
// If tutorial should be shown, register callback with AppState
|
||||
if (_showTutorial) {
|
||||
final appState = context.read<AppState>();
|
||||
appState.registerTutorialCallback(_hideTutorial);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _hideTutorial() {
|
||||
if (mounted && _showTutorial) {
|
||||
setState(() {
|
||||
_showTutorial = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Remove listener
|
||||
NodeDataManager().removeListener(_onCacheUpdated);
|
||||
|
||||
// Clear tutorial callback when widget is disposed
|
||||
if (_showTutorial) {
|
||||
try {
|
||||
context.read<AppState>().clearTutorialCallback();
|
||||
} catch (e) {
|
||||
// Context might be unavailable during disposal, ignore
|
||||
}
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) {
|
||||
_checkSubmissionGuideAndProceed(context, appState, locService);
|
||||
}
|
||||
|
||||
void _checkSubmissionGuideAndProceed(BuildContext context, AppState appState, LocalizationService locService) async {
|
||||
// Check if user has seen the submission guide
|
||||
final hasSeenGuide = await ChangelogService().hasSeenSubmissionGuide();
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (!hasSeenGuide) {
|
||||
// Show submission guide dialog first
|
||||
final shouldProceed = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const SubmissionGuideDialog(),
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
// If user canceled the submission guide, don't proceed with submission
|
||||
if (shouldProceed != true) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Now proceed with proximity check
|
||||
_checkProximityOnly(context, appState, locService);
|
||||
}
|
||||
|
||||
void _checkProximityOnly(BuildContext context, AppState appState, LocalizationService locService) {
|
||||
// Only check proximity if we have a target location
|
||||
if (session.target == null) {
|
||||
if (widget.session.target == null) {
|
||||
_commitWithoutCheck(context, appState, locService);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for nearby nodes within the configured distance
|
||||
final nearbyNodes = NodeCache.instance.findNodesWithinDistance(
|
||||
session.target!,
|
||||
final nearbyNodes = MapDataProvider().findNodesWithinDistance(
|
||||
widget.session.target!,
|
||||
kNodeProximityWarningDistance,
|
||||
);
|
||||
|
||||
@@ -58,8 +153,111 @@ class AddNodeSheet extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProfileDropdown(BuildContext context, AppState appState, AddNodeSession session, List<NodeProfile> submittableProfiles, LocalizationService locService) {
|
||||
return PopupMenuButton<String>(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
session.profile?.name ?? locService.t('addNode.selectProfile'),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: session.profile != null ? null : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.arrow_drop_down, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
itemBuilder: (context) => [
|
||||
// Regular profiles
|
||||
...submittableProfiles.map(
|
||||
(profile) => PopupMenuItem<String>(
|
||||
value: 'profile_${profile.id}',
|
||||
child: Text(profile.name),
|
||||
),
|
||||
),
|
||||
// Divider
|
||||
if (submittableProfiles.isNotEmpty) const PopupMenuDivider(),
|
||||
// Get more... option
|
||||
PopupMenuItem<String>(
|
||||
value: 'get_more',
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.language, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
locService.t('profiles.getMore'),
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'get_more') {
|
||||
_openIdentifyWebsite(context);
|
||||
} else if (value.startsWith('profile_')) {
|
||||
final profileId = value.substring(8); // Remove 'profile_' prefix
|
||||
final profile = submittableProfiles.firstWhere((p) => p.id == profileId);
|
||||
appState.updateSession(profile: profile);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _openIdentifyWebsite(BuildContext context) async {
|
||||
const url = 'https://deflock.me/identify';
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication, // Force external browser
|
||||
);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
_showErrorSnackBar(context, 'Unable to open website');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
_showErrorSnackBar(context, 'Error opening website: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showErrorSnackBar(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDirectionControls(BuildContext context, AppState appState, AddNodeSession session, LocalizationService locService) {
|
||||
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
|
||||
final is360Fov = session.profile?.fov == 360;
|
||||
final enableDirectionControls = requiresDirection && !is360Fov;
|
||||
|
||||
// Force direction to 0 when FOV is 360 (omnidirectional)
|
||||
if (is360Fov && session.directionDegrees != 0) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
appState.updateSession(directionDeg: 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Format direction display text with bold for current direction
|
||||
String directionsText = '';
|
||||
@@ -109,7 +307,7 @@ class AddNodeSheet extends StatelessWidget {
|
||||
divisions: 359,
|
||||
value: session.directionDegrees,
|
||||
label: session.directionDegrees.round().toString(),
|
||||
onChanged: requiresDirection ? (v) => appState.updateSession(directionDeg: v) : null,
|
||||
onChanged: enableDirectionControls ? (v) => appState.updateSession(directionDeg: v) : null,
|
||||
),
|
||||
),
|
||||
// Direction control buttons - always show but grey out when direction not required
|
||||
@@ -119,9 +317,9 @@ class AddNodeSheet extends StatelessWidget {
|
||||
icon: Icon(
|
||||
Icons.remove,
|
||||
size: 20,
|
||||
color: requiresDirection ? null : Theme.of(context).disabledColor,
|
||||
color: enableDirectionControls ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
onPressed: requiresDirection && session.directions.length > 1
|
||||
onPressed: enableDirectionControls && session.directions.length > 1
|
||||
? () => appState.removeDirection()
|
||||
: null,
|
||||
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
|
||||
@@ -133,9 +331,9 @@ class AddNodeSheet extends StatelessWidget {
|
||||
icon: Icon(
|
||||
Icons.add,
|
||||
size: 20,
|
||||
color: requiresDirection && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
|
||||
color: enableDirectionControls && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
onPressed: requiresDirection && session.directions.length < 8 ? () => appState.addDirection() : null,
|
||||
onPressed: enableDirectionControls && session.directions.length < 8 ? () => appState.addDirection() : null,
|
||||
tooltip: requiresDirection
|
||||
? (session.directions.length >= 8 ? 'Maximum 8 directions allowed' : 'Add new direction')
|
||||
: 'Direction not required for this profile',
|
||||
@@ -147,9 +345,9 @@ class AddNodeSheet extends StatelessWidget {
|
||||
icon: Icon(
|
||||
Icons.repeat,
|
||||
size: 20,
|
||||
color: requiresDirection ? null : Theme.of(context).disabledColor,
|
||||
color: enableDirectionControls ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
onPressed: requiresDirection && session.directions.length > 1
|
||||
onPressed: enableDirectionControls && session.directions.length > 1
|
||||
? () => appState.cycleDirection()
|
||||
: null,
|
||||
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',
|
||||
@@ -188,37 +386,78 @@ class AddNodeSheet extends StatelessWidget {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
void _commit() {
|
||||
void commit() {
|
||||
_checkProximityAndCommit(context, appState, locService);
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
void cancel() {
|
||||
appState.cancelSession();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
final session = widget.session;
|
||||
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
|
||||
// Check if we have good cache coverage around the node position
|
||||
bool hasGoodCoverage = true;
|
||||
if (session.target != null) {
|
||||
// Create a small bounds around the target position to check coverage
|
||||
const double bufferDegrees = 0.001; // ~100m buffer
|
||||
final targetBounds = LatLngBounds(
|
||||
LatLng(session.target!.latitude - bufferDegrees, session.target!.longitude - bufferDegrees),
|
||||
LatLng(session.target!.latitude + bufferDegrees, session.target!.longitude + bufferDegrees),
|
||||
);
|
||||
hasGoodCoverage = MapDataProvider().hasGoodCoverageFor(targetBounds);
|
||||
|
||||
// If strict coverage check fails, fall back to checking if we have any nodes nearby
|
||||
// This handles the timing issue where cache might not be marked as "covered" yet
|
||||
if (!hasGoodCoverage) {
|
||||
final nearbyNodes = MapDataProvider().findNodesWithinDistance(
|
||||
session.target!,
|
||||
200.0, // 200m radius - if we have nodes nearby, we likely have good data
|
||||
);
|
||||
hasGoodCoverage = nearbyNodes.isNotEmpty;
|
||||
}
|
||||
}
|
||||
|
||||
final allowSubmit = appState.isLoggedIn &&
|
||||
submittableProfiles.isNotEmpty &&
|
||||
session.profile != null &&
|
||||
session.profile!.isSubmittable;
|
||||
session.profile!.isSubmittable &&
|
||||
hasGoodCoverage;
|
||||
|
||||
void _openRefineTags() async {
|
||||
final result = await Navigator.push<OperatorProfile?>(
|
||||
void navigateToLogin() {
|
||||
Navigator.pushNamed(context, '/settings/osm-account');
|
||||
}
|
||||
|
||||
void openRefineTags() async {
|
||||
final result = await Navigator.push<RefineTagsResult?>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RefineTagsSheet(
|
||||
selectedOperatorProfile: session.operatorProfile,
|
||||
selectedProfile: session.profile,
|
||||
currentRefinedTags: session.refinedTags,
|
||||
operation: UploadOperation.create,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
if (result != session.operatorProfile) {
|
||||
appState.updateSession(operatorProfile: result);
|
||||
if (result != null) {
|
||||
appState.updateSession(
|
||||
operatorProfile: result.operatorProfile,
|
||||
refinedTags: result.refinedTags,
|
||||
changesetComment: result.changesetComment,
|
||||
updateOperatorProfile: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
fit: StackFit.loose,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
@@ -233,14 +472,7 @@ class AddNodeSheet extends StatelessWidget {
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: Text(locService.t('addNode.profile')),
|
||||
trailing: DropdownButton<NodeProfile?>(
|
||||
value: session.profile,
|
||||
hint: Text(locService.t('addNode.selectProfile')),
|
||||
items: submittableProfiles
|
||||
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
|
||||
.toList(),
|
||||
onChanged: (p) => appState.updateSession(profile: p),
|
||||
),
|
||||
trailing: _buildProfileDropdown(context, appState, session, submittableProfiles, locService),
|
||||
),
|
||||
// Direction controls
|
||||
_buildDirectionControls(context, appState, session, locService),
|
||||
@@ -308,6 +540,22 @@ class AddNodeSheet extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (!hasGoodCoverage)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.cloud_download, color: Colors.blue, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('addNode.loadingAreaData'),
|
||||
style: const TextStyle(color: Colors.blue, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
@@ -315,11 +563,9 @@ class AddNodeSheet extends StatelessWidget {
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: session.profile != null ? _openRefineTags : null, // Disabled when no profile selected
|
||||
onPressed: session.profile != null ? openRefineTags : null, // Disabled when no profile selected
|
||||
icon: const Icon(Icons.tune),
|
||||
label: Text(session.operatorProfile != null
|
||||
? locService.t('addNode.refineTagsWithProfile', params: [session.operatorProfile!.name])
|
||||
: locService.t('addNode.refineTags')),
|
||||
label: Text(locService.t('addNode.refineTags')),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -330,15 +576,15 @@ class AddNodeSheet extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _cancel,
|
||||
onPressed: cancel,
|
||||
child: Text(locService.cancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: allowSubmit ? _commit : null,
|
||||
child: Text(locService.t('actions.submit')),
|
||||
onPressed: !appState.isLoggedIn ? navigateToLogin : (allowSubmit ? commit : null),
|
||||
child: Text(!appState.isLoggedIn ? locService.t('actions.logIn') : locService.t('actions.submit')),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -346,6 +592,14 @@ class AddNodeSheet extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
|
||||
// Tutorial overlay - show only if tutorial should be shown and we're done checking
|
||||
if (!_isCheckingTutorial && _showTutorial)
|
||||
Positioned.fill(
|
||||
child: PositioningTutorialOverlay(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -185,6 +185,7 @@ class AdvancedEditOptionsSheet extends StatelessWidget {
|
||||
}
|
||||
|
||||
// No custom scheme or app launch failed - redirect to app store
|
||||
if (!context.mounted) return;
|
||||
await _redirectToAppStore(context, editor);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ class CameraIcon extends StatelessWidget {
|
||||
height: kNodeIconDiameter,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _ringColor.withOpacity(kNodeDotOpacity),
|
||||
color: _ringColor.withValues(alpha: kNodeDotOpacity),
|
||||
border: Border.all(
|
||||
color: _ringColor,
|
||||
width: getNodeRingThickness(context),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/changelog_service.dart';
|
||||
import '../services/version_service.dart';
|
||||
|
||||
class ChangelogDialog extends StatelessWidget {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -97,8 +96,8 @@ class _CompassIndicatorState extends State<CompassIndicator> {
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: isDisabled
|
||||
? Colors.grey.withOpacity(0.8)
|
||||
: Colors.white.withOpacity(0.95),
|
||||
? Colors.grey.withValues(alpha: 0.8)
|
||||
: Colors.white.withValues(alpha: 0.95),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isDisabled
|
||||
@@ -108,7 +107,7 @@ class _CompassIndicatorState extends State<CompassIndicator> {
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.25),
|
||||
color: Colors.black.withValues(alpha: 0.25),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
|
||||
250
lib/widgets/custom_scale_bar.dart
Normal file
250
lib/widgets/custom_scale_bar.dart
Normal file
@@ -0,0 +1,250 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../state/settings_state.dart';
|
||||
|
||||
/// Custom scale bar widget that respects user's distance unit preference
|
||||
///
|
||||
/// Replaces flutter_map's built-in Scalebar to support metric/imperial units.
|
||||
/// Uses the existing DistanceUnit enum from SettingsState.
|
||||
///
|
||||
/// Based on the brutalist code philosophy: simple, explicit, maintainable.
|
||||
class CustomScaleBar extends StatelessWidget {
|
||||
const CustomScaleBar({
|
||||
super.key,
|
||||
this.maxWidthPx = 120,
|
||||
this.barHeight = 8,
|
||||
this.padding = const EdgeInsets.all(10),
|
||||
this.alignment = Alignment.bottomLeft,
|
||||
this.textStyle,
|
||||
});
|
||||
|
||||
final double maxWidthPx;
|
||||
final double barHeight;
|
||||
final EdgeInsets padding;
|
||||
final Alignment alignment;
|
||||
final TextStyle? textStyle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
final camera = MapCamera.of(context);
|
||||
final center = camera.center;
|
||||
final zoom = camera.zoom;
|
||||
|
||||
// Calculate meters represented by maxWidthPx at current zoom around map center
|
||||
final maxMeters = _metersForPixelSpan(camera, center, zoom, maxWidthPx);
|
||||
|
||||
// Calculate nice intervals in the display unit for better user experience
|
||||
final niceMeters = _niceDistanceInDisplayUnit(maxMeters, appState.distanceUnit);
|
||||
|
||||
// Calculate actual bar width in pixels
|
||||
final metersPerPx = maxMeters / maxWidthPx;
|
||||
final barWidthPx = (niceMeters / metersPerPx).clamp(1.0, maxWidthPx);
|
||||
|
||||
// Format the label based on user's unit preference
|
||||
final label = _formatLabel(niceMeters, appState.distanceUnit);
|
||||
|
||||
// Use styling that matches the original flutter_map scale bar
|
||||
final style = textStyle ??
|
||||
const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
|
||||
return Align(
|
||||
alignment: alignment,
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: style),
|
||||
const SizedBox(height: 2),
|
||||
CustomPaint(
|
||||
size: Size(barWidthPx, barHeight + 6),
|
||||
painter: _ScaleBarPainter(barHeight: barHeight),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Calculate the real-world distance represented by a pixel span at the current zoom level
|
||||
///
|
||||
/// Uses a simple approach: calculate the distance between two points that are
|
||||
/// separated by the pixel span at the map center latitude.
|
||||
double _metersForPixelSpan(
|
||||
MapCamera camera,
|
||||
LatLng center,
|
||||
double zoom,
|
||||
double pixelSpan,
|
||||
) {
|
||||
// At the equator, 1 degree of longitude = ~111,320 meters
|
||||
// At other latitudes, it's scaled by cos(latitude)
|
||||
const metersPerDegreeAtEquator = 111320.0;
|
||||
final metersPerDegreeLongitude = metersPerDegreeAtEquator * math.cos(center.latitude * math.pi / 180);
|
||||
|
||||
// Calculate degrees per pixel at this zoom level
|
||||
// Web Mercator: 360 degrees spans 2^zoom tiles, each tile is 256 pixels
|
||||
final tilesAtZoom = math.pow(2, zoom);
|
||||
final pixelsAtZoom = tilesAtZoom * 256;
|
||||
final degreesPerPixel = 360.0 / pixelsAtZoom;
|
||||
|
||||
// Calculate the longitude span represented by our pixel span
|
||||
final longitudeSpan = degreesPerPixel * pixelSpan;
|
||||
|
||||
// Convert to meters
|
||||
return longitudeSpan * metersPerDegreeLongitude;
|
||||
}
|
||||
|
||||
/// Convert a maximum distance to a "nice" rounded distance in the display unit
|
||||
///
|
||||
/// For metric: Nice intervals like 1m, 2m, 5m, 10m, 1km, 2km, 5km
|
||||
/// For imperial: Nice intervals like 1ft, 2ft, 5ft, 10ft, 1mi, 2mi, 5mi
|
||||
double _niceDistanceInDisplayUnit(double maxMeters, DistanceUnit unit) {
|
||||
if (maxMeters <= 0) return 0;
|
||||
|
||||
switch (unit) {
|
||||
case DistanceUnit.metric:
|
||||
return _calculateNiceDistance(maxMeters, [
|
||||
// Small metric intervals (meters)
|
||||
1, 2, 5, 10, 20, 50, 100, 200, 500,
|
||||
// Large metric intervals (kilometers, converted to meters)
|
||||
1000, 2000, 5000, 10000, 20000, 50000, 100000, 200000, 500000,
|
||||
1000000, 2000000, 5000000, 10000000,
|
||||
]);
|
||||
|
||||
case DistanceUnit.imperial:
|
||||
const feetToMeters = 0.3048;
|
||||
const milesToMeters = 1609.34;
|
||||
|
||||
return _calculateNiceDistance(maxMeters, [
|
||||
// Small imperial intervals (feet, converted to meters)
|
||||
1 * feetToMeters,
|
||||
2 * feetToMeters,
|
||||
5 * feetToMeters,
|
||||
10 * feetToMeters,
|
||||
20 * feetToMeters,
|
||||
50 * feetToMeters,
|
||||
100 * feetToMeters,
|
||||
200 * feetToMeters,
|
||||
500 * feetToMeters,
|
||||
1000 * feetToMeters,
|
||||
2000 * feetToMeters,
|
||||
5000 * feetToMeters,
|
||||
// Large imperial intervals (miles, converted to meters)
|
||||
1 * milesToMeters,
|
||||
2 * milesToMeters,
|
||||
5 * milesToMeters,
|
||||
10 * milesToMeters,
|
||||
20 * milesToMeters,
|
||||
50 * milesToMeters,
|
||||
100 * milesToMeters,
|
||||
200 * milesToMeters,
|
||||
500 * milesToMeters,
|
||||
1000 * milesToMeters,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the largest "nice" distance that fits within the maximum
|
||||
double _calculateNiceDistance(double maxMeters, List<double> intervals) {
|
||||
// Find the largest interval that's still smaller than our max
|
||||
for (int i = intervals.length - 1; i >= 0; i--) {
|
||||
if (intervals[i] <= maxMeters) {
|
||||
return intervals[i];
|
||||
}
|
||||
}
|
||||
// Fallback to smallest interval if none fit
|
||||
return intervals.first;
|
||||
}
|
||||
|
||||
/// Format the distance label according to the user's unit preference
|
||||
///
|
||||
/// Uses the same logic as DistanceService for consistency:
|
||||
/// - Metric: meters < 1000m, kilometers ≥ 1000m
|
||||
/// - Imperial: feet < 5280ft, miles ≥ 5280ft
|
||||
String _formatLabel(double meters, DistanceUnit unit) {
|
||||
switch (unit) {
|
||||
case DistanceUnit.metric:
|
||||
if (meters >= 1000) {
|
||||
final km = meters / 1000.0;
|
||||
return '${_trim(km)} km';
|
||||
}
|
||||
return '${meters.round()} m';
|
||||
|
||||
case DistanceUnit.imperial:
|
||||
final feet = meters * 3.28084;
|
||||
if (feet >= 5280) {
|
||||
final miles = feet / 5280.0;
|
||||
return '${_trim(miles)} mi';
|
||||
}
|
||||
return '${feet.round()} ft';
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim unnecessary decimal places from distance values
|
||||
String _trim(double v) {
|
||||
if (v >= 100) return v.toStringAsFixed(0);
|
||||
if (v >= 10) return v.toStringAsFixed(1).replaceAll(RegExp(r'\.0$'), '');
|
||||
return v
|
||||
.toStringAsFixed(2)
|
||||
.replaceAll(RegExp(r'0+$'), '')
|
||||
.replaceAll(RegExp(r'\.$'), '');
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom painter for drawing the scale bar
|
||||
///
|
||||
/// Draws a simple horizontal line with vertical end markers,
|
||||
/// matching the style of the original flutter_map scale bar.
|
||||
class _ScaleBarPainter extends CustomPainter {
|
||||
_ScaleBarPainter({required this.barHeight});
|
||||
|
||||
final double barHeight;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.black
|
||||
..strokeWidth = 3 // Match original scale bar stroke width
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
final yTop = 2.0;
|
||||
final yBottom = yTop + barHeight;
|
||||
|
||||
// Draw horizontal base line
|
||||
canvas.drawLine(Offset(0, yBottom), Offset(size.width, yBottom), paint);
|
||||
|
||||
// Draw left vertical marker
|
||||
canvas.drawLine(Offset(0, yTop), Offset(0, yBottom), paint);
|
||||
|
||||
// Draw right vertical marker
|
||||
canvas.drawLine(Offset(size.width, yTop), Offset(size.width, yBottom), paint);
|
||||
|
||||
// Draw middle marker for longer scales (visual clarity)
|
||||
if (size.width >= 40) {
|
||||
final midX = size.width / 2;
|
||||
canvas.drawLine(
|
||||
Offset(midX, yBottom - barHeight * 0.6),
|
||||
Offset(midX, yBottom),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _ScaleBarPainter oldDelegate) =>
|
||||
oldDelegate.barHeight != barHeight;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import '../dev_config.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/offline_area_service.dart';
|
||||
import '../services/offline_areas/offline_tile_utils.dart';
|
||||
import 'download_started_dialog.dart';
|
||||
|
||||
class DownloadAreaDialog extends StatefulWidget {
|
||||
final MapController controller;
|
||||
@@ -56,12 +57,17 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
}
|
||||
|
||||
final minZoom = 1; // Always start from zoom 1 to show area overview when zoomed out
|
||||
final maxZoom = _zoom.toInt();
|
||||
|
||||
// Calculate maximum possible zoom based on tile count limit
|
||||
|
||||
// Calculate maximum possible zoom based on tile count limit and tile provider max zoom
|
||||
final maxPossibleZoom = _calculateMaxZoomForTileLimit(bounds, minZoom);
|
||||
|
||||
final nTiles = computeTileList(bounds, minZoom, maxZoom).length;
|
||||
// Clamp current zoom to the effective maximum if it exceeds it
|
||||
if (_zoom > maxPossibleZoom) {
|
||||
_zoom = maxPossibleZoom.toDouble();
|
||||
}
|
||||
|
||||
final actualMaxZoom = _zoom.toInt();
|
||||
final nTiles = computeTileList(bounds, minZoom, actualMaxZoom).length;
|
||||
final tileEstimateKb = _getTileEstimateKb();
|
||||
final totalMb = (nTiles * tileEstimateKb) / 1024.0;
|
||||
final roundedMb = (totalMb * 10).round() / 10; // Round to nearest tenth
|
||||
@@ -75,15 +81,22 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
}
|
||||
|
||||
/// Calculate the maximum zoom level that keeps tile count under the absolute limit
|
||||
/// and respects the selected tile type's maximum zoom level
|
||||
int _calculateMaxZoomForTileLimit(LatLngBounds bounds, int minZoom) {
|
||||
for (int zoom = minZoom; zoom <= kAbsoluteMaxZoom; zoom++) {
|
||||
final appState = context.read<AppState>();
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
|
||||
// Use tile type's max zoom if available, otherwise fall back to absolute max
|
||||
final effectiveMaxZoom = selectedTileType?.maxZoom ?? kAbsoluteMaxZoom;
|
||||
|
||||
for (int zoom = minZoom; zoom <= effectiveMaxZoom; zoom++) {
|
||||
final tileCount = computeTileList(bounds, minZoom, zoom).length;
|
||||
if (tileCount > kAbsoluteMaxTileCount) {
|
||||
// Return the previous zoom level that was still under the absolute limit
|
||||
return math.max(minZoom, zoom - 1);
|
||||
}
|
||||
}
|
||||
return kAbsoluteMaxZoom;
|
||||
return effectiveMaxZoom;
|
||||
}
|
||||
|
||||
/// Get tile size estimate in KB, using preview tile data if available, otherwise fallback to constant
|
||||
@@ -110,7 +123,6 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
final bounds = widget.controller.camera.visibleBounds;
|
||||
final maxZoom = _zoom.toInt();
|
||||
final isOfflineMode = appState.offlineMode;
|
||||
|
||||
// Use the calculated max possible zoom instead of fixed span
|
||||
@@ -177,8 +189,8 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: _tileCount! > kMaxReasonableTileCount
|
||||
? Colors.orange.withOpacity(0.1)
|
||||
: Colors.green.withOpacity(0.1),
|
||||
? Colors.orange.withValues(alpha: 0.1)
|
||||
: Colors.green.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Column(
|
||||
@@ -186,7 +198,7 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
children: [
|
||||
Text(
|
||||
_tileCount! > kMaxReasonableTileCount
|
||||
? 'Above recommended limit (Z${_maxPossibleZoom})'
|
||||
? 'Above recommended limit (Z$_maxPossibleZoom)'
|
||||
: locService.t('download.maxRecommendedZoom', params: [_maxPossibleZoom.toString()]),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
@@ -199,7 +211,7 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_tileCount! > kMaxReasonableTileCount
|
||||
? 'Current selection exceeds ${kMaxReasonableTileCount} recommended tile limit but is within ${kAbsoluteMaxTileCount} absolute limit'
|
||||
? 'Current selection exceeds $kMaxReasonableTileCount recommended tile limit but is within $kAbsoluteMaxTileCount absolute limit'
|
||||
: locService.t('download.withinTileLimit', params: [kMaxReasonableTileCount.toString()]),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
@@ -218,9 +230,9 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -252,8 +264,9 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
try {
|
||||
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
|
||||
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
|
||||
if (!context.mounted) return;
|
||||
final dir = "${appDocDir.path}/$id";
|
||||
|
||||
|
||||
// Get current tile provider info
|
||||
final appState = context.read<AppState>();
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
@@ -275,16 +288,30 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
tileTypeName: selectedTileType?.name,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(locService.t('download.downloadStarted')),
|
||||
),
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const DownloadStartedDialog(),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.red),
|
||||
const SizedBox(width: 10),
|
||||
Text(locService.t('download.title')),
|
||||
],
|
||||
),
|
||||
content: Text(locService.t('download.downloadFailed', params: [e.toString()])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.t('actions.ok')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user