mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-05-30 11:49:34 +02:00
Merge pull request #24 from FoggedLens/tileprovider_rework
Tileprovider rework
This commit is contained in:
+132
-23
@@ -182,20 +182,47 @@ 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:**
|
||||
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
|
||||
3. Background uploader processes queue when online:
|
||||
- **Pending** → Create changeset → **CreatingChangeset** → **Uploading**
|
||||
- **Uploading** → Upload node → **ClosingChangeset**
|
||||
- **ClosingChangeset** → Close changeset → **Complete**
|
||||
4. Success → cache updated with real data, temp markers removed
|
||||
5. Failure → error state, retry available
|
||||
5. Failures → appropriate retry logic based on which stage failed
|
||||
|
||||
**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.
|
||||
@@ -259,7 +286,39 @@ These are internal app tags, not OSM tags. The underscore prefix makes this expl
|
||||
**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.
|
||||
|
||||
### 6. Offline vs Online Mode Behavior
|
||||
### 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:**
|
||||
```
|
||||
@@ -272,7 +331,7 @@ 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
|
||||
@@ -285,7 +344,7 @@ Local cache contains production data. Showing production nodes in sandbox mode w
|
||||
- Simple RecentAlert tracking prevents duplicate notifications
|
||||
- Visual callback system for in-app alerts when app is active
|
||||
|
||||
### 8. Compass Indicator & North Lock
|
||||
### 9. Compass Indicator & North Lock
|
||||
|
||||
**Purpose**: Visual compass showing map orientation with optional north-lock functionality
|
||||
|
||||
@@ -309,7 +368,32 @@ 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 (Simplified in v1.5.2+)
|
||||
|
||||
**Purpose**: Show loading and error states for surveillance data fetching only
|
||||
|
||||
**Simplified approach (v1.5.2+):**
|
||||
- **Surveillance data focus**: Only tracks node/camera data loading, not tile loading
|
||||
- **Visual feedback**: Tiles show their own loading progress naturally
|
||||
- **Reduced complexity**: Eliminated tile completion tracking and multiple issue types
|
||||
|
||||
**Status types:**
|
||||
- **Loading**: Shows when fetching surveillance data from APIs
|
||||
- **Success**: Brief confirmation when data loads successfully
|
||||
- **Timeout**: Network request timeouts
|
||||
- **Limit reached**: When node display limit is hit
|
||||
- **API issues**: Overpass/OSM API problems only
|
||||
|
||||
**What was removed:**
|
||||
- Tile server issue tracking (tiles handle their own progress)
|
||||
- "Both" network issue type (only surveillance data matters)
|
||||
- Complex semaphore-based completion detection
|
||||
- Tile-related status messages and localizations
|
||||
|
||||
**Why the change:**
|
||||
The previous approach tracked both tile loading and surveillance data, creating redundancy since tiles already show loading progress visually on the map. Users don't need to be notified about tile loading issues when they can see tiles loading/failing directly. Focusing only on surveillance data makes the indicator more purposeful and less noisy.
|
||||
|
||||
### 11. Suspected Locations
|
||||
|
||||
**Data pipeline:**
|
||||
- **CSV ingestion**: Downloads utility permit data from alprwatch.org
|
||||
@@ -327,7 +411,7 @@ Users often want to follow their location while keeping the map oriented north.
|
||||
**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
|
||||
### 12. Upload Mode Simplification
|
||||
|
||||
**Release vs Debug builds:**
|
||||
- **Release builds**: Production OSM only (simplified UX)
|
||||
@@ -340,11 +424,22 @@ Most users should contribute to production; testing modes add complexity
|
||||
bool get showUploadModeSelector => kDebugMode;
|
||||
```
|
||||
|
||||
### 11. Tile Provider System & URL Templates
|
||||
### 13. Tile Provider System & Clean Architecture (v1.5.2+)
|
||||
|
||||
**Design approach:**
|
||||
**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
|
||||
- **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
|
||||
|
||||
@@ -352,7 +447,7 @@ bool get showUploadModeSelector => kDebugMode;
|
||||
```
|
||||
{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
|
||||
{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)
|
||||
```
|
||||
@@ -363,13 +458,27 @@ bool get showUploadModeSelector => kDebugMode;
|
||||
- **Mapbox**: Satellite and street tiles, requires API key
|
||||
- **OpenTopoMap**: Topographic maps, no API key required
|
||||
|
||||
**Validation logic:**
|
||||
URL templates must contain either `{quadkey}` OR all of `{x}`, `{y}`, and `{z}`. This allows for both standard tile services and specialized formats like Bing Maps.
|
||||
**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
|
||||
|
||||
**Why this approach:**
|
||||
Provides maximum flexibility while maintaining simplicity. Users can add any tile service without code changes, while built-in providers offer immediate functionality. The quadkey system enables access to high-quality satellite imagery without API key requirements.
|
||||
**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
|
||||
|
||||
### 12. Navigation & Routing (Implemented, Awaiting Integration)
|
||||
**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, Awaiting Integration)
|
||||
|
||||
**Current state:**
|
||||
- **Search functionality**: Fully implemented and active
|
||||
|
||||
@@ -98,10 +98,12 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
## Roadmap
|
||||
|
||||
### Needed Bugfixes
|
||||
- Max nodes not working
|
||||
- Messages notification
|
||||
- Move "delete OSM acct" button to OSM page
|
||||
- Remove potentially wrong FOVs from default profiles
|
||||
- Download area zoom goes too far
|
||||
- Update node cache to reflect cleared queue entries
|
||||
- Are offline areas preferred for fast loading even when online? Check working.
|
||||
- Fix network indicator - only done when fetch queue is empty!
|
||||
|
||||
### Current Development
|
||||
- Decide what to do for extracting nodes attached to a way/relation:
|
||||
@@ -113,13 +115,10 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
- Nav start+end too close together error (warning + disable submit button?)
|
||||
- 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
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Upload System Refactor - v1.5.3
|
||||
|
||||
## Overview
|
||||
Refactored the upload queue processing and OSM submission logic to properly handle the three distinct phases of OSM node operations, fixing the core issue where step 2 failures (node operations) weren't handled correctly.
|
||||
|
||||
## Problem Analysis
|
||||
The previous implementation incorrectly treated OSM interaction as a 2-step process:
|
||||
1. ~~Open changeset + submit node~~ (conflated)
|
||||
2. Close changeset
|
||||
|
||||
But OSM actually requires 3 distinct steps:
|
||||
1. **Create changeset**
|
||||
2. **Perform node operation** (create/modify/delete)
|
||||
3. **Close changeset**
|
||||
|
||||
### Issues Fixed:
|
||||
- **Step 2 failure handling**: Node operation failures now properly close orphaned changesets and retry appropriately
|
||||
- **State confusion**: Users now see exactly which of the 3 stages is happening or failed
|
||||
- **Error tracking**: Each stage has appropriate retry logic and error messages
|
||||
- **UI clarity**: Displays "Creating changeset...", "Uploading...", "Closing changeset..." with progress info
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Uploader Service (`lib/services/uploader.dart`)
|
||||
- **Simplified UploadResult**: Replaced complex boolean flags with simple `success/failure` pattern
|
||||
- **Three explicit methods**:
|
||||
- `createChangeset(PendingUpload)` → Returns changeset ID
|
||||
- `performNodeOperation(PendingUpload, changesetId)` → Returns node ID
|
||||
- `closeChangeset(changesetId)` → Returns success/failure
|
||||
- **Legacy compatibility**: `upload()` method still exists for simulate mode
|
||||
- **Better error context**: Each method provides specific error messages for its stage
|
||||
|
||||
### 2. Upload Queue State (`lib/state/upload_queue_state.dart`)
|
||||
- **Three processing methods**:
|
||||
- `_processCreateChangeset()` - Stage 1
|
||||
- `_processNodeOperation()` - Stage 2
|
||||
- `_processChangesetClose()` - Stage 3
|
||||
- **Proper state transitions**: Clear progression through `pending` → `creatingChangeset` → `uploading` → `closingChangeset` → `complete`
|
||||
- **Stage-specific retry logic**:
|
||||
- Stage 1 failure: Simple retry (no cleanup)
|
||||
- Stage 2 failure: Close orphaned changeset, retry from stage 1
|
||||
- Stage 3 failure: Exponential backoff up to 59 minutes
|
||||
- **Simulate mode support**: All three stages work in simulate mode
|
||||
|
||||
### 3. Upload Queue UI (`lib/screens/upload_queue_screen.dart`)
|
||||
- **Enhanced status display**: Shows retry attempts and time remaining (only when changeset close has failed)
|
||||
- **Better error visibility**: Tap error icon to see detailed failure messages
|
||||
- **Stage progression**: Clear visual feedback for each of the 3 stages
|
||||
- **Cleaner progress display**: Time countdown only shows when there have been changeset close issues
|
||||
|
||||
### 4. Cache Cleanup (`lib/state/upload_queue_state.dart`, `lib/services/node_cache.dart`)
|
||||
- **Fixed orphaned pending nodes**: Removing or clearing queue items now properly cleans up temporary cache markers
|
||||
- **Operation-specific cleanup**:
|
||||
- **Creates**: Remove temporary nodes with `_pending_upload` markers
|
||||
- **Edits**: Remove temp nodes + `_pending_edit` markers from originals
|
||||
- **Deletes**: Remove `_pending_deletion` markers from originals
|
||||
- **Extracts**: Remove temp extracted nodes (leave originals unchanged)
|
||||
- **Added NodeCache methods**: `removePendingDeletionMarker()` for deletion cancellation cleanup
|
||||
|
||||
### 5. Documentation Updates
|
||||
- **DEVELOPER.md**: Added detailed explanation of three-stage architecture
|
||||
- **Changelog**: Updated v1.5.3 release notes to highlight the fix
|
||||
- **Code comments**: Improved throughout for clarity
|
||||
|
||||
## Architecture Benefits
|
||||
|
||||
### Brutalist Code Principles Applied:
|
||||
1. **Explicit over implicit**: Three methods instead of one complex method
|
||||
2. **Simple error handling**: Success/failure instead of multiple boolean flags
|
||||
3. **Clear responsibilities**: Each method does exactly one thing
|
||||
4. **Minimal state complexity**: Straightforward state machine progression
|
||||
|
||||
### User Experience Improvements:
|
||||
- **Transparent progress**: Users see exactly what stage is happening
|
||||
- **Better error messages**: Specific context about which stage failed
|
||||
- **Proper retry behavior**: Stage 2 failures no longer leave orphaned changesets
|
||||
- **Time awareness**: Countdown shows when OSM will auto-close changesets
|
||||
|
||||
### Maintainability Gains:
|
||||
- **Easier debugging**: Each stage can be tested independently
|
||||
- **Clear failure points**: No confusion about which step failed
|
||||
- **Simpler testing**: Individual stages are unit-testable
|
||||
- **Future extensibility**: Easy to add new upload operations or modify stages
|
||||
|
||||
## Refined Retry Logic (Post-Testing Updates)
|
||||
|
||||
After initial testing feedback, the retry logic was refined to properly handle the 59-minute changeset window:
|
||||
|
||||
### Three-Phase Retry Strategy:
|
||||
- **Phase 1 (Create Changeset)**: Up to 3 attempts with 20s delays → Error state (user retry required)
|
||||
- **Phase 2 (Submit Node)**: Unlimited attempts within 59-minute window → Error if time expires
|
||||
- **Phase 3 (Close Changeset)**: Unlimited attempts within 59-minute window → Auto-complete if time expires (trust OSM auto-close)
|
||||
|
||||
### Key Behavioral Changes:
|
||||
- **59-minute timer starts** when changeset creation succeeds (not when node operation completes)
|
||||
- **Node submission failures** retry indefinitely within the 59-minute window
|
||||
- **Changeset close failures** retry indefinitely but never error out (always eventually complete)
|
||||
- **UI countdown** only shows when there have been failures in phases 2 or 3
|
||||
- **Proper error messages**: "Failed to create changeset after 3 attempts" vs "Could not submit node within 59 minutes"
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
When testing this refactor:
|
||||
|
||||
1. **Normal uploads**: Verify all three stages show proper progression
|
||||
2. **Network interruption**:
|
||||
- Test failure at each stage individually
|
||||
- Verify orphaned changesets are properly closed
|
||||
- Check retry logic works appropriately
|
||||
3. **Error handling**:
|
||||
- Tap error icons to see detailed messages
|
||||
- Verify different error types show stage-specific context
|
||||
4. **Simulate mode**: Confirm all three stages work in simulate mode
|
||||
5. **Queue management**: Verify queue continues processing when individual items fail
|
||||
6. **Changeset closing**: Test that changeset close retries work with exponential backoff
|
||||
|
||||
## Rollback Plan
|
||||
If issues are discovered, the legacy `upload()` method can be restored by:
|
||||
1. Reverting `_processCreateChangeset()` to call `up.upload(item)` directly
|
||||
2. Removing `_processNodeOperation()` and `_processChangesetClose()` calls
|
||||
3. This would restore the old 2-stage behavior while keeping the UI improvements
|
||||
|
||||
---
|
||||
|
||||
The core fix addresses the main issue you identified: **step 2 failures (node operations) are now properly tracked and handled with appropriate cleanup and retry logic**.
|
||||
@@ -1,4 +1,38 @@
|
||||
{
|
||||
"1.5.3": {
|
||||
"content": [
|
||||
"• MAJOR: Proper three-stage upload process - uploads now correctly track changeset creation, node operation, and changeset closing as separate steps",
|
||||
"• NEW: Enhanced upload error handling - failures in each stage (create changeset, upload node, close changeset) are now handled appropriately",
|
||||
"• NEW: Improved upload status display - shows 'Creating changeset...', 'Uploading...', and 'Closing changeset...' with time remaining for changeset close",
|
||||
"• NEW: Error message details - tap the error icon (!) on failed uploads to see exactly what went wrong and at which stage",
|
||||
"• IMPROVED: Proper 59-minute changeset window handling - node submission and changeset closing share the same timer from successful changeset creation",
|
||||
"• IMPROVED: Step 2 failures (node operations) retry indefinitely within the 59-minute window instead of giving up after 3 attempts",
|
||||
"• IMPROVED: Changeset close retry logic - continues trying for up to 59 minutes, then trusts OSM auto-close (never errors out once node is submitted)",
|
||||
"• IMPROVED: Orphaned changeset cleanup - if network fails after changeset creation but before node upload, we properly close the changeset before retrying",
|
||||
"• IMPROVED: Upload queue processing is now more robust and continues when individual items encounter errors",
|
||||
"• IMPROVED: Real-time UI updates for all upload status changes, attempt counts, and time remaining (only shows countdown when changeset close has failed)",
|
||||
"• FIXED: Queue processing no longer gets stuck when individual uploads fail",
|
||||
"• FIXED: Orphaned pending nodes - removing queue items or clearing the queue now properly removes temporary markers from the map",
|
||||
"• IMPROVED: Removed placeholder FOV values from built-in profiles - FOV functionality remains available for custom profiles when actual device specifications are known",
|
||||
"• IMPROVED: Better debugging support throughout the upload pipeline for easier troubleshooting"
|
||||
]
|
||||
},
|
||||
"1.5.2": {
|
||||
"content": [
|
||||
"• MAJOR: Fixed severe tile loading performance issue - eliminated expensive filesystem searches on every tile request",
|
||||
"• IMPROVED: Smart cache routing - only checks offline cache when actually needed, dramatically improving browsing speed",
|
||||
"• IMPROVED: Simplified tile loading architecture - replaced HTTP interception with clean TileProvider implementation",
|
||||
"• IMPROVED: Cross-platform tile performance optimizations - better retry timing, request deduplication, queue management",
|
||||
"• IMPROVED: Network status indicator now focuses only on surveillance data loading, not tile loading (tiles show their own progress)",
|
||||
"• IMPROVED: Node limit behavior - buttons now show helpful messages instead of being disabled, encouraging users to zoom in for safe editing",
|
||||
"• IMPROVED: Indicator positioning - network status and node limit indicators automatically move down when search bar is visible",
|
||||
"• IMPROVED: Reduced complexity in cache management and state tracking",
|
||||
"• FIXED: Max nodes setting now correctly limits rendering only (not data fetching) to prevent UI lag",
|
||||
"• FIXED: New node limit indicator shows when not all devices are displayed due to rendering limit",
|
||||
"• FIXED: Tile cache properly clears when switching between tile providers/types - no more mixed tiles",
|
||||
"• FIXED: Network status indicator no longer shows false timeouts during surveillance data splitting operations"
|
||||
]
|
||||
},
|
||||
"1.5.1": {
|
||||
"content": [
|
||||
"• NEW: Bing satellite imagery - high-quality satellite tiles used by the iD editor, no API key required",
|
||||
|
||||
+8
-2
@@ -16,7 +16,6 @@ import 'services/tile_preview_service.dart';
|
||||
import 'services/changelog_service.dart';
|
||||
import 'services/operator_profile_service.dart';
|
||||
import 'services/profile_service.dart';
|
||||
import 'widgets/camera_provider_with_cache.dart';
|
||||
import 'widgets/proximity_warning_dialog.dart';
|
||||
import 'dev_config.dart';
|
||||
import 'state/auth_state.dart';
|
||||
@@ -437,7 +436,6 @@ class AppState extends ChangeNotifier {
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
// Clear node cache when switching upload modes to prevent mixing production/sandbox data
|
||||
NodeCache.instance.clear();
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
debugPrint('[AppState] Cleared node cache due to upload mode change');
|
||||
|
||||
await _settingsState.setUploadMode(mode);
|
||||
@@ -480,6 +478,14 @@ class AppState extends ChangeNotifier {
|
||||
await _settingsState.setNetworkStatusIndicatorEnabled(enabled);
|
||||
}
|
||||
|
||||
/// Migrate upload queue to new two-stage changeset system (v1.5.3)
|
||||
Future<void> migrateUploadQueueToTwoStageSystem() async {
|
||||
// Migration is handled automatically in PendingUpload.fromJson via _migrateFromLegacyFields
|
||||
// This method triggers a queue reload to apply migrations
|
||||
await _uploadQueueState.reloadQueue();
|
||||
debugPrint('[AppState] Upload queue migration completed');
|
||||
}
|
||||
|
||||
/// Set suspected location minimum distance from real nodes
|
||||
Future<void> setSuspectedLocationMinDistance(int distance) async {
|
||||
await _settingsState.setSuspectedLocationMinDistance(distance);
|
||||
|
||||
+15
-6
@@ -53,11 +53,18 @@ 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 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;
|
||||
|
||||
// Suspected locations CSV URL
|
||||
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
|
||||
@@ -125,11 +132,12 @@ const double kPinchMoveThreshold = 30.0; // How much drag required for two-finge
|
||||
const double kRotationThreshold = 6.0; // Degrees of rotation required before map actually rotates (Google Maps style)
|
||||
|
||||
// Tile fetch configuration (brutalist approach: simple, configurable, unlimited retries)
|
||||
const int kTileFetchConcurrentThreads = 10; // Number of simultaneous tile downloads
|
||||
const int kTileFetchInitialDelayMs = 200; // Base delay for first retry (500ms)
|
||||
const double kTileFetchBackoffMultiplier = 1.5; // Multiply delay by this each attempt
|
||||
const int kTileFetchMaxDelayMs = 5000; // Cap delays at this value (10 seconds max)
|
||||
const int kTileFetchRandomJitterMs = 100; // Random fuzz to add (0 to 250ms)
|
||||
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)
|
||||
@@ -165,3 +173,4 @@ double getNodeRingThickness(BuildContext context) {
|
||||
// return _kNodeRingThicknessBase * MediaQuery.of(context).devicePixelRatio;
|
||||
return _kNodeRingThicknessBase;
|
||||
}
|
||||
|
||||
|
||||
+15
-11
@@ -193,7 +193,7 @@
|
||||
"queueCleared": "Warteschlange geleert",
|
||||
"uploadQueueTitle": "Upload-Warteschlange ({} Elemente)",
|
||||
"queueIsEmpty": "Warteschlange ist leer",
|
||||
"cameraWithIndex": "Kamera {}",
|
||||
"itemWithIndex": "Objekt {}",
|
||||
"error": " (Fehler)",
|
||||
"completing": " (Wird abgeschlossen...)",
|
||||
"destination": "Ziel: {}",
|
||||
@@ -203,7 +203,10 @@
|
||||
"attempts": "Versuche: {}",
|
||||
"uploadFailedRetry": "Upload fehlgeschlagen. Zum Wiederholen antippen.",
|
||||
"retryUpload": "Upload wiederholen",
|
||||
"clearAll": "Alle Löschen"
|
||||
"clearAll": "Alle Löschen",
|
||||
"errorDetails": "Fehlerdetails",
|
||||
"uploading": " (Uploading...)",
|
||||
"closingChangeset": " (Changeset schließen...)"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Kachel-Anbieter",
|
||||
@@ -377,15 +380,16 @@
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
|
||||
+16
-11
@@ -225,7 +225,7 @@
|
||||
"queueCleared": "Queue cleared",
|
||||
"uploadQueueTitle": "Upload Queue ({} items)",
|
||||
"queueIsEmpty": "Queue is empty",
|
||||
"cameraWithIndex": "Camera {}",
|
||||
"itemWithIndex": "Item {}",
|
||||
"error": " (Error)",
|
||||
"completing": " (Completing...)",
|
||||
"destination": "Dest: {}",
|
||||
@@ -235,7 +235,11 @@
|
||||
"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...)"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Tile Providers",
|
||||
@@ -409,15 +413,16 @@
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
|
||||
+15
-11
@@ -225,7 +225,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: {}",
|
||||
@@ -235,7 +235,10 @@
|
||||
"attempts": "Intentos: {}",
|
||||
"uploadFailedRetry": "Subida falló. Toque reintentar para intentar de nuevo.",
|
||||
"retryUpload": "Reintentar subida",
|
||||
"clearAll": "Limpiar Todo"
|
||||
"clearAll": "Limpiar Todo",
|
||||
"errorDetails": "Detalles del Error",
|
||||
"uploading": " (Subiendo...)",
|
||||
"closingChangeset": " (Cerrando changeset...)"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Proveedores de Tiles",
|
||||
@@ -409,15 +412,16 @@
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
|
||||
+15
-11
@@ -225,7 +225,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: {}",
|
||||
@@ -235,7 +235,10 @@
|
||||
"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",
|
||||
"uploading": " (Téléchargement...)",
|
||||
"closingChangeset": " (Fermeture du changeset...)"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Fournisseurs de Tuiles",
|
||||
@@ -409,15 +412,16 @@
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
|
||||
+15
-11
@@ -225,7 +225,7 @@
|
||||
"queueCleared": "Coda pulita",
|
||||
"uploadQueueTitle": "Coda Upload ({} elementi)",
|
||||
"queueIsEmpty": "La coda è vuota",
|
||||
"cameraWithIndex": "Telecamera {}",
|
||||
"itemWithIndex": "Elemento {}",
|
||||
"error": " (Errore)",
|
||||
"completing": " (Completamento...)",
|
||||
"destination": "Dest: {}",
|
||||
@@ -235,7 +235,10 @@
|
||||
"attempts": "Tentativi: {}",
|
||||
"uploadFailedRetry": "Upload fallito. Tocca riprova per tentare di nuovo.",
|
||||
"retryUpload": "Riprova upload",
|
||||
"clearAll": "Pulisci Tutto"
|
||||
"clearAll": "Pulisci Tutto",
|
||||
"errorDetails": "Dettagli dell'Errore",
|
||||
"uploading": " (Caricamento...)",
|
||||
"closingChangeset": " (Chiusura changeset...)"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Fornitori di Tile",
|
||||
@@ -409,15 +412,16 @@
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
|
||||
+15
-11
@@ -225,7 +225,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: {}",
|
||||
@@ -235,7 +235,10 @@
|
||||
"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",
|
||||
"uploading": " (Enviando...)",
|
||||
"closingChangeset": " (Fechando changeset...)"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Provedores de Tiles",
|
||||
@@ -409,15 +412,16 @@
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
|
||||
+15
-11
@@ -225,7 +225,7 @@
|
||||
"queueCleared": "队列已清空",
|
||||
"uploadQueueTitle": "上传队列({} 项)",
|
||||
"queueIsEmpty": "队列为空",
|
||||
"cameraWithIndex": "摄像头 {}",
|
||||
"itemWithIndex": "项目 {}",
|
||||
"error": "(错误)",
|
||||
"completing": "(完成中...)",
|
||||
"destination": "目标:{}",
|
||||
@@ -235,7 +235,10 @@
|
||||
"attempts": "尝试次数:{}",
|
||||
"uploadFailedRetry": "上传失败。点击重试再次尝试。",
|
||||
"retryUpload": "重试上传",
|
||||
"clearAll": "全部清空"
|
||||
"clearAll": "全部清空",
|
||||
"errorDetails": "错误详情",
|
||||
"uploading": " (上传中...)",
|
||||
"closingChangeset": " (关闭变更集...)"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "瓦片提供商",
|
||||
@@ -409,15 +412,16 @@
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "显示网络状态指示器",
|
||||
"showIndicatorSubtitle": "在地图上显示网络加载和错误状态",
|
||||
"loading": "加载中...",
|
||||
"timedOut": "超时",
|
||||
"noData": "这里没有瓦片",
|
||||
"success": "完成",
|
||||
"nodeLimitReached": "显示限制 - 在设置中增加",
|
||||
"tileProviderSlow": "瓦片提供商缓慢",
|
||||
"nodeDataSlow": "节点数据缓慢",
|
||||
"networkIssues": "网络问题"
|
||||
"showIndicatorSubtitle": "显示监控数据加载和错误状态",
|
||||
"loading": "加载监控数据...",
|
||||
"timedOut": "请求超时",
|
||||
"noData": "无离线数据",
|
||||
"success": "监控数据已加载",
|
||||
"nodeDataSlow": "监控数据缓慢"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "显示 {rendered} / {total} 设备",
|
||||
"editingDisabledMessage": "可见设备过多,无法安全编辑。请放大地图以减少可见设备数量,然后重试。"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "搜索位置",
|
||||
|
||||
@@ -52,7 +52,6 @@ class NodeProfile {
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
fov: 45.0, // Flock cameras typically have narrow FOV
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-motorola',
|
||||
@@ -70,7 +69,6 @@ class NodeProfile {
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
fov: 60.0, // Motorola cameras typically have moderate FOV
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-genetec',
|
||||
@@ -88,7 +86,6 @@ class NodeProfile {
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
fov: 50.0, // Genetec cameras typically have moderate FOV
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-leonardo',
|
||||
@@ -106,7 +103,6 @@ class NodeProfile {
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
fov: 55.0, // Leonardo cameras typically have moderate FOV
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-neology',
|
||||
@@ -156,7 +152,6 @@ class NodeProfile {
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
fov: 90.0, // Axis cameras can have wider FOV
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-generic-gunshot',
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
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
|
||||
@@ -15,8 +26,16 @@ class PendingUpload {
|
||||
final int? originalNodeId; // If this is modify/delete, the ID of the original OSM node
|
||||
int? submittedNodeId; // The actual node ID returned by OSM after successful submission
|
||||
int attempts;
|
||||
bool error;
|
||||
bool completing; // True when upload succeeded but item is showing checkmark briefly
|
||||
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,
|
||||
@@ -29,7 +48,15 @@ class PendingUpload {
|
||||
this.submittedNodeId,
|
||||
this.attempts = 0,
|
||||
this.error = false,
|
||||
this.errorMessage,
|
||||
this.completing = false,
|
||||
this.uploadState = UploadState.pending,
|
||||
this.changesetId,
|
||||
this.nodeOperationCompletedAt,
|
||||
this.changesetCloseAttempts = 0,
|
||||
this.lastChangesetCloseAttemptAt,
|
||||
this.nodeSubmissionAttempts = 0,
|
||||
this.lastNodeSubmissionAttemptAt,
|
||||
}) : assert(
|
||||
(operation == UploadOperation.create && originalNodeId == null) ||
|
||||
(operation == UploadOperation.create) || (originalNodeId != null),
|
||||
@@ -48,6 +75,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,6 +135,88 @@ class PendingUpload {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 and operator profile
|
||||
Map<String, String> getCombinedTags() {
|
||||
// Deletions don't need tags
|
||||
@@ -101,7 +257,15 @@ class PendingUpload {
|
||||
'submittedNodeId': submittedNodeId,
|
||||
'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(
|
||||
@@ -123,7 +287,33 @@ class PendingUpload {
|
||||
submittedNodeId: j['submittedNodeId'],
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ 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';
|
||||
@@ -49,6 +48,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
// 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;
|
||||
|
||||
@@ -113,6 +115,22 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
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;
|
||||
@@ -546,6 +564,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
},
|
||||
child: NodeTagSheet(
|
||||
node: node,
|
||||
isNodeLimitActive: _isNodeLimitActive,
|
||||
onEditPressed: () {
|
||||
// Check minimum zoom level before starting edit session
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
@@ -666,13 +685,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
? _navigationSheetHeight
|
||||
: _tagSheetHeight));
|
||||
|
||||
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
|
||||
@@ -717,6 +732,11 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
onNodeTap: openNodeTagSheet,
|
||||
onSuspectedLocationTap: openSuspectedLocationSheet,
|
||||
onSearchPressed: _onNavigationButtonPressed,
|
||||
onNodeLimitChanged: (isLimited) {
|
||||
setState(() {
|
||||
_isNodeLimitActive = isLimited;
|
||||
});
|
||||
},
|
||||
onUserGesture: () {
|
||||
if (appState.followMeMode != FollowMeMode.off) {
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
@@ -828,7 +848,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
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 +41,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:
|
||||
@@ -130,16 +185,23 @@ 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' +
|
||||
@@ -151,12 +213,12 @@ class UploadQueueScreen extends StatelessWidget {
|
||||
: upload.direction.round().toString()
|
||||
]) + '\n' +
|
||||
locService.t('queue.attempts', params: [upload.attempts.toString()]) +
|
||||
(upload.error ? "\n${locService.t('queue.uploadFailedRetry')}" : "")
|
||||
(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 +227,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(
|
||||
|
||||
@@ -203,6 +203,10 @@ class ChangelogService {
|
||||
versionsNeedingMigration.add('1.3.1');
|
||||
}
|
||||
|
||||
if (needsMigration(lastSeenVersion, currentVersion, '1.5.3')) {
|
||||
versionsNeedingMigration.add('1.5.3');
|
||||
}
|
||||
|
||||
// Future versions can be added here
|
||||
// if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) {
|
||||
// versionsNeedingMigration.add('2.0.0');
|
||||
@@ -268,6 +272,12 @@ class ChangelogService {
|
||||
debugPrint('[ChangelogService] 1.3.1 migration completed: enabled network status indicator');
|
||||
break;
|
||||
|
||||
case '1.5.3':
|
||||
// Migrate upload queue to new two-stage changeset system
|
||||
await appState.migrateUploadQueueToTwoStageSystem();
|
||||
debugPrint('[ChangelogService] 1.5.3 migration completed: migrated upload queue to two-stage system');
|
||||
break;
|
||||
|
||||
// Future migrations can be added here
|
||||
// case '2.0.0':
|
||||
// await appState.doSomethingNew();
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/tile_provider.dart' as models;
|
||||
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
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ class MapDataProvider {
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: AppState.instance.maxCameras,
|
||||
maxResults: 0, // No limit - fetch all available data
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ class MapDataProvider {
|
||||
return fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxNodes: AppState.instance.maxCameras,
|
||||
maxNodes: 0, // No limit - get all available data
|
||||
);
|
||||
}
|
||||
} else if (uploadMode == UploadMode.sandbox) {
|
||||
@@ -86,7 +86,7 @@ class MapDataProvider {
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: AppState.instance.maxCameras,
|
||||
maxResults: 0, // No limit - fetch all available data
|
||||
);
|
||||
} else {
|
||||
// Production mode: use pre-fetch service for efficient area loading
|
||||
@@ -116,13 +116,9 @@ class MapDataProvider {
|
||||
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 all local nodes without any rendering limit
|
||||
// Rendering limits are applied at the UI layer
|
||||
return localNodes;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,45 @@ Future<List<OsmNode>> fetchOsmApiNodes({
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
// Check if this is a user-initiated fetch (indicated by loading state)
|
||||
final wasUserInitiated = NetworkStatus.instance.currentStatus == NetworkStatusType.waiting;
|
||||
|
||||
try {
|
||||
final nodes = await _fetchFromOsmApi(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults,
|
||||
);
|
||||
|
||||
// Only report success at the top level if this was user-initiated
|
||||
if (wasUserInitiated) {
|
||||
NetworkStatus.instance.setSuccess();
|
||||
}
|
||||
|
||||
return nodes;
|
||||
} catch (e) {
|
||||
// Only report errors at the top level if this was user-initiated
|
||||
if (wasUserInitiated) {
|
||||
if (e.toString().contains('timeout') || e.toString().contains('timed out')) {
|
||||
NetworkStatus.instance.setTimeoutError();
|
||||
} else {
|
||||
NetworkStatus.instance.setNetworkError();
|
||||
}
|
||||
}
|
||||
|
||||
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 +80,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 +91,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
|
||||
throw e; // Re-throw to let caller handle
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,14 +23,35 @@ Future<List<OsmNode>> fetchOverpassNodes({
|
||||
// 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,
|
||||
);
|
||||
try {
|
||||
final nodes = await _fetchOverpassNodesWithSplitting(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults,
|
||||
splitDepth: 0,
|
||||
reportStatus: wasUserInitiated, // Only top level reports status
|
||||
);
|
||||
|
||||
// Only report success at the top level if this was user-initiated
|
||||
if (wasUserInitiated) {
|
||||
NetworkStatus.instance.setSuccess();
|
||||
}
|
||||
|
||||
return nodes;
|
||||
} catch (e) {
|
||||
// Only report errors at the top level if this was user-initiated
|
||||
if (wasUserInitiated) {
|
||||
if (e.toString().contains('timeout') || e.toString().contains('timed out')) {
|
||||
NetworkStatus.instance.setTimeoutError();
|
||||
} else {
|
||||
NetworkStatus.instance.setNetworkError();
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('[fetchOverpassNodes] Top-level operation failed: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal method that handles splitting when node limit is exceeded.
|
||||
@@ -40,7 +61,7 @@ Future<List<OsmNode>> _fetchOverpassNodesWithSplitting({
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
required int maxResults,
|
||||
required int splitDepth,
|
||||
required bool wasUserInitiated,
|
||||
required bool reportStatus, // Only true for top level
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
@@ -51,28 +72,20 @@ Future<List<OsmNode>> _fetchOverpassNodesWithSplitting({
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxResults: maxResults,
|
||||
reportStatus: reportStatus,
|
||||
);
|
||||
} 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
|
||||
return []; // Return empty rather than rethrowing - let caller handle error reporting
|
||||
} 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 [];
|
||||
return []; // Return empty - let caller handle error reporting
|
||||
}
|
||||
|
||||
// Split the bounds into 4 quadrants and try each separately
|
||||
@@ -87,7 +100,7 @@ Future<List<OsmNode>> _fetchOverpassNodesWithSplitting({
|
||||
uploadMode: uploadMode,
|
||||
maxResults: 0, // No limit on individual quadrants to avoid double-limiting
|
||||
splitDepth: splitDepth + 1,
|
||||
wasUserInitiated: wasUserInitiated,
|
||||
reportStatus: false, // Sub-requests don't report status
|
||||
);
|
||||
allNodes.addAll(nodes);
|
||||
}
|
||||
@@ -102,6 +115,7 @@ Future<List<OsmNode>> _fetchSingleOverpassQuery({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
required int maxResults,
|
||||
required bool reportStatus,
|
||||
}) async {
|
||||
const String overpassEndpoint = 'https://overpass-api.de/api/interpreter';
|
||||
|
||||
@@ -146,8 +160,8 @@ Future<List<OsmNode>> _fetchSingleOverpassQuery({
|
||||
throw OverpassRateLimitException('Rate limited by server', serverResponse: errorBody);
|
||||
}
|
||||
|
||||
NetworkStatus.instance.reportOverpassIssue();
|
||||
return [];
|
||||
// Don't report status here - let the top level handle it
|
||||
throw Exception('Overpass API error: $errorBody');
|
||||
}
|
||||
|
||||
final data = await compute(jsonDecode, response.body) as Map<String, dynamic>;
|
||||
@@ -157,7 +171,7 @@ Future<List<OsmNode>> _fetchSingleOverpassQuery({
|
||||
debugPrint('[fetchOverpassNodes] Retrieved ${elements.length} elements (nodes + ways/relations)');
|
||||
}
|
||||
|
||||
NetworkStatus.instance.reportOverpassSuccess();
|
||||
// Don't report success here - let the top level handle it
|
||||
|
||||
// Parse response to determine which nodes are constrained
|
||||
final nodes = _parseOverpassResponseWithConstraints(elements);
|
||||
@@ -173,14 +187,8 @@ Future<List<OsmNode>> _fetchSingleOverpassQuery({
|
||||
|
||||
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 [];
|
||||
// Don't report status here - let the top level handle it
|
||||
throw e; // Re-throw to let caller handle
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ 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(kTileFetchConcurrentThreads);
|
||||
@@ -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();
|
||||
|
||||
@@ -121,21 +120,12 @@ Future<List<int>> fetchRemoteTile({
|
||||
if (attempt > 1) {
|
||||
debugPrint('[fetchRemoteTile] SUCCESS $z/$x/$y from $hostInfo after $attempt attempts');
|
||||
}
|
||||
NetworkStatus.instance.reportOsmTileSuccess();
|
||||
return resp.bodyBytes;
|
||||
} else {
|
||||
debugPrint('[fetchRemoteTile] FAIL $z/$x/$y from $hostInfo: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
|
||||
NetworkStatus.instance.reportOsmTileIssue();
|
||||
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();
|
||||
}
|
||||
|
||||
// Calculate delay and retry (no attempt limit - keep trying forever)
|
||||
final delay = _calculateRetryDelay(attempt, random);
|
||||
if (attempt == 1) {
|
||||
@@ -146,7 +136,7 @@ Future<List<int>> fetchRemoteTile({
|
||||
}
|
||||
await Future.delayed(Duration(milliseconds: delay));
|
||||
} finally {
|
||||
_tileFetchSemaphore.release();
|
||||
_tileFetchSemaphore.release(z: z, x: x, y: y);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,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,
|
||||
@@ -198,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();
|
||||
@@ -211,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;
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import 'dart:async';
|
||||
|
||||
import '../app_state.dart';
|
||||
|
||||
enum NetworkIssueType { osmTiles, overpassApi, both }
|
||||
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success, nodeLimitReached }
|
||||
enum NetworkIssueType { overpassApi }
|
||||
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success }
|
||||
|
||||
|
||||
|
||||
@@ -12,30 +12,23 @@ 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 hasAnyIssues => _overpassHasIssues;
|
||||
bool get overpassHasIssues => _overpassHasIssues;
|
||||
bool get isWaitingForData => _isWaitingForData;
|
||||
bool get isTimedOut => _isTimedOut;
|
||||
bool get hasNoData => _hasNoData;
|
||||
bool get hasSuccess => _hasSuccess;
|
||||
bool get nodeLimitReached => _nodeLimitReached;
|
||||
|
||||
NetworkStatusType get currentStatus {
|
||||
// Simple single-path status logic
|
||||
@@ -44,34 +37,14 @@ class NetworkStatus extends ChangeNotifier {
|
||||
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');
|
||||
}
|
||||
|
||||
// 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');
|
||||
});
|
||||
}
|
||||
|
||||
/// Report Overpass API issues
|
||||
void reportOverpassIssue() {
|
||||
if (!_overpassHasIssues) {
|
||||
@@ -90,16 +63,6 @@ class NetworkStatus extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -176,17 +139,15 @@ class NetworkStatus extends ChangeNotifier {
|
||||
|
||||
/// Clear waiting/timeout/no-data status (legacy method for compatibility)
|
||||
void clearWaiting() {
|
||||
if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess || _nodeLimitReached) {
|
||||
if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess) {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_hasSuccess = false;
|
||||
_nodeLimitReached = false;
|
||||
_recentOfflineMisses = 0;
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
_nodeLimitResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -227,22 +188,6 @@ class NetworkStatus extends ChangeNotifier {
|
||||
debugPrint('[NetworkStatus] Network error occurred');
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Report that a tile was not available offline
|
||||
@@ -271,12 +216,10 @@ class NetworkStatus extends ChangeNotifier {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_osmRecoveryTimer?.cancel();
|
||||
_overpassRecoveryTimer?.cancel();
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
_nodeLimitResetTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,22 @@ 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) {
|
||||
|
||||
@@ -29,6 +29,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();
|
||||
|
||||
@@ -139,20 +139,15 @@ class PrefetchAreaService {
|
||||
_preFetchedUploadMode = uploadMode;
|
||||
_lastFetchTime = DateTime.now();
|
||||
|
||||
// Report completion to network status (only if user was waiting)
|
||||
NetworkStatus.instance.setSuccess();
|
||||
// The overpass module already reported success/failure during fetching
|
||||
// We just need to handle the successful result here
|
||||
|
||||
// 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();
|
||||
}
|
||||
// The overpass module already reported the error status
|
||||
// Don't update pre-fetched area info on failure
|
||||
} finally {
|
||||
_preFetchInProgress = false;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+208
-60
@@ -1,29 +1,60 @@
|
||||
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
|
||||
// Generate changeset XML
|
||||
String action;
|
||||
switch (p.operation) {
|
||||
case UploadOperation.create:
|
||||
@@ -39,7 +70,7 @@ class Uploader {
|
||||
action = 'Extract';
|
||||
break;
|
||||
}
|
||||
// Generate appropriate comment based on operation type
|
||||
|
||||
final profileName = p.profile?.name ?? 'surveillance';
|
||||
final csXml = '''
|
||||
<osm>
|
||||
@@ -48,17 +79,38 @@ class Uploader {
|
||||
<tag k="comment" v="$action $profileName surveillance node"/>
|
||||
</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 +122,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 +159,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,25 +347,25 @@ class Uploader {
|
||||
Future<http.Response> _get(String path) => http.get(
|
||||
Uri.https(_host, path),
|
||||
headers: _headers,
|
||||
);
|
||||
).timeout(kUploadHttpTimeout);
|
||||
|
||||
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',
|
||||
@@ -228,5 +377,4 @@ extension StringExtension on String {
|
||||
String capitalize() {
|
||||
return "${this[0].toUpperCase()}${substring(1)}";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -172,19 +172,33 @@ class UploadQueueState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
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
|
||||
CameraProviderWithCache.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
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void retryUpload(PendingUpload upload) {
|
||||
upload.error = false;
|
||||
upload.clearError();
|
||||
upload.attempts = 0;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
@@ -208,53 +222,283 @@ class UploadQueueState extends ChangeNotifier {
|
||||
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;
|
||||
// Find next item to process based on state
|
||||
final pendingItems = _queue.where((pu) => pu.uploadState == UploadState.pending).toList();
|
||||
final creatingChangesetItems = _queue.where((pu) => pu.uploadState == UploadState.creatingChangeset).toList();
|
||||
final uploadingItems = _queue.where((pu) => pu.uploadState == UploadState.uploading).toList();
|
||||
final closingItems = _queue.where((pu) => pu.uploadState == UploadState.closingChangeset).toList();
|
||||
|
||||
// Process any expired items
|
||||
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!);
|
||||
// Continue processing loop - don't return here
|
||||
}
|
||||
}
|
||||
|
||||
// Find next item to process (process in stage order)
|
||||
PendingUpload? item;
|
||||
if (pendingItems.isNotEmpty) {
|
||||
item = pendingItems.first;
|
||||
} else if (creatingChangesetItems.isNotEmpty) {
|
||||
// Already in progress, skip
|
||||
return;
|
||||
} else if (uploadingItems.isNotEmpty) {
|
||||
// Check if any uploading items are ready for retry
|
||||
final readyToRetry = uploadingItems.where((ui) =>
|
||||
!ui.hasChangesetExpired && ui.isReadyForNodeSubmissionRetry
|
||||
).toList();
|
||||
|
||||
if (readyToRetry.isNotEmpty) {
|
||||
item = readyToRetry.first;
|
||||
}
|
||||
} else {
|
||||
// No active items, check if any changeset close items are ready for retry
|
||||
final readyToRetry = closingItems.where((ci) =>
|
||||
!ci.hasChangesetExpired && ci.isReadyForChangesetCloseRetry
|
||||
).toList();
|
||||
|
||||
if (readyToRetry.isNotEmpty) {
|
||||
item = readyToRetry.first;
|
||||
}
|
||||
}
|
||||
|
||||
if (item == null) {
|
||||
// No items ready for processing - check if queue is effectively empty
|
||||
final hasActiveItems = _queue.any((pu) =>
|
||||
pu.uploadState == UploadState.pending ||
|
||||
pu.uploadState == UploadState.creatingChangeset ||
|
||||
(pu.uploadState == UploadState.uploading && !pu.hasChangesetExpired) ||
|
||||
(pu.uploadState == UploadState.closingChangeset && !pu.hasChangesetExpired)
|
||||
);
|
||||
|
||||
if (!hasActiveItems) {
|
||||
debugPrint('[UploadQueue] No active items remaining, stopping uploader');
|
||||
_uploadTimer?.cancel();
|
||||
}
|
||||
return; // Nothing to process right now
|
||||
}
|
||||
|
||||
// Retrieve access after every tick (accounts for re-login)
|
||||
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));
|
||||
}
|
||||
debugPrint('[UploadQueue] Processing item in state: ${item.uploadState} with uploadMode: ${item.uploadMode}');
|
||||
|
||||
if (item.uploadState == UploadState.pending) {
|
||||
await _processCreateChangeset(item, access);
|
||||
} else if (item.uploadState == UploadState.creatingChangeset) {
|
||||
// Already in progress, skip (shouldn't happen due to filtering above)
|
||||
debugPrint('[UploadQueue] Changeset creation already in progress, skipping');
|
||||
return;
|
||||
} else if (item.uploadState == UploadState.uploading) {
|
||||
await _processNodeOperation(item, access);
|
||||
} else if (item.uploadState == UploadState.closingChangeset) {
|
||||
await _processChangesetClose(item, access);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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 simulated node ID and move to changeset close phase
|
||||
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() {
|
||||
_uploadTimer?.cancel();
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -357,6 +601,28 @@ class UploadQueueState extends ChangeNotifier {
|
||||
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.instance.removePendingDeletionMarker(upload.originalNodeId!);
|
||||
}
|
||||
} else if (upload.isEdit) {
|
||||
// For edits: remove both the temp node and the _pending_edit marker from original
|
||||
NodeCache.instance.removeTempNodesByCoordinate(upload.coord);
|
||||
if (upload.originalNodeId != null) {
|
||||
NodeCache.instance.removePendingEditMarker(upload.originalNodeId!);
|
||||
}
|
||||
} else if (upload.operation == UploadOperation.extract) {
|
||||
// For extracts: remove the temp node (leave original unchanged)
|
||||
NodeCache.instance.removeTempNodesByCoordinate(upload.coord);
|
||||
} else {
|
||||
// For creates: remove the temp node
|
||||
NodeCache.instance.removeTempNodesByCoordinate(upload.coord);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Queue persistence ----------
|
||||
Future<void> _saveQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -374,6 +640,12 @@ class UploadQueueState extends ChangeNotifier {
|
||||
..addAll(list.map((e) => PendingUpload.fromJson(e)));
|
||||
}
|
||||
|
||||
// Public method for migration purposes
|
||||
Future<void> reloadQueue() async {
|
||||
await _loadQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_uploadTimer?.cancel();
|
||||
|
||||
@@ -20,7 +20,7 @@ class CameraProviderWithCache extends ChangeNotifier {
|
||||
Timer? _debounceTimer;
|
||||
|
||||
/// Call this to get (quickly) all cached overlays for the given view.
|
||||
/// Filters by currently enabled profiles.
|
||||
/// Filters by currently enabled profiles only. Limiting is handled by MapView.
|
||||
List<OsmNode> getCachedNodesForBounds(LatLngBounds bounds) {
|
||||
final allNodes = NodeCache.instance.queryByBounds(bounds);
|
||||
final enabledProfiles = AppState.instance.enabledProfiles;
|
||||
|
||||
@@ -3,12 +3,12 @@ import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../models/tile_provider.dart' as models;
|
||||
import '../../services/simple_tile_service.dart';
|
||||
import '../../services/deflock_tile_provider.dart';
|
||||
|
||||
/// Manages tile layer creation, caching, and provider switching.
|
||||
/// Handles tile HTTP client lifecycle and cache invalidation.
|
||||
/// Uses DeFlock's custom tile provider for clean integration.
|
||||
class TileLayerManager {
|
||||
late final SimpleTileHttpClient _tileHttpClient;
|
||||
DeflockTileProvider? _tileProvider;
|
||||
int _mapRebuildKey = 0;
|
||||
String? _lastTileTypeId;
|
||||
bool? _lastOfflineMode;
|
||||
@@ -18,12 +18,12 @@ class TileLayerManager {
|
||||
|
||||
/// Initialize the tile layer manager
|
||||
void initialize() {
|
||||
_tileHttpClient = SimpleTileHttpClient();
|
||||
// Don't create tile provider here - create it fresh for each build
|
||||
}
|
||||
|
||||
/// Dispose of resources
|
||||
void dispose() {
|
||||
_tileHttpClient.close();
|
||||
// No resources to dispose with the new tile provider
|
||||
}
|
||||
|
||||
/// Check if cache should be cleared and increment rebuild key if needed.
|
||||
@@ -46,6 +46,8 @@ class TileLayerManager {
|
||||
if (shouldClear) {
|
||||
// Force map rebuild with new key to bust flutter_map cache
|
||||
_mapRebuildKey++;
|
||||
// Also force new tile provider instance to ensure fresh cache
|
||||
_tileProvider = null;
|
||||
debugPrint('[TileLayerManager] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey');
|
||||
}
|
||||
|
||||
@@ -57,38 +59,42 @@ class TileLayerManager {
|
||||
|
||||
/// Clear the tile request queue (call after cache clear)
|
||||
void clearTileQueue() {
|
||||
debugPrint('[TileLayerManager] Post-frame: Clearing tile request queue');
|
||||
_tileHttpClient.clearTileQueue();
|
||||
// With the new tile provider, clearing is handled by FlutterMap's internal cache
|
||||
// We just need to increment the rebuild key to bust the cache
|
||||
_mapRebuildKey++;
|
||||
debugPrint('[TileLayerManager] Cache cleared - rebuilding map $_mapRebuildKey');
|
||||
}
|
||||
|
||||
/// Clear tile queue immediately (for zoom changes, etc.)
|
||||
void clearTileQueueImmediate() {
|
||||
_tileHttpClient.clearTileQueue();
|
||||
// No immediate clearing needed with the new architecture
|
||||
// FlutterMap handles this naturally
|
||||
}
|
||||
|
||||
/// Clear only tiles that are no longer visible in the current bounds
|
||||
void clearStaleRequests({required LatLngBounds currentBounds}) {
|
||||
_tileHttpClient.clearStaleRequests(currentBounds);
|
||||
// No selective clearing needed with the new architecture
|
||||
// FlutterMap's internal caching is efficient enough
|
||||
}
|
||||
|
||||
/// Build tile layer widget with current provider and type.
|
||||
/// Uses fake domain that SimpleTileHttpClient can parse for cache separation.
|
||||
/// Uses DeFlock's custom tile provider for clean integration with our offline/online system.
|
||||
Widget buildTileLayer({
|
||||
required models.TileProvider? selectedProvider,
|
||||
required models.TileType? selectedTileType,
|
||||
}) {
|
||||
// Use fake domain with standard HTTPS scheme: https://tiles.local/provider/type/z/x/y
|
||||
// This naturally separates cache entries by provider and type while being HTTP-compatible
|
||||
final urlTemplate = 'https://tiles.local/${selectedProvider?.id ?? 'unknown'}/${selectedTileType?.id ?? 'unknown'}/{z}/{x}/{y}';
|
||||
// Create a fresh tile provider instance if we don't have one or cache was cleared
|
||||
_tileProvider ??= DeflockTileProvider();
|
||||
|
||||
// Use provider/type info in URL template for FlutterMap's cache key generation
|
||||
// This ensures different providers/types get different cache keys
|
||||
final urlTemplate = '${selectedProvider?.id ?? 'unknown'}/${selectedTileType?.id ?? 'unknown'}/{z}/{x}/{y}';
|
||||
|
||||
return TileLayer(
|
||||
urlTemplate: urlTemplate,
|
||||
urlTemplate: urlTemplate, // Critical for cache key generation
|
||||
userAgentPackageName: 'me.deflock.deflockapp',
|
||||
maxZoom: selectedTileType?.maxZoom?.toDouble() ?? 18.0,
|
||||
tileProvider: NetworkTileProvider(
|
||||
httpClient: _tileHttpClient,
|
||||
// Enable flutter_map caching - cache busting handled by URL changes and FlutterMap key
|
||||
),
|
||||
tileProvider: _tileProvider!,
|
||||
);
|
||||
}
|
||||
}
|
||||
+102
-35
@@ -25,6 +25,7 @@ import 'map/camera_refresh_controller.dart';
|
||||
import 'map/gps_controller.dart';
|
||||
import 'map/suspected_location_markers.dart';
|
||||
import 'network_status_indicator.dart';
|
||||
import 'node_limit_indicator.dart';
|
||||
import 'provisional_pin.dart';
|
||||
import 'proximity_alert_banner.dart';
|
||||
import '../dev_config.dart';
|
||||
@@ -44,6 +45,7 @@ class MapView extends StatefulWidget {
|
||||
this.onNodeTap,
|
||||
this.onSuspectedLocationTap,
|
||||
this.onSearchPressed,
|
||||
this.onNodeLimitChanged,
|
||||
});
|
||||
|
||||
final FollowMeMode followMeMode;
|
||||
@@ -53,6 +55,7 @@ class MapView extends StatefulWidget {
|
||||
final void Function(OsmNode)? onNodeTap;
|
||||
final void Function(SuspectedLocation)? onSuspectedLocationTap;
|
||||
final VoidCallback? onSearchPressed;
|
||||
final void Function(bool isLimited)? onNodeLimitChanged;
|
||||
|
||||
@override
|
||||
State<MapView> createState() => MapViewState();
|
||||
@@ -76,7 +79,8 @@ class MapViewState extends State<MapView> {
|
||||
// Track map center to clear queue on significant panning
|
||||
LatLng? _lastCenter;
|
||||
|
||||
|
||||
// Track node limit state for parent notification
|
||||
bool _lastNodeLimitState = false;
|
||||
|
||||
// State for proximity alert banner
|
||||
bool _showProximityBanner = false;
|
||||
@@ -156,7 +160,6 @@ class MapViewState extends State<MapView> {
|
||||
getNearbyNodes: () {
|
||||
if (mounted) {
|
||||
try {
|
||||
final cameraProvider = context.read<CameraProviderWithCache>();
|
||||
LatLngBounds? mapBounds;
|
||||
try {
|
||||
mapBounds = _controller.mapController.camera.visibleBounds;
|
||||
@@ -164,7 +167,7 @@ class MapViewState extends State<MapView> {
|
||||
return [];
|
||||
}
|
||||
return mapBounds != null
|
||||
? cameraProvider.getCachedNodesForBounds(mapBounds)
|
||||
? CameraProviderWithCache.instance.getCachedNodesForBounds(mapBounds)
|
||||
: [];
|
||||
} catch (e) {
|
||||
debugPrint('[MapView] Could not get nearby nodes: $e');
|
||||
@@ -395,38 +398,70 @@ class MapViewState extends State<MapView> {
|
||||
// Edit sessions don't need to center - we're already centered from the node tap
|
||||
// SheetAwareMap handles the visual positioning
|
||||
|
||||
// Fetch cached cameras for current map bounds (using Consumer so overlays redraw instantly)
|
||||
Widget cameraLayers = Consumer<CameraProviderWithCache>(
|
||||
builder: (context, cameraProvider, child) {
|
||||
// Get current zoom level and map bounds (shared by all logic)
|
||||
double currentZoom = 15.0; // fallback
|
||||
LatLngBounds? mapBounds;
|
||||
try {
|
||||
currentZoom = _controller.mapController.camera.zoom;
|
||||
mapBounds = _controller.mapController.camera.visibleBounds;
|
||||
} catch (_) {
|
||||
// Controller not ready yet, use fallback values
|
||||
mapBounds = null;
|
||||
}
|
||||
|
||||
final minZoom = _getMinZoomForNodes(context);
|
||||
List<OsmNode> nodes;
|
||||
|
||||
if (currentZoom >= minZoom) {
|
||||
// Above minimum zoom - get cached nodes
|
||||
nodes = (mapBounds != null)
|
||||
? cameraProvider.getCachedNodesForBounds(mapBounds)
|
||||
: <OsmNode>[];
|
||||
} else {
|
||||
// Below minimum zoom - don't render any nodes
|
||||
nodes = <OsmNode>[];
|
||||
}
|
||||
// Get current zoom level and map bounds (shared by all logic)
|
||||
double currentZoom = 15.0; // fallback
|
||||
LatLngBounds? mapBounds;
|
||||
try {
|
||||
currentZoom = _controller.mapController.camera.zoom;
|
||||
mapBounds = _controller.mapController.camera.visibleBounds;
|
||||
} catch (_) {
|
||||
// Controller not ready yet, use fallback values
|
||||
mapBounds = null;
|
||||
}
|
||||
|
||||
final minZoom = _getMinZoomForNodes(context);
|
||||
List<OsmNode> allNodes;
|
||||
List<OsmNode> nodesToRender;
|
||||
bool isLimitActive = false;
|
||||
|
||||
if (currentZoom >= minZoom) {
|
||||
// Above minimum zoom - get cached nodes directly (no Provider needed)
|
||||
allNodes = (mapBounds != null)
|
||||
? CameraProviderWithCache.instance.getCachedNodesForBounds(mapBounds)
|
||||
: <OsmNode>[];
|
||||
|
||||
// Filter out invalid coordinates before applying limit
|
||||
final validNodes = allNodes.where((node) {
|
||||
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||
node.coord.latitude.abs() <= 90 &&
|
||||
node.coord.longitude.abs() <= 180;
|
||||
}).toList();
|
||||
|
||||
// Apply rendering limit to prevent UI lag
|
||||
final maxNodes = appState.maxCameras;
|
||||
if (validNodes.length > maxNodes) {
|
||||
nodesToRender = validNodes.take(maxNodes).toList();
|
||||
isLimitActive = true;
|
||||
debugPrint('[MapView] Node limit active: rendering ${nodesToRender.length} of ${validNodes.length} devices');
|
||||
} else {
|
||||
nodesToRender = validNodes;
|
||||
isLimitActive = false;
|
||||
}
|
||||
} else {
|
||||
// Below minimum zoom - don't render any nodes
|
||||
allNodes = <OsmNode>[];
|
||||
nodesToRender = <OsmNode>[];
|
||||
isLimitActive = false;
|
||||
}
|
||||
|
||||
// Notify parent if limit state changed (for button disabling)
|
||||
if (isLimitActive != _lastNodeLimitState) {
|
||||
_lastNodeLimitState = isLimitActive;
|
||||
// Schedule callback after build completes to avoid setState during build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.onNodeLimitChanged?.call(isLimitActive);
|
||||
});
|
||||
}
|
||||
|
||||
// Build camera layers using the limited nodes
|
||||
Widget cameraLayers = LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
|
||||
// Determine if we should dim node markers (when suspected location is selected)
|
||||
final shouldDimNodes = appState.selectedSuspectedLocation != null;
|
||||
|
||||
final markers = CameraMarkersBuilder.buildCameraMarkers(
|
||||
cameras: nodes,
|
||||
cameras: nodesToRender,
|
||||
mapController: _controller.mapController,
|
||||
userLocation: _gpsController.currentLocation,
|
||||
selectedNodeId: widget.selectedNodeId,
|
||||
@@ -451,7 +486,7 @@ class MapViewState extends State<MapView> {
|
||||
// Filter out suspected locations that are too close to real nodes
|
||||
final filteredSuspectedLocations = _filterSuspectedLocationsByProximity(
|
||||
suspectedLocations: limitedSuspectedLocations,
|
||||
realNodes: nodes,
|
||||
realNodes: nodesToRender,
|
||||
minDistance: appState.suspectedLocationMinDistance,
|
||||
);
|
||||
|
||||
@@ -473,7 +508,7 @@ class MapViewState extends State<MapView> {
|
||||
}
|
||||
|
||||
final overlays = DirectionConesBuilder.buildDirectionCones(
|
||||
cameras: nodes,
|
||||
cameras: nodesToRender,
|
||||
zoom: currentZoom,
|
||||
session: session,
|
||||
editSession: editSession,
|
||||
@@ -496,7 +531,7 @@ class MapViewState extends State<MapView> {
|
||||
}
|
||||
|
||||
// Build edit lines connecting original nodes to their edited positions
|
||||
final editLines = _buildEditLines(nodes);
|
||||
final editLines = _buildEditLines(nodesToRender);
|
||||
|
||||
// Build center marker for add/edit sessions
|
||||
final centerMarkers = <Marker>[];
|
||||
@@ -573,9 +608,30 @@ class MapViewState extends State<MapView> {
|
||||
if (editLines.isNotEmpty) PolylineLayer(polylines: editLines),
|
||||
if (routeLines.isNotEmpty) PolylineLayer(polylines: routeLines),
|
||||
MarkerLayer(markers: [...suspectedLocationMarkers, ...markers, ...centerMarkers]),
|
||||
|
||||
// Node limit indicator (top-left) - shown when limit is active
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final appState = context.read<AppState>();
|
||||
// Add search bar offset when search bar is visible
|
||||
final searchBarOffset = (!appState.offlineMode && appState.isInSearchMode) ? 60.0 : 0.0;
|
||||
|
||||
return NodeLimitIndicator(
|
||||
isActive: isLimitActive,
|
||||
renderedCount: nodesToRender.length,
|
||||
totalCount: isLimitActive ? allNodes.where((node) {
|
||||
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||
node.coord.latitude.abs() <= 90 &&
|
||||
node.coord.longitude.abs() <= 180;
|
||||
}).length : 0,
|
||||
top: 8.0 + searchBarOffset,
|
||||
left: 8.0,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return Stack(
|
||||
@@ -728,7 +784,18 @@ class MapViewState extends State<MapView> {
|
||||
|
||||
// Network status indicator (top-left) - conditionally shown
|
||||
if (appState.networkStatusIndicatorEnabled)
|
||||
const NetworkStatusIndicator(),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
// Calculate position based on node limit indicator presence and search bar
|
||||
final searchBarOffset = (!appState.offlineMode && appState.isInSearchMode) ? 60.0 : 0.0;
|
||||
final nodeLimitOffset = isLimitActive ? 48.0 : 0.0; // Height of node limit indicator + spacing
|
||||
|
||||
return NetworkStatusIndicator(
|
||||
top: 8.0 + searchBarOffset + nodeLimitOffset,
|
||||
left: 8.0,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Proximity alert banner (top)
|
||||
ProximityAlertBanner(
|
||||
|
||||
@@ -4,7 +4,14 @@ import '../services/network_status.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class NetworkStatusIndicator extends StatelessWidget {
|
||||
const NetworkStatusIndicator({super.key});
|
||||
final double top;
|
||||
final double left;
|
||||
|
||||
const NetworkStatusIndicator({
|
||||
super.key,
|
||||
this.top = 56.0,
|
||||
this.left = 8.0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -44,29 +51,13 @@ class NetworkStatusIndicator extends StatelessWidget {
|
||||
color = Colors.green;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.nodeLimitReached:
|
||||
message = locService.t('networkStatus.nodeLimitReached');
|
||||
icon = Icons.visibility_off;
|
||||
color = Colors.amber;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.issues:
|
||||
switch (networkStatus.currentIssueType) {
|
||||
case NetworkIssueType.osmTiles:
|
||||
message = locService.t('networkStatus.tileProviderSlow');
|
||||
icon = Icons.map_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.overpassApi:
|
||||
message = locService.t('networkStatus.nodeDataSlow');
|
||||
icon = Icons.camera_alt_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.both:
|
||||
message = locService.t('networkStatus.networkIssues');
|
||||
icon = Icons.cloud_off_outlined;
|
||||
color = Colors.red;
|
||||
break;
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
@@ -77,8 +68,8 @@ class NetworkStatusIndicator extends StatelessWidget {
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
top: 8, // Position relative to the map area (not the screen)
|
||||
left: 8,
|
||||
top: top, // Position dynamically based on other indicators
|
||||
left: left,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class NodeLimitIndicator extends StatelessWidget {
|
||||
final bool isActive;
|
||||
final int renderedCount;
|
||||
final int totalCount;
|
||||
final double top;
|
||||
final double left;
|
||||
|
||||
const NodeLimitIndicator({
|
||||
super.key,
|
||||
required this.isActive,
|
||||
required this.renderedCount,
|
||||
required this.totalCount,
|
||||
this.top = 8.0,
|
||||
this.left = 8.0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!isActive) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final locService = LocalizationService.instance;
|
||||
final message = locService.t('nodeLimitIndicator.message')
|
||||
.replaceAll('{rendered}', renderedCount.toString())
|
||||
.replaceAll('{total}', totalCount.toString());
|
||||
|
||||
return Positioned(
|
||||
top: top, // Position at top-left of map area
|
||||
left: left,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.amber, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.visibility_off,
|
||||
size: 16,
|
||||
color: Colors.amber,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
color: Colors.amber,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,14 @@ import 'advanced_edit_options_sheet.dart';
|
||||
class NodeTagSheet extends StatelessWidget {
|
||||
final OsmNode node;
|
||||
final VoidCallback? onEditPressed;
|
||||
final bool isNodeLimitActive;
|
||||
|
||||
const NodeTagSheet({super.key, required this.node, this.onEditPressed});
|
||||
const NodeTagSheet({
|
||||
super.key,
|
||||
required this.node,
|
||||
this.onEditPressed,
|
||||
this.isNodeLimitActive = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -31,6 +37,20 @@ class NodeTagSheet extends StatelessWidget {
|
||||
node.tags['_pending_deletion'] != 'true');
|
||||
|
||||
void _openEditSheet() {
|
||||
// Check if node limit is active and warn user
|
||||
if (isNodeLimitActive) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
locService.t('nodeLimitIndicator.editingDisabledMessage')
|
||||
),
|
||||
duration: const Duration(seconds: 4),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (onEditPressed != null) {
|
||||
onEditPressed!(); // Use callback if provided
|
||||
} else {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: deflockapp
|
||||
description: Map public surveillance infrastructure with OpenStreetMap
|
||||
publish_to: "none"
|
||||
version: 1.5.1+19 # The thing after the + is the version code, incremented with each release
|
||||
version: 1.5.3+24 # The thing after the + is the version code, incremented with each release
|
||||
|
||||
environment:
|
||||
sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../../lib/services/deflock_tile_provider.dart';
|
||||
import '../../lib/services/map_data_provider.dart';
|
||||
|
||||
void main() {
|
||||
group('DeflockTileProvider', () {
|
||||
late DeflockTileProvider provider;
|
||||
|
||||
setUp(() {
|
||||
provider = DeflockTileProvider();
|
||||
});
|
||||
|
||||
test('creates image provider for tile coordinates', () {
|
||||
const coordinates = TileCoordinates(0, 0, 0);
|
||||
const options = TileLayer(
|
||||
urlTemplate: 'test/{z}/{x}/{y}',
|
||||
);
|
||||
|
||||
final imageProvider = provider.getImage(coordinates, options);
|
||||
|
||||
expect(imageProvider, isA<DeflockTileImageProvider>());
|
||||
expect((imageProvider as DeflockTileImageProvider).coordinates, equals(coordinates));
|
||||
});
|
||||
});
|
||||
|
||||
group('DeflockTileImageProvider', () {
|
||||
test('generates consistent keys for same coordinates', () {
|
||||
const coordinates1 = TileCoordinates(1, 2, 3);
|
||||
const coordinates2 = TileCoordinates(1, 2, 3);
|
||||
const coordinates3 = TileCoordinates(1, 2, 4);
|
||||
|
||||
const options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
|
||||
final mapDataProvider = MapDataProvider();
|
||||
|
||||
final provider1 = DeflockTileImageProvider(
|
||||
coordinates: coordinates1,
|
||||
options: options,
|
||||
mapDataProvider: mapDataProvider,
|
||||
providerId: 'test_provider',
|
||||
tileTypeId: 'test_type',
|
||||
);
|
||||
final provider2 = DeflockTileImageProvider(
|
||||
coordinates: coordinates2,
|
||||
options: options,
|
||||
mapDataProvider: mapDataProvider,
|
||||
providerId: 'test_provider',
|
||||
tileTypeId: 'test_type',
|
||||
);
|
||||
final provider3 = DeflockTileImageProvider(
|
||||
coordinates: coordinates3,
|
||||
options: options,
|
||||
mapDataProvider: mapDataProvider,
|
||||
providerId: 'test_provider',
|
||||
tileTypeId: 'test_type',
|
||||
);
|
||||
|
||||
// Same coordinates should be equal
|
||||
expect(provider1, equals(provider2));
|
||||
expect(provider1.hashCode, equals(provider2.hashCode));
|
||||
|
||||
// Different coordinates should not be equal
|
||||
expect(provider1, isNot(equals(provider3)));
|
||||
});
|
||||
|
||||
test('generates different keys for different providers/types', () {
|
||||
const coordinates = TileCoordinates(1, 2, 3);
|
||||
const options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final mapDataProvider = MapDataProvider();
|
||||
|
||||
final provider1 = DeflockTileImageProvider(
|
||||
coordinates: coordinates,
|
||||
options: options,
|
||||
mapDataProvider: mapDataProvider,
|
||||
providerId: 'provider_a',
|
||||
tileTypeId: 'type_1',
|
||||
);
|
||||
final provider2 = DeflockTileImageProvider(
|
||||
coordinates: coordinates,
|
||||
options: options,
|
||||
mapDataProvider: mapDataProvider,
|
||||
providerId: 'provider_b',
|
||||
tileTypeId: 'type_1',
|
||||
);
|
||||
final provider3 = DeflockTileImageProvider(
|
||||
coordinates: coordinates,
|
||||
options: options,
|
||||
mapDataProvider: mapDataProvider,
|
||||
providerId: 'provider_a',
|
||||
tileTypeId: 'type_2',
|
||||
);
|
||||
|
||||
// Different providers should not be equal (even with same coordinates)
|
||||
expect(provider1, isNot(equals(provider2)));
|
||||
expect(provider1.hashCode, isNot(equals(provider2.hashCode)));
|
||||
|
||||
// Different tile types should not be equal (even with same coordinates and provider)
|
||||
expect(provider1, isNot(equals(provider3)));
|
||||
expect(provider1.hashCode, isNot(equals(provider3.hashCode)));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user