mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-14 01:33:07 +00:00
Compare commits
39 Commits
1.5.1-rele
...
v1.6.3-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adbe8c340c | ||
|
|
8c4f53ff7b | ||
|
|
b1a39a2320 | ||
|
|
59064f7165 | ||
|
|
24214e94f9 | ||
|
|
6cda350f22 | ||
|
|
89f8ad2e0a | ||
|
|
cc1a335a49 | ||
|
|
473d65c83e | ||
|
|
b176724fc5 | ||
|
|
d6519a76bf | ||
|
|
14b4fb4a0a | ||
|
|
1583eca7a4 | ||
|
|
2aef3148f6 | ||
|
|
3f83d67bc1 | ||
|
|
043a036075 | ||
|
|
0ec53c3a11 | ||
|
|
9782352909 | ||
|
|
db5c7311b1 | ||
|
|
bb3d398c9c | ||
|
|
3d5edf320e | ||
|
|
a0601cd6ae | ||
|
|
5043ef3e34 | ||
|
|
d902495312 | ||
|
|
c81014d530 | ||
|
|
4d5a078378 | ||
|
|
31f6960d44 | ||
|
|
0d13fdee37 | ||
|
|
c4d9cd7986 | ||
|
|
bc03dcbe89 | ||
|
|
f3a5238f50 | ||
|
|
9e07439f08 | ||
|
|
dccafc898b | ||
|
|
560a5db14d | ||
|
|
df0377b41f | ||
|
|
153377e9e6 | ||
|
|
c6d73d42ee | ||
|
|
45f1635e10 | ||
|
|
2b2349dd16 |
166
DEVELOPER.md
166
DEVELOPER.md
@@ -182,24 +182,57 @@ 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.
|
||||
|
||||
**Queue persistence & cache synchronization (v1.5.4+):**
|
||||
- **Startup repopulation**: Queue initialization now repopulates cache with pending nodes, ensuring visual continuity after app restarts
|
||||
- **Specific node cleanup**: Each upload stores a `tempNodeId` for precise removal, preventing accidental cleanup of other pending nodes at the same location
|
||||
- **Proximity awareness**: Proximity warnings now consider pending nodes to prevent duplicate submissions at the same location
|
||||
- **Processing status UI**: Upload queue screen shows clear indicators when processing is paused due to offline mode or user settings
|
||||
|
||||
### 4. Cache & Visual States
|
||||
|
||||
**Node visual states:**
|
||||
@@ -259,7 +292,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 +337,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 +350,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 +374,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 +417,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 +430,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 +453,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,18 +464,31 @@ 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
|
||||
- **Basic routing**: Complete but disabled pending API integration
|
||||
- **Avoidance routing**: Awaiting alprwatch.org/directions API
|
||||
- **Avoidance routing**: Fully implemented and active
|
||||
- **Offline routing**: Requires vector map tiles
|
||||
|
||||
**Architecture:**
|
||||
@@ -733,4 +847,4 @@ debugPrint('[ComponentName] Detailed message: $data');
|
||||
|
||||
---
|
||||
|
||||
This documentation should be updated as the architecture evolves. When making significant changes, update both the relevant section here and add a brief note explaining the rationale for the change.
|
||||
This documentation should be updated as the architecture evolves. When making significant changes, update both the relevant section here and add a brief note explaining the rationale for the change.
|
||||
|
||||
20
README.md
20
README.md
@@ -98,27 +98,15 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
## Roadmap
|
||||
|
||||
### Needed Bugfixes
|
||||
- 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:
|
||||
- Auto extract (how?)
|
||||
- Leave it alone (wrong answer unless user chooses intentionally)
|
||||
- Manual cleanup (cognitive load for users)
|
||||
- Delete the old one (also wrong answer unless user chooses intentionally)
|
||||
- Give multiple of these options??
|
||||
- Nav start+end too close together error (warning + disable submit button?)
|
||||
- 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
|
||||
- Optional reason message when deleting
|
||||
- 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
|
||||
|
||||
@@ -134,6 +122,12 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
- Grab the full latest database for each profile just like for suspected locations (instead of overpass)
|
||||
- Optional custom icons for profiles to aid identification
|
||||
- Custom device providers and OSM/Overpass alternatives
|
||||
- Offer options for extracting nodes which are attached to a way/relation:
|
||||
- Auto extract (how?)
|
||||
- Leave it alone (wrong answer unless user chooses intentionally)
|
||||
- Manual cleanup (cognitive load for users)
|
||||
- Delete the old one (also wrong answer unless user chooses intentionally)
|
||||
- Give multiple of these options??
|
||||
|
||||
---
|
||||
|
||||
|
||||
243
REFACTORING_ROUND_1_SUMMARY.md
Normal file
243
REFACTORING_ROUND_1_SUMMARY.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# Refactoring Rounds 1 & 2 Complete - v1.6.0
|
||||
|
||||
## Overview
|
||||
Successfully refactored the largest file in the codebase (MapView, 880 lines) by extracting specialized manager classes with clear separation of concerns. This follows the "brutalist code" philosophy of the project - simple, explicit, and maintainable.
|
||||
|
||||
## What Was Accomplished
|
||||
|
||||
### File Size Reduction
|
||||
- **MapView**: 880 lines → 572 lines (**35% reduction, -308 lines**)
|
||||
- **Total new code**: 4 new focused manager classes (351 lines total)
|
||||
- **Net complexity reduction**: Converted monolithic widget into clean orchestrator + specialized managers
|
||||
|
||||
### Step 1.5: Terminology Update (Camera → Node)
|
||||
- **Renamed 3 core files** to use "node" instead of "camera" terminology
|
||||
- **Updated all class names** to reflect current multi-device scope (not just cameras)
|
||||
- **Updated all method names** and comments for consistency
|
||||
- **Updated all imports/references** across the entire codebase
|
||||
- **Benefits**: Consistent terminology that reflects the app's expansion beyond just cameras to all surveillance devices
|
||||
=======
|
||||
|
||||
### New Manager Classes Created
|
||||
|
||||
#### 1. MapDataManager (`lib/widgets/map/map_data_manager.dart`) - 92 lines
|
||||
**Responsibility**: Data fetching, filtering, and node limit logic
|
||||
- `getNodesForRendering()` - Central method for getting filtered/limited nodes
|
||||
- `getMinZoomForNodes()` - Upload mode-aware zoom requirements
|
||||
- `showZoomWarningIfNeeded()` - Zoom level user feedback
|
||||
- `MapDataResult` - Clean result object with all node data + state
|
||||
|
||||
**Benefits**:
|
||||
- Encapsulates all node data logic
|
||||
- Clear separation between data concerns and UI concerns
|
||||
- Easily testable data operations
|
||||
|
||||
#### 2. MapInteractionManager (`lib/widgets/map/map_interaction_manager.dart`) - 45 lines
|
||||
**Responsibility**: Map gesture handling and interaction configuration
|
||||
- `getInteractionOptions()` - Constrained node interaction logic
|
||||
- `mapMovedSignificantly()` - Pan detection for tile queue management
|
||||
|
||||
**Benefits**:
|
||||
- Isolates gesture complexity from UI rendering
|
||||
- Clear constrained node behavior in one place
|
||||
- Reusable interaction logic
|
||||
|
||||
#### 3. MarkerLayerBuilder (`lib/widgets/map/marker_layer_builder.dart`) - 165 lines
|
||||
**Responsibility**: Building all map markers including surveillance nodes, suspected locations, navigation pins, route markers
|
||||
- `buildMarkerLayers()` - Main orchestrator for all marker types
|
||||
- `LocationPin` - Route start/end pin widget (extracted from MapView)
|
||||
- Private methods for each marker category
|
||||
- Proximity filtering for suspected locations
|
||||
|
||||
**Benefits**:
|
||||
- All marker logic in one place
|
||||
- Clean separation of marker types
|
||||
- Reusable marker building functions
|
||||
|
||||
#### 4. OverlayLayerBuilder (`lib/widgets/map/overlay_layer_builder.dart`) - 89 lines
|
||||
**Responsibility**: Building polygons, lines, and route overlays
|
||||
- `buildOverlayLayers()` - Direction cones, edit lines, suspected location bounds, route paths
|
||||
- Clean layer composition
|
||||
- Route visualization logic
|
||||
|
||||
**Benefits**:
|
||||
- Overlay logic separated from marker logic
|
||||
- Clear layer ordering and composition
|
||||
- Easy to add new overlay types
|
||||
|
||||
## Architectural Benefits
|
||||
|
||||
### Brutalist Code Principles Applied
|
||||
1. **Explicit over implicit**: Each manager has one clear responsibility
|
||||
2. **Simple delegation**: MapView orchestrates, managers execute
|
||||
3. **No clever abstractions**: Straightforward method calls and data flow
|
||||
4. **Clear failure points**: Each manager handles its own error cases
|
||||
|
||||
### Maintainability Gains
|
||||
1. **Focused testing**: Each manager can be unit tested independently
|
||||
2. **Clear debugging**: Issues confined to specific domains (data vs UI vs interaction)
|
||||
3. **Easier feature additions**: New marker types go in MarkerLayerBuilder, new data logic goes in MapDataManager
|
||||
4. **Reduced cognitive load**: Developers can focus on one concern at a time
|
||||
|
||||
### Code Organization Improvements
|
||||
1. **Single responsibility**: Each class does exactly one thing
|
||||
2. **Composition over inheritance**: MapView composes managers rather than inheriting complexity
|
||||
3. **Clean interfaces**: Result objects (MapDataResult) provide clear contracts
|
||||
4. **Consistent patterns**: All managers follow same initialization and method patterns
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
### Manager Initialization
|
||||
```dart
|
||||
class MapViewState extends State<MapView> {
|
||||
late final MapDataManager _dataManager;
|
||||
late final MapInteractionManager _interactionManager;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// ... existing initialization ...
|
||||
_dataManager = MapDataManager();
|
||||
_interactionManager = MapInteractionManager();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Clean Delegation Pattern
|
||||
```dart
|
||||
// Before: Complex data logic mixed with UI
|
||||
final nodeData = _dataManager.getNodesForRendering(
|
||||
currentZoom: currentZoom,
|
||||
mapBounds: mapBounds,
|
||||
uploadMode: appState.uploadMode,
|
||||
maxNodes: appState.maxNodes,
|
||||
onNodeLimitChanged: widget.onNodeLimitChanged,
|
||||
);
|
||||
|
||||
// Before: Complex marker building mixed with layout
|
||||
final markerLayer = MarkerLayerBuilder.buildMarkerLayers(
|
||||
nodesToRender: nodeData.nodesToRender,
|
||||
mapController: _controller,
|
||||
appState: appState,
|
||||
// ... other parameters
|
||||
);
|
||||
```
|
||||
|
||||
### Result Objects for Clean Interfaces
|
||||
```dart
|
||||
class MapDataResult {
|
||||
final List<OsmNode> allNodes;
|
||||
final List<OsmNode> nodesToRender;
|
||||
final bool isLimitActive;
|
||||
final int validNodesCount;
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Strategy for Round 1
|
||||
|
||||
### Critical Test Areas
|
||||
1. **MapView rendering**: Verify all markers, overlays, and controls still appear correctly
|
||||
2. **Node limit logic**: Test limit indicator shows/hides appropriately
|
||||
3. **Constrained node editing**: Ensure constrained nodes still lock interaction properly
|
||||
4. **Zoom warnings**: Verify zoom level warnings appear at correct thresholds
|
||||
5. **Route visualization**: Test navigation pins and route lines render correctly
|
||||
6. **Suspected locations**: Verify proximity filtering and bounds display
|
||||
7. **Sheet positioning**: Ensure map positioning with sheets still works
|
||||
|
||||
### Regression Prevention
|
||||
- **No functionality changes**: All existing behavior preserved
|
||||
- **Same performance**: No additional overhead from manager pattern
|
||||
- **Clean error handling**: Each manager handles its own error cases
|
||||
- **Memory management**: No memory leaks from manager lifecycle
|
||||
|
||||
## Round 2 Results: HomeScreen Extraction
|
||||
|
||||
Successfully completed HomeScreen refactoring (878 → 604 lines, **31% reduction**):
|
||||
|
||||
### New Coordinator Classes Created
|
||||
|
||||
#### 5. SheetCoordinator (`lib/screens/coordinators/sheet_coordinator.dart`) - 189 lines
|
||||
**Responsibility**: All bottom sheet operations including opening, closing, height tracking
|
||||
- `openAddNodeSheet()`, `openEditNodeSheet()`, `openNavigationSheet()` - Sheet lifecycle management
|
||||
- Height tracking and active sheet calculation
|
||||
- Sheet state management (edit/navigation shown flags)
|
||||
- Sheet transition coordination (prevents map bounce)
|
||||
|
||||
#### 6. NavigationCoordinator (`lib/screens/coordinators/navigation_coordinator.dart`) - 124 lines
|
||||
**Responsibility**: Route planning, navigation, and map centering/zoom logic
|
||||
- `startRoute()`, `resumeRoute()` - Route lifecycle with auto follow-me detection
|
||||
- `handleNavigationButtonPress()` - Search mode and route overview toggling
|
||||
- `zoomToShowFullRoute()` - Intelligent route visualization
|
||||
- Map centering logic based on GPS availability and user proximity
|
||||
|
||||
#### 7. MapInteractionHandler (`lib/screens/coordinators/map_interaction_handler.dart`) - 84 lines
|
||||
**Responsibility**: Map interaction events including node taps and search result selection
|
||||
- `handleNodeTap()` - Node selection with highlighting and centering
|
||||
- `handleSuspectedLocationTap()` - Suspected location interaction
|
||||
- `handleSearchResultSelection()` - Search result processing with map animation
|
||||
- `handleUserGesture()` - Selection clearing on user interaction
|
||||
|
||||
### Round 2 Benefits
|
||||
- **HomeScreen reduced**: 878 lines → 604 lines (**31% reduction, -274 lines**)
|
||||
- **Clear coordinator separation**: Each coordinator handles one domain (sheets, navigation, interactions)
|
||||
- **Simplified HomeScreen**: Now primarily orchestrates coordinators rather than implementing logic
|
||||
- **Better testability**: Coordinators can be unit tested independently
|
||||
- **Enhanced maintainability**: Feature additions have clear homes in appropriate coordinators
|
||||
|
||||
## Combined Results (Both Rounds)
|
||||
|
||||
### Total Impact
|
||||
- **MapView**: 880 → 572 lines (**-308 lines**)
|
||||
- **HomeScreen**: 878 → 604 lines (**-274 lines**)
|
||||
- **Total reduction**: **582 lines** removed from the two largest files
|
||||
- **New focused classes**: 7 manager/coordinator classes with clear responsibilities
|
||||
- **Net code increase**: 947 lines added across all new classes
|
||||
- **Overall impact**: +365 lines total, but dramatically improved organization and maintainability
|
||||
|
||||
### Architectural Transformation
|
||||
- **Before**: Two monolithic files handling multiple concerns each
|
||||
- **After**: Clean orchestrator pattern with focused managers/coordinators
|
||||
- **Maintainability**: Exponentially improved due to separation of concerns
|
||||
- **Testability**: Each manager/coordinator can be independently tested
|
||||
- **Feature Development**: Clear homes for new functionality
|
||||
|
||||
## Next Phase: AppState (Optional Round 3)
|
||||
|
||||
The third largest file is AppState (729 lines). If desired, could extract:
|
||||
1. **SessionCoordinator** - Add/edit session management
|
||||
2. **NavigationStateCoordinator** - Search and route state management
|
||||
3. **DataCoordinator** - Upload queue and node operations
|
||||
|
||||
Expected reduction: ~300-400 lines, but AppState is already well-organized as the central state provider.
|
||||
|
||||
## Files Modified
|
||||
|
||||
### New Files
|
||||
- `lib/widgets/map/map_data_manager.dart`
|
||||
- `lib/widgets/map/map_interaction_manager.dart`
|
||||
- `lib/widgets/map/marker_layer_builder.dart`
|
||||
- `lib/widgets/map/overlay_layer_builder.dart`
|
||||
- `lib/widgets/node_provider_with_cache.dart` (renamed from camera_provider_with_cache.dart)
|
||||
- `lib/widgets/map/node_refresh_controller.dart` (renamed from camera_refresh_controller.dart)
|
||||
- `lib/widgets/map/node_markers.dart` (renamed from camera_markers.dart)
|
||||
|
||||
### Modified Files
|
||||
- `lib/widgets/map_view.dart` (880 → 572 lines)
|
||||
- `lib/app_state.dart` (updated imports and references)
|
||||
- `lib/state/upload_queue_state.dart` (updated all references)
|
||||
- `lib/services/prefetch_area_service.dart` (updated references)
|
||||
|
||||
### Removed Files
|
||||
- `lib/widgets/camera_provider_with_cache.dart` (renamed to node_provider_with_cache.dart)
|
||||
- `lib/widgets/map/camera_refresh_controller.dart` (renamed to node_refresh_controller.dart)
|
||||
- `lib/widgets/map/camera_markers.dart` (renamed to node_markers.dart)
|
||||
|
||||
### Total Impact
|
||||
- **Lines removed**: 308 from MapView
|
||||
- **Lines added**: 351 across 4 focused managers
|
||||
- **Net addition**: 43 lines total
|
||||
- **Complexity reduction**: Significant (monolithic → modular)
|
||||
|
||||
---
|
||||
|
||||
This refactoring maintains backward compatibility while dramatically improving code organization and maintainability. The brutalist approach ensures each component has a clear, single purpose with explicit interfaces.
|
||||
125
UPLOAD_REFACTOR_SUMMARY.md
Normal file
125
UPLOAD_REFACTOR_SUMMARY.md
Normal file
@@ -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**.
|
||||
147
V1.6.2_CHANGES_SUMMARY.md
Normal file
147
V1.6.2_CHANGES_SUMMARY.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# v1.6.2 Changes Summary
|
||||
|
||||
## Issues Addressed
|
||||
|
||||
### 1. Navigation Interaction Conflict Prevention
|
||||
**Problem**: When navigation sheet is open (route planning or route overview) and user taps a node to view tags, competing UI states create conflicts and inconsistent behavior.
|
||||
|
||||
**Root Cause**: Two interaction modes trying to operate simultaneously:
|
||||
- **Route planning/overview** (temporary selection states)
|
||||
- **Node examination** (inspect/edit individual devices)
|
||||
|
||||
**Solution**: **Prevention over management** - disable conflicting interactions entirely:
|
||||
- Nodes and suspected locations are **dimmed and non-clickable** during `isInSearchMode` or `showingOverview`
|
||||
- Visual feedback (0.5 opacity) indicates interactive elements are temporarily disabled
|
||||
- Clean UX: users must complete/cancel navigation before examining nodes
|
||||
|
||||
**Brutalist Approach**: Prevent the conflict from ever happening rather than managing complex state transitions. Single condition check disables taps and applies dimming consistently across all interactive map elements.
|
||||
|
||||
### 2. Node Edge Blinking Bug
|
||||
**Problem**: Nodes appear/disappear exactly when their centers cross screen edges, causing "blinking" effect as they pop in/out of existence at screen periphery.
|
||||
|
||||
**Root Cause**: Node rendering uses exact `camera.visibleBounds` while data prefetching expands bounds by 3x. This creates a mismatch where data exists but isn't rendered until nodes cross the exact screen boundary.
|
||||
|
||||
**Solution**: Expanded rendering bounds by 1.3x while keeping data prefetch at 3x:
|
||||
- Added `kNodeRenderingBoundsExpansion = 1.3` constant in `dev_config.dart`
|
||||
- Added `_expandBounds()` method to `MapDataManager` (reusing proven logic from prefetch service)
|
||||
- Modified `getNodesForRendering()` to use expanded bounds for rendering decisions
|
||||
- Nodes now appear before sliding into view and stay visible until after sliding out
|
||||
|
||||
**Brutalist Approach**: Simple bounds expansion using proven mathematical logic. No complex visibility detection or animation state tracking.
|
||||
|
||||
### 3. Route Overview Follow-Me Management
|
||||
**Problem**: Route overview didn't disable follow-me mode, causing unexpected map jumps. Route resume didn't intelligently handle follow-me based on user proximity to route.
|
||||
|
||||
**Root Cause**: No coordination between route overview display and follow-me mode. Resume logic didn't consider user location relative to route path.
|
||||
|
||||
**Solution**: Smart follow-me management for route overview workflow:
|
||||
- **Opening overview**: Store current follow-me mode and disable it to prevent map jumps
|
||||
- **Resume from overview**: Check if user is within configurable distance (500m) of route path
|
||||
- **Near route**: Center on GPS location and restore previous follow-me mode
|
||||
- **Far from route**: Center on route start without follow-me
|
||||
- **Zoom level**: Use level 16 for resume instead of 14
|
||||
|
||||
**Brutalist Approach**: Simple distance-to-route calculation with clear decision logic. No complex state machine - just store/restore with proximity-based decisions.
|
||||
|
||||
## Files Modified
|
||||
|
||||
### Core Logic Changes
|
||||
- `lib/widgets/map/map_data_manager.dart` - Added bounds expansion for node rendering
|
||||
- `lib/dev_config.dart` - Added rendering bounds expansion constant
|
||||
|
||||
### Navigation Interaction Prevention
|
||||
- `lib/widgets/map/marker_layer_builder.dart` - Added dimming and tap disabling for conflicting navigation states
|
||||
- `lib/widgets/map/node_markers.dart` - Added `enabled` parameter to prevent tap handler fallbacks
|
||||
- `lib/widgets/map/suspected_location_markers.dart` - Added `enabled` and `shouldDimAll` parameters for consistent behavior
|
||||
- Removed navigation state cleanup code (prevention approach eliminates need)
|
||||
|
||||
### Route Overview Follow-Me Management
|
||||
- `lib/screens/coordinators/navigation_coordinator.dart` - Added follow-me tracking and smart resume logic
|
||||
- `lib/dev_config.dart` - Added route proximity threshold and resume zoom level constants
|
||||
|
||||
### Version & Documentation
|
||||
- `pubspec.yaml` - Updated to v1.6.2+28
|
||||
- `assets/changelog.json` - Added v1.6.2 changelog entry
|
||||
- `V1.6.2_CHANGES_SUMMARY.md` - This documentation
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
### Navigation Interaction Prevention Pattern
|
||||
```dart
|
||||
// Disable node interactions when navigation is in conflicting state
|
||||
final shouldDisableNodeTaps = appState.isInSearchMode || appState.showingOverview;
|
||||
|
||||
// Apply to all interactive elements
|
||||
onNodeTap: shouldDisableNodeTaps ? null : onNodeTap,
|
||||
onLocationTap: shouldDisableNodeTaps ? null : onSuspectedLocationTap,
|
||||
shouldDim: shouldDisableNodeTaps, // Visual feedback via dimming
|
||||
```
|
||||
|
||||
This pattern prevents conflicts by making competing interactions impossible rather than trying to resolve them after they occur.
|
||||
|
||||
### Bounds Expansion Implementation
|
||||
```dart
|
||||
/// Expand bounds by the given multiplier, maintaining center point.
|
||||
/// Used to expand rendering bounds to prevent nodes blinking at screen edges.
|
||||
LatLngBounds _expandBounds(LatLngBounds bounds, double multiplier) {
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
final centerLng = (bounds.east + bounds.west) / 2;
|
||||
|
||||
final latSpan = (bounds.north - bounds.south) * multiplier / 2;
|
||||
final lngSpan = (bounds.east - bounds.west) * multiplier / 2;
|
||||
|
||||
return LatLngBounds(
|
||||
LatLng(centerLat - latSpan, centerLng - lngSpan),
|
||||
LatLng(centerLat + latSpan, centerLng + lngSpan),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The expansion maintains the center point while scaling the bounds uniformly. Factor of 1.3x provides smooth transitions without excessive over-rendering.
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
### Issue 1 - Navigation Interaction Prevention
|
||||
1. **Search mode dimming**: Enter search mode → verify all nodes and suspected locations are dimmed (0.5 opacity)
|
||||
2. **Search mode taps disabled**: In search mode → tap dimmed nodes → verify no response (no tag sheet opens)
|
||||
3. **Route overview dimming**: Start route → open route overview → verify nodes are dimmed and non-clickable
|
||||
4. **Active route compatibility**: Follow active route (no overview) → tap nodes → verify tag sheets open normally
|
||||
5. **Visual consistency**: Compare dimming with existing selected node dimming behavior
|
||||
6. **Suspected location consistency**: Verify suspected locations dim and disable the same as nodes
|
||||
|
||||
### Issue 2 - Node Edge Blinking
|
||||
1. **Pan testing**: Pan map slowly and verify nodes appear smoothly before entering view (not popping in at edge)
|
||||
2. **Pan exit**: Pan map to move nodes out of view and verify they disappear smoothly after leaving view
|
||||
3. **Zoom testing**: Zoom in/out and verify nodes don't blink during zoom operations
|
||||
4. **Performance**: Verify expanded rendering doesn't cause performance issues with high node counts
|
||||
5. **Different zoom levels**: Test at various zoom levels to ensure expansion works consistently
|
||||
|
||||
### Regression Testing
|
||||
1. **Navigation functionality**: Verify all navigation features still work normally (search, route planning, active navigation)
|
||||
2. **Sheet interactions**: Verify all sheet types (tag, edit, add, suspected location) still open/close properly
|
||||
3. **Map interactions**: Verify node selection, editing, and map controls work normally
|
||||
4. **Performance**: Monitor for any performance degradation from bounds expansion
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Why Brutalist Approach Succeeded
|
||||
Both fixes follow the "brutalist code" philosophy:
|
||||
1. **Simple, explicit solutions** rather than complex state management
|
||||
2. **Consistent patterns** applied uniformly across similar situations
|
||||
3. **Clear failure points** with obvious debugging paths
|
||||
4. **No clever abstractions** that could hide bugs
|
||||
|
||||
### Bounds Expansion Benefits
|
||||
- **Mathematical simplicity**: Reuses proven bounds expansion logic
|
||||
- **Performance aware**: 1.3x expansion provides smooth UX without excessive computation
|
||||
- **Configurable**: Expansion factor isolated in dev_config for easy adjustment
|
||||
- **Future-proof**: Could easily add different expansion factors for different scenarios
|
||||
|
||||
### Interaction Prevention Benefits
|
||||
- **Eliminates complexity**: No state transition management needed
|
||||
- **Clear visual feedback**: Users understand when interactions are disabled
|
||||
- **Consistent behavior**: Same dimming/disabling across all interactive elements
|
||||
- **Fewer edge cases**: Impossible states can't occur
|
||||
- **Negative code commit**: Removed more code than added
|
||||
|
||||
This approach ensures robust, maintainable code that handles edge cases gracefully while remaining easy to understand and modify.
|
||||
@@ -1,4 +1,65 @@
|
||||
{
|
||||
"1.6.3": {
|
||||
"content": [
|
||||
"• Fixed navigation sheet button flow - route to/from buttons no longer reappear after selecting second location",
|
||||
"• Added cancel button when selecting second route point for easier exit from route planning"
|
||||
]
|
||||
},
|
||||
"1.6.2": {
|
||||
"content": [
|
||||
"• Improved node rendering bounds - nodes appear slightly before sliding into view and stay visible until just after sliding out, eliminating edge blinking",
|
||||
"• Navigation interaction conflict prevention - nodes and suspected locations are now dimmed and non-clickable during route planning and route overview to prevent state conflicts",
|
||||
"• Enhanced route overview behavior - follow-me is automatically disabled when opening overview and intelligently restored when resuming based on proximity to route",
|
||||
"• Smart route resume - centers on GPS location with follow-me if near route, or route start without follow-me if far away, with configurable proximity threshold"
|
||||
]
|
||||
},
|
||||
"1.6.1": {
|
||||
"content": [
|
||||
"• Navigation route calculation timeout increased from 15 to 30 seconds - better success rate for complex routes in dense areas",
|
||||
"• Route timeout is now configurable in dev_config for easier future adjustments",
|
||||
"• Fix accidentally opening edit sheet on node tap instead of tags sheet"
|
||||
]
|
||||
},
|
||||
"1.6.0": {
|
||||
"content": [
|
||||
"• Internal code organization improvements - better separation of concerns for improved maintainability",
|
||||
"• Extracted specialized manager classes for map data, interactions, sheets, and navigation",
|
||||
"• Improved code modularity while preserving all existing functionality"
|
||||
]
|
||||
},
|
||||
"1.5.4": {
|
||||
"content": [
|
||||
"• OSM message notifications - dot appears on Settings button and OSM Account section when you have unread messages on OpenStreetMap",
|
||||
"• Download area max zoom level is now limited to the currently selected tile provider's maximum zoom level",
|
||||
"• Navigation route planning now prevents selecting start and end locations that are too close together",
|
||||
"• Cleaned up internal 'maxCameras' references to use 'maxNodes' terminology consistently",
|
||||
"• Proximity warnings now consider pending nodes - prevents submitting multiple nodes at the same location without warning",
|
||||
"• Pending nodes now reappear on the map after app restart - queue items repopulate the visual cache on startup",
|
||||
"• Upload queue screen shows when processing is paused (offline mode or manually paused)"
|
||||
]
|
||||
},
|
||||
"1.5.3": {
|
||||
"content": [
|
||||
"• Uploads now correctly track changeset creation, node operation, and changeset closing as separate steps",
|
||||
"• Upload queue processing is now more robust and continues when individual items encounter errors",
|
||||
"• Enhanced upload error handling - failures in each stage (create changeset, upload node, close changeset) are now handled appropriately",
|
||||
"• Improved upload status display - shows 'Creating changeset...', 'Uploading...', and 'Closing changeset...' with time remaining for changeset close",
|
||||
"• You can now tap the error icon (!) on failed uploads to see exactly what went wrong and at which stage",
|
||||
"• Moved 'Delete OSM Account' link from About page to OSM Account page - now only appears when logged in",
|
||||
"• Removing queue items or clearing the queue now properly removes temporary markers from the map",
|
||||
"• Removed placeholder FOV values from built-in profiles - FOV functionality remains available"
|
||||
]
|
||||
},
|
||||
"1.5.2": {
|
||||
"content": [
|
||||
"• Simplified tile loading architecture - replaced HTTP interception with clean TileProvider implementation",
|
||||
"• Improved tile loading performance - eliminate expensive filesystem searches on every tile request",
|
||||
"• Network status indicator now indicates only node data loading, not tile loading",
|
||||
"• Network status indicator no longer shows false timeouts during surveillance data splitting operations",
|
||||
"• Max nodes setting now correctly limits rendering only (not data fetching)",
|
||||
"• New node limit indicator shows when not all devices are displayed due to rendering limit"
|
||||
]
|
||||
},
|
||||
"1.5.1": {
|
||||
"content": [
|
||||
"• NEW: Bing satellite imagery - high-quality satellite tiles used by the iD editor, no API key required",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -15,11 +18,13 @@ import 'services/node_cache.dart';
|
||||
import 'services/tile_preview_service.dart';
|
||||
import 'services/changelog_service.dart';
|
||||
import 'services/operator_profile_service.dart';
|
||||
import 'widgets/node_provider_with_cache.dart';
|
||||
import 'services/profile_service.dart';
|
||||
import 'widgets/camera_provider_with_cache.dart';
|
||||
import 'widgets/proximity_warning_dialog.dart';
|
||||
import 'widgets/reauth_messages_dialog.dart';
|
||||
import 'dev_config.dart';
|
||||
import 'state/auth_state.dart';
|
||||
import 'state/messages_state.dart';
|
||||
import 'state/navigation_state.dart';
|
||||
import 'state/operator_profile_state.dart';
|
||||
import 'state/profile_state.dart';
|
||||
@@ -40,6 +45,7 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
// State modules
|
||||
late final AuthState _authState;
|
||||
late final MessagesState _messagesState;
|
||||
late final NavigationState _navigationState;
|
||||
late final OperatorProfileState _operatorProfileState;
|
||||
late final ProfileState _profileState;
|
||||
@@ -50,10 +56,12 @@ class AppState extends ChangeNotifier {
|
||||
late final UploadQueueState _uploadQueueState;
|
||||
|
||||
bool _isInitialized = false;
|
||||
Timer? _messageCheckTimer;
|
||||
|
||||
AppState() {
|
||||
instance = this;
|
||||
_authState = AuthState();
|
||||
_messagesState = MessagesState();
|
||||
_navigationState = NavigationState();
|
||||
_operatorProfileState = OperatorProfileState();
|
||||
_profileState = ProfileState();
|
||||
@@ -65,6 +73,7 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
// Set up state change listeners
|
||||
_authState.addListener(_onStateChanged);
|
||||
_messagesState.addListener(_onStateChanged);
|
||||
_navigationState.addListener(_onStateChanged);
|
||||
_operatorProfileState.addListener(_onStateChanged);
|
||||
_profileState.addListener(_onStateChanged);
|
||||
@@ -104,6 +113,7 @@ class AppState extends ChangeNotifier {
|
||||
double? get routeDistance => _navigationState.routeDistance;
|
||||
bool get settingRouteStart => _navigationState.settingRouteStart;
|
||||
bool get isSettingSecondPoint => _navigationState.isSettingSecondPoint;
|
||||
bool get areRoutePointsTooClose => _navigationState.areRoutePointsTooClose;
|
||||
bool get isCalculating => _navigationState.isCalculating;
|
||||
bool get showingOverview => _navigationState.showingOverview;
|
||||
String? get routingError => _navigationState.routingError;
|
||||
@@ -112,6 +122,7 @@ class AppState extends ChangeNotifier {
|
||||
// Navigation search state
|
||||
bool get isNavigationSearchLoading => _navigationState.isSearchLoading;
|
||||
List<SearchResult> get navigationSearchResults => _navigationState.searchResults;
|
||||
int get navigationAvoidanceDistance => _settingsState.navigationAvoidanceDistance;
|
||||
|
||||
// Profile state
|
||||
List<NodeProfile> get profiles => _profileState.profiles;
|
||||
@@ -133,7 +144,7 @@ class AppState extends ChangeNotifier {
|
||||
// Settings state
|
||||
bool get offlineMode => _settingsState.offlineMode;
|
||||
bool get pauseQueueProcessing => _settingsState.pauseQueueProcessing;
|
||||
int get maxCameras => _settingsState.maxCameras;
|
||||
int get maxNodes => _settingsState.maxNodes;
|
||||
UploadMode get uploadMode => _settingsState.uploadMode;
|
||||
FollowMeMode get followMeMode => _settingsState.followMeMode;
|
||||
|
||||
@@ -142,6 +153,11 @@ class AppState extends ChangeNotifier {
|
||||
bool get networkStatusIndicatorEnabled => _settingsState.networkStatusIndicatorEnabled;
|
||||
int get suspectedLocationMinDistance => _settingsState.suspectedLocationMinDistance;
|
||||
|
||||
// Messages state
|
||||
int? get unreadMessageCount => _messagesState.unreadCount;
|
||||
bool get hasUnreadMessages => _messagesState.hasUnreadMessages;
|
||||
bool get isCheckingMessages => _messagesState.isChecking;
|
||||
|
||||
// Tile provider state
|
||||
List<TileProvider> get tileProviders => _settingsState.tileProviders;
|
||||
TileType? get selectedTileType => _settingsState.selectedTileType;
|
||||
@@ -198,6 +214,18 @@ class AppState extends ChangeNotifier {
|
||||
await _uploadQueueState.init();
|
||||
await _authState.init(_settingsState.uploadMode);
|
||||
|
||||
// Set up callback to repopulate pending nodes after cache clears
|
||||
NodeProviderWithCache.instance.setOnCacheClearedCallback(() {
|
||||
_uploadQueueState.repopulateCacheFromQueue();
|
||||
});
|
||||
|
||||
// Check for messages on app launch if user is already logged in
|
||||
if (isLoggedIn) {
|
||||
checkMessages();
|
||||
}
|
||||
|
||||
// Note: Re-auth check will be triggered from home screen after init
|
||||
|
||||
// Initialize OfflineAreaService to ensure offline areas are loaded
|
||||
await OfflineAreaService().ensureInitialized();
|
||||
|
||||
@@ -205,16 +233,40 @@ class AppState extends ChangeNotifier {
|
||||
_startUploader();
|
||||
|
||||
_isInitialized = true;
|
||||
|
||||
// Start periodic message checking
|
||||
_startMessageCheckTimer();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _startMessageCheckTimer() {
|
||||
_messageCheckTimer?.cancel();
|
||||
|
||||
// Check messages every 10 minutes when logged in
|
||||
_messageCheckTimer = Timer.periodic(
|
||||
const Duration(minutes: 10),
|
||||
(timer) {
|
||||
if (isLoggedIn) {
|
||||
checkMessages();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Auth Methods ----------
|
||||
Future<void> login() async {
|
||||
await _authState.login();
|
||||
// Check for messages after successful login
|
||||
if (isLoggedIn) {
|
||||
checkMessages();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _authState.logout();
|
||||
// Clear message state when logging out
|
||||
clearMessages();
|
||||
}
|
||||
|
||||
Future<void> refreshAuthState() async {
|
||||
@@ -223,11 +275,103 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
Future<void> forceLogin() async {
|
||||
await _authState.forceLogin();
|
||||
// Check for messages after successful login
|
||||
if (isLoggedIn) {
|
||||
checkMessages();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> validateToken() async {
|
||||
return await _authState.validateToken();
|
||||
}
|
||||
|
||||
// ---------- Messages Methods ----------
|
||||
Future<void> checkMessages({bool forceRefresh = false}) async {
|
||||
final accessToken = await _authState.getAccessToken();
|
||||
await _messagesState.checkMessages(
|
||||
accessToken: accessToken,
|
||||
uploadMode: uploadMode,
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
}
|
||||
|
||||
String getMessagesUrl() {
|
||||
return _messagesState.getMessagesUrl(uploadMode);
|
||||
}
|
||||
|
||||
void clearMessages() {
|
||||
_messagesState.clearMessages();
|
||||
}
|
||||
|
||||
/// Check if the current OAuth token has required scopes for message notifications
|
||||
/// Returns true if re-authentication is needed
|
||||
Future<bool> needsReauthForMessages() async {
|
||||
// Only check if logged in and not in simulate mode
|
||||
if (!isLoggedIn || uploadMode == UploadMode.simulate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final accessToken = await _authState.getAccessToken();
|
||||
if (accessToken == null) return false;
|
||||
|
||||
try {
|
||||
// Try to fetch user details - this should include message data if scope is correct
|
||||
final response = await http.get(
|
||||
Uri.parse('${_getApiHost()}/api/0.6/user/details.json'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
|
||||
if (response.statusCode == 403) {
|
||||
// Forbidden - likely missing scope
|
||||
return true;
|
||||
}
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
final messages = data['user']?['messages'];
|
||||
// If messages field is missing, we might not have the right scope
|
||||
return messages == null;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
// On error, assume no re-auth needed to avoid annoying users
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Show re-authentication dialog if needed
|
||||
Future<void> checkAndPromptReauthForMessages(BuildContext context) async {
|
||||
if (await needsReauthForMessages()) {
|
||||
_showReauthDialog(context);
|
||||
}
|
||||
}
|
||||
|
||||
void _showReauthDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ReauthMessagesDialog(
|
||||
onReauth: () {
|
||||
// Navigate to OSM account page where user can re-authenticate
|
||||
Navigator.of(context).pushNamed('/settings/osm-account');
|
||||
},
|
||||
onDismiss: () {
|
||||
// Just dismiss - will show again on next app start or mode change
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getApiHost() {
|
||||
switch (uploadMode) {
|
||||
case UploadMode.production:
|
||||
return 'https://api.openstreetmap.org';
|
||||
case UploadMode.sandbox:
|
||||
return 'https://api06.dev.openstreetmap.org';
|
||||
case UploadMode.simulate:
|
||||
return 'https://api.openstreetmap.org';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Profile Methods ----------
|
||||
void toggleProfile(NodeProfile p, bool e) {
|
||||
@@ -430,18 +574,27 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
set maxCameras(int n) {
|
||||
_settingsState.maxCameras = n;
|
||||
set maxNodes(int n) {
|
||||
_settingsState.maxNodes = n;
|
||||
}
|
||||
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
// Clear node cache when switching upload modes to prevent mixing production/sandbox data
|
||||
NodeCache.instance.clear();
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
debugPrint('[AppState] Cleared node cache due to upload mode change');
|
||||
|
||||
await _settingsState.setUploadMode(mode);
|
||||
await _authState.onUploadModeChanged(mode);
|
||||
|
||||
// Clear and re-check messages for new mode
|
||||
clearMessages();
|
||||
if (isLoggedIn) {
|
||||
// Don't await - let it run in background
|
||||
checkMessages();
|
||||
|
||||
// Note: Re-auth check will be triggered from the settings screen after mode change
|
||||
}
|
||||
|
||||
_startUploader(); // Restart uploader with new mode
|
||||
}
|
||||
|
||||
@@ -480,11 +633,24 @@ 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);
|
||||
}
|
||||
|
||||
/// Set navigation avoidance distance
|
||||
Future<void> setNavigationAvoidanceDistance(int distance) async {
|
||||
await _settingsState.setNavigationAvoidanceDistance(distance);
|
||||
}
|
||||
|
||||
// ---------- Queue Methods ----------
|
||||
void clearQueue() {
|
||||
_uploadQueueState.clearQueue();
|
||||
@@ -551,7 +717,9 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageCheckTimer?.cancel();
|
||||
_authState.removeListener(_onStateChanged);
|
||||
_messagesState.removeListener(_onStateChanged);
|
||||
_navigationState.removeListener(_onStateChanged);
|
||||
_operatorProfileState.removeListener(_onStateChanged);
|
||||
_profileState.removeListener(_onStateChanged);
|
||||
|
||||
@@ -53,6 +53,16 @@ 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;
|
||||
|
||||
// Navigation routing configuration
|
||||
const Duration kNavigationRoutingTimeout = Duration(seconds: 120); // HTTP timeout for routing requests
|
||||
|
||||
// Suspected locations CSV URL
|
||||
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-latest.csv';
|
||||
|
||||
@@ -60,7 +70,7 @@ const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locati
|
||||
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
|
||||
|
||||
// Navigation features - set to false to hide navigation UI elements while in development
|
||||
const bool kEnableNavigationFeatures = kEnableDevelopmentModes; // Hide navigation until fully implemented
|
||||
const bool kEnableNavigationFeatures = true; // Hide navigation until fully implemented
|
||||
|
||||
// Node editing features - set to false to temporarily disable editing
|
||||
const bool kEnableNodeEdits = true; // Set to false to temporarily disable node editing
|
||||
@@ -70,11 +80,7 @@ const bool kEnableNodeExtraction = false; // Set to true to enable extract from
|
||||
|
||||
/// Navigation availability: only dev builds, and only when online
|
||||
bool enableNavigationFeatures({required bool offlineMode}) {
|
||||
if (!kEnableDevelopmentModes) {
|
||||
return false; // Release builds: never allow navigation
|
||||
} else {
|
||||
return !offlineMode; // Dev builds: only when online
|
||||
}
|
||||
return kEnableNavigationFeatures && !offlineMode;
|
||||
}
|
||||
|
||||
// Marker/node interaction
|
||||
@@ -87,6 +93,9 @@ const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
|
||||
|
||||
// Pre-fetch area configuration
|
||||
const double kPreFetchAreaExpansionMultiplier = 3.0; // Expand visible bounds by this factor for pre-fetching
|
||||
const double kNodeRenderingBoundsExpansion = 1.3; // Expand visible bounds by this factor for node rendering to prevent edge blinking
|
||||
const double kRouteProximityThresholdMeters = 500.0; // Distance threshold for determining if user is near route when resuming navigation
|
||||
const double kResumeNavigationZoomLevel = 16.0; // Zoom level when resuming navigation
|
||||
const int kPreFetchZoomLevel = 10; // Always pre-fetch at this zoom level for consistent area sizes
|
||||
const int kMaxPreFetchSplitDepth = 3; // Maximum recursive splits when hitting Overpass node limit
|
||||
|
||||
@@ -117,6 +126,12 @@ const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown betw
|
||||
// Node proximity warning configuration (for new/edited nodes that are too close to existing ones)
|
||||
const double kNodeProximityWarningDistance = 15.0; // meters - distance threshold to show warning
|
||||
|
||||
// Navigation route planning configuration
|
||||
const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance between start and end points
|
||||
|
||||
// Node display configuration
|
||||
const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render on the map at once
|
||||
|
||||
// Map interaction configuration
|
||||
const double kNodeDoubleTapZoomDelta = 1.0; // How much to zoom in when double-tapping nodes (was 1.0)
|
||||
const double kScrollWheelVelocity = 0.01; // Mouse scroll wheel zoom speed (default 0.005)
|
||||
@@ -125,11 +140,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 +181,4 @@ double getNodeRingThickness(BuildContext context) {
|
||||
// return _kNodeRingThicknessBase * MediaQuery.of(context).devicePixelRatio;
|
||||
return _kNodeRingThicknessBase;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"aboutSubtitle": "App-Informationen und Credits",
|
||||
"languageSubtitle": "Wählen Sie Ihre bevorzugte Sprache",
|
||||
"maxNodes": "Max. angezeigte Knoten",
|
||||
"maxNodesSubtitle": "Obergrenze für die Anzahl der Knoten auf der Karte festlegen (Standard: 250).",
|
||||
"maxNodesSubtitle": "Obergrenze für die Anzahl der Knoten auf der Karte festlegen.",
|
||||
"maxNodesWarning": "Sie möchten das wahrscheinlich nicht tun, es sei denn, Sie sind absolut sicher, dass Sie einen guten Grund dafür haben.",
|
||||
"offlineMode": "Offline-Modus",
|
||||
"offlineModeSubtitle": "Alle Netzwerkanfragen außer für lokale/Offline-Bereiche deaktivieren.",
|
||||
@@ -152,7 +152,8 @@
|
||||
"simulate": "Simulieren",
|
||||
"productionDescription": "Hochladen in die Live-OSM-Datenbank (für alle Benutzer sichtbar)",
|
||||
"sandboxDescription": "Uploads gehen an die OSM Sandbox (sicher zum Testen, wird regelmäßig zurückgesetzt).",
|
||||
"simulateDescription": "Uploads simulieren (kontaktiert OSM-Server nicht)"
|
||||
"simulateDescription": "Uploads simulieren (kontaktiert OSM-Server nicht)",
|
||||
"cannotChangeWithQueue": "Upload-Ziel kann nicht geändert werden, während {} Elemente in der Warteschlange sind. Warteschlange zuerst leeren."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "OpenStreetMap-Konto",
|
||||
@@ -175,7 +176,20 @@
|
||||
"deleteAccountSubtitle": "Ihr OpenStreetMap-Konto verwalten",
|
||||
"deleteAccountExplanation": "Um Ihr OpenStreetMap-Konto zu löschen, müssen Sie die OpenStreetMap-Website besuchen. Dies entfernt dauerhaft Ihr OSM-Konto und alle zugehörigen Daten.",
|
||||
"deleteAccountWarning": "Warnung: Diese Aktion kann nicht rückgängig gemacht werden und löscht Ihr OSM-Konto dauerhaft.",
|
||||
"goToOSM": "Zu OpenStreetMap gehen"
|
||||
"goToOSM": "Zu OpenStreetMap gehen",
|
||||
"accountManagement": "Kontoverwaltung",
|
||||
"accountManagementDescription": "Um Ihr OpenStreetMap-Konto zu löschen, müssen Sie die entsprechende OpenStreetMap-Website besuchen. Dadurch werden Ihr Konto und alle zugehörigen Daten dauerhaft gelöscht.",
|
||||
"currentDestinationProduction": "Derzeit verbunden mit: Produktions-OpenStreetMap",
|
||||
"currentDestinationSandbox": "Derzeit verbunden mit: Sandbox-OpenStreetMap",
|
||||
"currentDestinationSimulate": "Derzeit im: Simulationsmodus (kein echtes Konto)",
|
||||
"viewMessages": "Nachrichten auf OSM anzeigen",
|
||||
"unreadMessagesCount": "Sie haben {} ungelesene Nachrichten",
|
||||
"noUnreadMessages": "Keine ungelesenen Nachrichten",
|
||||
"reauthRequired": "Authentifizierung aktualisieren",
|
||||
"reauthExplanation": "Sie müssen Ihre Authentifizierung aktualisieren, um OSM-Nachrichtenbenachrichtigungen über die App zu erhalten.",
|
||||
"reauthBenefit": "Dies ermöglicht Benachrichtigungspunkte, wenn Sie ungelesene Nachrichten auf OpenStreetMap haben.",
|
||||
"reauthNow": "Jetzt machen",
|
||||
"reauthLater": "Später"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Upload-Warteschlange",
|
||||
@@ -193,7 +207,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 +217,14 @@
|
||||
"attempts": "Versuche: {}",
|
||||
"uploadFailedRetry": "Upload fehlgeschlagen. Zum Wiederholen antippen.",
|
||||
"retryUpload": "Upload wiederholen",
|
||||
"clearAll": "Alle Löschen"
|
||||
"clearAll": "Alle Löschen",
|
||||
"errorDetails": "Fehlerdetails",
|
||||
"creatingChangeset": " (Changeset erstellen...)",
|
||||
"uploading": " (Uploading...)",
|
||||
"closingChangeset": " (Changeset schließen...)",
|
||||
"processingPaused": "Warteschlangenverarbeitung pausiert",
|
||||
"pausedDueToOffline": "Upload-Verarbeitung ist pausiert, da der Offline-Modus aktiviert ist.",
|
||||
"pausedByUser": "Upload-Verarbeitung ist manuell pausiert."
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Kachel-Anbieter",
|
||||
@@ -377,15 +398,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",
|
||||
@@ -442,6 +464,7 @@
|
||||
"endSelect": "Ende (auswählen)",
|
||||
"distance": "Entfernung: {} km",
|
||||
"routeActive": "Route aktiv",
|
||||
"locationsTooClose": "Start- und Endpositionen sind zu nah beieinander",
|
||||
"navigationSettings": "Navigation",
|
||||
"navigationSettingsSubtitle": "Routenplanung und Vermeidungseinstellungen",
|
||||
"avoidanceDistance": "Vermeidungsabstand",
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
"aboutSubtitle": "App information and credits",
|
||||
"languageSubtitle": "Choose your preferred language",
|
||||
"maxNodes": "Max nodes drawn",
|
||||
"maxNodesSubtitle": "Set an upper limit for the number of nodes on the map (default: 250).",
|
||||
"maxNodesSubtitle": "Set an upper limit for the number of nodes on the map.",
|
||||
"maxNodesWarning": "You probably don't want to do that unless you are absolutely sure you have a good reason for it.",
|
||||
"offlineMode": "Offline Mode",
|
||||
"offlineModeSubtitle": "Disable all network requests except for local/offline areas.",
|
||||
@@ -184,7 +184,8 @@
|
||||
"simulate": "Simulate",
|
||||
"productionDescription": "Upload to the live OSM database (visible to all users)",
|
||||
"sandboxDescription": "Uploads go to the OSM Sandbox (safe for testing, resets regularly).",
|
||||
"simulateDescription": "Simulate uploads (does not contact OSM servers)"
|
||||
"simulateDescription": "Simulate uploads (does not contact OSM servers)",
|
||||
"cannotChangeWithQueue": "Cannot change upload destination while {} items are in queue. Clear queue first."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "OpenStreetMap Account",
|
||||
@@ -207,7 +208,20 @@
|
||||
"deleteAccountSubtitle": "Manage your OpenStreetMap account",
|
||||
"deleteAccountExplanation": "To delete your OpenStreetMap account, you'll need to visit the OpenStreetMap website. This will permanently remove your OSM account and all associated data.",
|
||||
"deleteAccountWarning": "Warning: This action cannot be undone and will permanently delete your OSM account.",
|
||||
"goToOSM": "Go to OpenStreetMap"
|
||||
"goToOSM": "Go to OpenStreetMap",
|
||||
"accountManagement": "Account Management",
|
||||
"accountManagementDescription": "To delete your OpenStreetMap account, you'll need to visit the appropriate OpenStreetMap website. This will permanently remove your account and all associated data.",
|
||||
"currentDestinationProduction": "Currently connected to: Production OpenStreetMap",
|
||||
"currentDestinationSandbox": "Currently connected to: Sandbox OpenStreetMap",
|
||||
"currentDestinationSimulate": "Currently in: Simulate mode (no real account)",
|
||||
"viewMessages": "View Messages on OSM",
|
||||
"unreadMessagesCount": "You have {} unread messages",
|
||||
"noUnreadMessages": "No unread messages",
|
||||
"reauthRequired": "Refresh Authentication",
|
||||
"reauthExplanation": "You must refresh your authentication to receive OSM message notifications through the app.",
|
||||
"reauthBenefit": "This will enable notification dots when you have unread messages on OpenStreetMap.",
|
||||
"reauthNow": "Do That Now",
|
||||
"reauthLater": "Later"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Upload Queue",
|
||||
@@ -225,7 +239,7 @@
|
||||
"queueCleared": "Queue cleared",
|
||||
"uploadQueueTitle": "Upload Queue ({} items)",
|
||||
"queueIsEmpty": "Queue is empty",
|
||||
"cameraWithIndex": "Camera {}",
|
||||
"itemWithIndex": "Item {}",
|
||||
"error": " (Error)",
|
||||
"completing": " (Completing...)",
|
||||
"destination": "Dest: {}",
|
||||
@@ -235,7 +249,14 @@
|
||||
"attempts": "Attempts: {}",
|
||||
"uploadFailedRetry": "Upload failed. Tap retry to try again.",
|
||||
"retryUpload": "Retry upload",
|
||||
"clearAll": "Clear All"
|
||||
"clearAll": "Clear All",
|
||||
"errorDetails": "Error Details",
|
||||
"creatingChangeset": " (Creating changeset...)",
|
||||
"uploading": " (Uploading...)",
|
||||
"closingChangeset": " (Closing changeset...)",
|
||||
"processingPaused": "Queue Processing Paused",
|
||||
"pausedDueToOffline": "Upload processing is paused because offline mode is enabled.",
|
||||
"pausedByUser": "Upload processing is manually paused."
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Tile Providers",
|
||||
@@ -409,15 +430,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",
|
||||
@@ -442,6 +464,7 @@
|
||||
"endSelect": "End (select)",
|
||||
"distance": "Distance: {} km",
|
||||
"routeActive": "Route active",
|
||||
"locationsTooClose": "Start and end locations are too close together",
|
||||
"navigationSettings": "Navigation",
|
||||
"navigationSettingsSubtitle": "Route planning and avoidance settings",
|
||||
"avoidanceDistance": "Avoidance Distance",
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
"aboutSubtitle": "Información de la aplicación y créditos",
|
||||
"languageSubtitle": "Elige tu idioma preferido",
|
||||
"maxNodes": "Máx. nodos dibujados",
|
||||
"maxNodesSubtitle": "Establecer un límite superior para el número de nodos en el mapa (predeterminado: 250).",
|
||||
"maxNodesSubtitle": "Establecer un límite superior para el número de nodos en el mapa.",
|
||||
"maxNodesWarning": "Probablemente no quieras hacer eso a menos que estés absolutamente seguro de que tienes una buena razón para ello.",
|
||||
"offlineMode": "Modo Sin Conexión",
|
||||
"offlineModeSubtitle": "Deshabilitar todas las solicitudes de red excepto para áreas locales/sin conexión.",
|
||||
@@ -184,7 +184,8 @@
|
||||
"simulate": "Simular",
|
||||
"productionDescription": "Subir a la base de datos OSM en vivo (visible para todos los usuarios)",
|
||||
"sandboxDescription": "Las subidas van al Sandbox de OSM (seguro para pruebas, se reinicia regularmente).",
|
||||
"simulateDescription": "Simular subidas (no contacta servidores OSM)"
|
||||
"simulateDescription": "Simular subidas (no contacta servidores OSM)",
|
||||
"cannotChangeWithQueue": "No se puede cambiar el destino de subida mientras hay {} elementos en cola. Limpie la cola primero."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "Cuenta de OpenStreetMap",
|
||||
@@ -207,7 +208,20 @@
|
||||
"deleteAccountSubtitle": "Gestiona tu cuenta de OpenStreetMap",
|
||||
"deleteAccountExplanation": "Para eliminar tu cuenta de OpenStreetMap, necesitarás visitar el sitio web de OpenStreetMap. Esto eliminará permanentemente tu cuenta OSM y todos los datos asociados.",
|
||||
"deleteAccountWarning": "Advertencia: Esta acción no se puede deshacer y eliminará permanentemente tu cuenta OSM.",
|
||||
"goToOSM": "Ir a OpenStreetMap"
|
||||
"goToOSM": "Ir a OpenStreetMap",
|
||||
"accountManagement": "Gestión de Cuenta",
|
||||
"accountManagementDescription": "Para eliminar su cuenta de OpenStreetMap, debe visitar el sitio web de OpenStreetMap correspondiente. Esto eliminará permanentemente su cuenta y todos los datos asociados.",
|
||||
"currentDestinationProduction": "Actualmente conectado a: OpenStreetMap de Producción",
|
||||
"currentDestinationSandbox": "Actualmente conectado a: OpenStreetMap Sandbox",
|
||||
"currentDestinationSimulate": "Actualmente en: Modo de simulación (sin cuenta real)",
|
||||
"viewMessages": "Ver Mensajes en OSM",
|
||||
"unreadMessagesCount": "Tienes {} mensajes sin leer",
|
||||
"noUnreadMessages": "No hay mensajes sin leer",
|
||||
"reauthRequired": "Actualizar Autenticación",
|
||||
"reauthExplanation": "Debes actualizar tu autenticación para recibir notificaciones de mensajes OSM a través de la aplicación.",
|
||||
"reauthBenefit": "Esto habilitará puntos de notificación cuando tengas mensajes sin leer en OpenStreetMap.",
|
||||
"reauthNow": "Hazlo Ahora",
|
||||
"reauthLater": "Más Tarde"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Cola de Subida",
|
||||
@@ -225,7 +239,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 +249,14 @@
|
||||
"attempts": "Intentos: {}",
|
||||
"uploadFailedRetry": "Subida falló. Toque reintentar para intentar de nuevo.",
|
||||
"retryUpload": "Reintentar subida",
|
||||
"clearAll": "Limpiar Todo"
|
||||
"clearAll": "Limpiar Todo",
|
||||
"errorDetails": "Detalles del Error",
|
||||
"creatingChangeset": " (Creando changeset...)",
|
||||
"uploading": " (Subiendo...)",
|
||||
"closingChangeset": " (Cerrando changeset...)",
|
||||
"processingPaused": "Procesamiento de Cola Pausado",
|
||||
"pausedDueToOffline": "El procesamiento de subida está pausado porque el modo sin conexión está habilitado.",
|
||||
"pausedByUser": "El procesamiento de subida está pausado manualmente."
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Proveedores de Tiles",
|
||||
@@ -409,15 +430,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",
|
||||
@@ -442,6 +464,7 @@
|
||||
"endSelect": "Fin (seleccionar)",
|
||||
"distance": "Distancia: {} km",
|
||||
"routeActive": "Ruta activa",
|
||||
"locationsTooClose": "Las ubicaciones de inicio y fin están demasiado cerca",
|
||||
"navigationSettings": "Navegación",
|
||||
"navigationSettingsSubtitle": "Configuración de planificación de rutas y evitación",
|
||||
"avoidanceDistance": "Distancia de evitación",
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
"aboutSubtitle": "Informations sur l'application et crédits",
|
||||
"languageSubtitle": "Choisissez votre langue préférée",
|
||||
"maxNodes": "Max. nœuds dessinés",
|
||||
"maxNodesSubtitle": "Définir une limite supérieure pour le nombre de nœuds sur la carte (par défaut: 250).",
|
||||
"maxNodesSubtitle": "Définir une limite supérieure pour le nombre de nœuds sur la carte.",
|
||||
"maxNodesWarning": "Vous ne voulez probablement pas faire cela à moins d'être absolument sûr d'avoir une bonne raison de le faire.",
|
||||
"offlineMode": "Mode Hors Ligne",
|
||||
"offlineModeSubtitle": "Désactiver toutes les requêtes réseau sauf pour les zones locales/hors ligne.",
|
||||
@@ -184,7 +184,8 @@
|
||||
"simulate": "Simuler",
|
||||
"productionDescription": "Télécharger vers la base de données OSM en direct (visible pour tous les utilisateurs)",
|
||||
"sandboxDescription": "Les téléchargements vont vers le Sandbox OSM (sûr pour les tests, réinitialisé régulièrement).",
|
||||
"simulateDescription": "Simuler les téléchargements (ne contacte pas les serveurs OSM)"
|
||||
"simulateDescription": "Simuler les téléchargements (ne contacte pas les serveurs OSM)",
|
||||
"cannotChangeWithQueue": "Impossible de changer la destination de téléversement tant que {} éléments sont en file d'attente. Videz d'abord la file d'attente."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "Compte OpenStreetMap",
|
||||
@@ -207,7 +208,20 @@
|
||||
"deleteAccountSubtitle": "Gérez votre compte OpenStreetMap",
|
||||
"deleteAccountExplanation": "Pour supprimer votre compte OpenStreetMap, vous devrez visiter le site web OpenStreetMap. Cela supprimera définitivement votre compte OSM et toutes les données associées.",
|
||||
"deleteAccountWarning": "Attention : Cette action ne peut pas être annulée et supprimera définitivement votre compte OSM.",
|
||||
"goToOSM": "Aller à OpenStreetMap"
|
||||
"goToOSM": "Aller à OpenStreetMap",
|
||||
"accountManagement": "Gestion de Compte",
|
||||
"accountManagementDescription": "Pour supprimer votre compte OpenStreetMap, vous devez visiter le site Web OpenStreetMap approprié. Cela supprimera définitivement votre compte et toutes les données associées.",
|
||||
"currentDestinationProduction": "Actuellement connecté à : OpenStreetMap de Production",
|
||||
"currentDestinationSandbox": "Actuellement connecté à : OpenStreetMap Sandbox",
|
||||
"currentDestinationSimulate": "Actuellement en : Mode simulation (pas de compte réel)",
|
||||
"viewMessages": "Voir les Messages sur OSM",
|
||||
"unreadMessagesCount": "Vous avez {} messages non lus",
|
||||
"noUnreadMessages": "Aucun message non lu",
|
||||
"reauthRequired": "Actualiser l'Authentification",
|
||||
"reauthExplanation": "Vous devez actualiser votre authentification pour recevoir des notifications de messages OSM via l'application.",
|
||||
"reauthBenefit": "Cela activera les points de notification lorsque vous avez des messages non lus sur OpenStreetMap.",
|
||||
"reauthNow": "Le Faire Maintenant",
|
||||
"reauthLater": "Plus Tard"
|
||||
},
|
||||
"queue": {
|
||||
"title": "File de Téléchargement",
|
||||
@@ -225,7 +239,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 +249,14 @@
|
||||
"attempts": "Tentatives: {}",
|
||||
"uploadFailedRetry": "Téléchargement échoué. Appuyer pour réessayer.",
|
||||
"retryUpload": "Réessayer téléchargement",
|
||||
"clearAll": "Tout Vider"
|
||||
"clearAll": "Tout Vider",
|
||||
"errorDetails": "Détails de l'Erreur",
|
||||
"creatingChangeset": " (Création du changeset...)",
|
||||
"uploading": " (Téléchargement...)",
|
||||
"closingChangeset": " (Fermeture du changeset...)",
|
||||
"processingPaused": "Traitement de la File d'Attente Interrompu",
|
||||
"pausedDueToOffline": "Le traitement des téléversements est interrompu car le mode hors ligne est activé.",
|
||||
"pausedByUser": "Le traitement des téléversements est interrompu manuellement."
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Fournisseurs de Tuiles",
|
||||
@@ -409,15 +430,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",
|
||||
@@ -442,6 +464,7 @@
|
||||
"endSelect": "Fin (sélectionner)",
|
||||
"distance": "Distance: {} km",
|
||||
"routeActive": "Itinéraire actif",
|
||||
"locationsTooClose": "Les emplacements de départ et d'arrivée sont trop proches",
|
||||
"navigationSettings": "Navigation",
|
||||
"navigationSettingsSubtitle": "Paramètres de planification d'itinéraire et d'évitement",
|
||||
"avoidanceDistance": "Distance d'évitement",
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
"aboutSubtitle": "Informazioni sull'applicazione e crediti",
|
||||
"languageSubtitle": "Scegli la tua lingua preferita",
|
||||
"maxNodes": "Max nodi disegnati",
|
||||
"maxNodesSubtitle": "Imposta un limite superiore per il numero di nodi sulla mappa (predefinito: 250).",
|
||||
"maxNodesSubtitle": "Imposta un limite superiore per il numero di nodi sulla mappa.",
|
||||
"maxNodesWarning": "Probabilmente non vuoi farlo a meno che non sei assolutamente sicuro di avere una buona ragione per farlo.",
|
||||
"offlineMode": "Modalità Offline",
|
||||
"offlineModeSubtitle": "Disabilita tutte le richieste di rete tranne per aree locali/offline.",
|
||||
@@ -184,7 +184,8 @@
|
||||
"simulate": "Simula",
|
||||
"productionDescription": "Carica nel database OSM dal vivo (visibile a tutti gli utenti)",
|
||||
"sandboxDescription": "Gli upload vanno alla Sandbox OSM (sicuro per i test, si resetta regolarmente).",
|
||||
"simulateDescription": "Simula upload (non contatta i server OSM)"
|
||||
"simulateDescription": "Simula upload (non contatta i server OSM)",
|
||||
"cannotChangeWithQueue": "Impossibile cambiare la destinazione di upload mentre ci sono {} elementi in coda. Svuota prima la coda."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "Account OpenStreetMap",
|
||||
@@ -207,7 +208,20 @@
|
||||
"deleteAccountSubtitle": "Gestisci il tuo account OpenStreetMap",
|
||||
"deleteAccountExplanation": "Per eliminare il tuo account OpenStreetMap, dovrai visitare il sito web di OpenStreetMap. Questo rimuoverà permanentemente il tuo account OSM e tutti i dati associati.",
|
||||
"deleteAccountWarning": "Attenzione: Questa azione non può essere annullata e eliminerà permanentemente il tuo account OSM.",
|
||||
"goToOSM": "Vai a OpenStreetMap"
|
||||
"goToOSM": "Vai a OpenStreetMap",
|
||||
"accountManagement": "Gestione Account",
|
||||
"accountManagementDescription": "Per eliminare il tuo account OpenStreetMap, devi visitare il sito web OpenStreetMap appropriato. Questo rimuoverà permanentemente il tuo account e tutti i dati associati.",
|
||||
"currentDestinationProduction": "Attualmente connesso a: OpenStreetMap di Produzione",
|
||||
"currentDestinationSandbox": "Attualmente connesso a: OpenStreetMap Sandbox",
|
||||
"currentDestinationSimulate": "Attualmente in: Modalità simulazione (nessun account reale)",
|
||||
"viewMessages": "Visualizza Messaggi su OSM",
|
||||
"unreadMessagesCount": "Hai {} messaggi non letti",
|
||||
"noUnreadMessages": "Nessun messaggio non letto",
|
||||
"reauthRequired": "Aggiorna Autenticazione",
|
||||
"reauthExplanation": "Devi aggiornare la tua autenticazione per ricevere notifiche di messaggi OSM tramite l'app.",
|
||||
"reauthBenefit": "Questo abiliterà i punti di notifica quando hai messaggi non letti su OpenStreetMap.",
|
||||
"reauthNow": "Fallo Ora",
|
||||
"reauthLater": "Più Tardi"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Coda di Upload",
|
||||
@@ -225,7 +239,7 @@
|
||||
"queueCleared": "Coda pulita",
|
||||
"uploadQueueTitle": "Coda Upload ({} elementi)",
|
||||
"queueIsEmpty": "La coda è vuota",
|
||||
"cameraWithIndex": "Telecamera {}",
|
||||
"itemWithIndex": "Elemento {}",
|
||||
"error": " (Errore)",
|
||||
"completing": " (Completamento...)",
|
||||
"destination": "Dest: {}",
|
||||
@@ -235,7 +249,14 @@
|
||||
"attempts": "Tentativi: {}",
|
||||
"uploadFailedRetry": "Upload fallito. Tocca riprova per tentare di nuovo.",
|
||||
"retryUpload": "Riprova upload",
|
||||
"clearAll": "Pulisci Tutto"
|
||||
"clearAll": "Pulisci Tutto",
|
||||
"errorDetails": "Dettagli dell'Errore",
|
||||
"creatingChangeset": " (Creazione changeset...)",
|
||||
"uploading": " (Caricamento...)",
|
||||
"closingChangeset": " (Chiusura changeset...)",
|
||||
"processingPaused": "Elaborazione Coda Sospesa",
|
||||
"pausedDueToOffline": "L'elaborazione dei caricamenti è sospesa perché la modalità offline è abilitata.",
|
||||
"pausedByUser": "L'elaborazione dei caricamenti è sospesa manualmente."
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Fornitori di Tile",
|
||||
@@ -409,15 +430,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",
|
||||
@@ -442,6 +464,7 @@
|
||||
"endSelect": "Fine (seleziona)",
|
||||
"distance": "Distanza: {} km",
|
||||
"routeActive": "Percorso attivo",
|
||||
"locationsTooClose": "Le posizioni di partenza e arrivo sono troppo vicine",
|
||||
"navigationSettings": "Navigazione",
|
||||
"navigationSettingsSubtitle": "Impostazioni pianificazione percorso ed evitamento",
|
||||
"avoidanceDistance": "Distanza di evitamento",
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
"aboutSubtitle": "Informações do aplicativo e créditos",
|
||||
"languageSubtitle": "Escolha seu idioma preferido",
|
||||
"maxNodes": "Máx. de nós desenhados",
|
||||
"maxNodesSubtitle": "Definir um limite superior para o número de nós no mapa (padrão: 250).",
|
||||
"maxNodesSubtitle": "Definir um limite superior para o número de nós no mapa.",
|
||||
"maxNodesWarning": "Você provavelmente não quer fazer isso a menos que tenha certeza absoluta de que tem uma boa razão para isso.",
|
||||
"offlineMode": "Modo Offline",
|
||||
"offlineModeSubtitle": "Desabilitar todas as requisições de rede exceto para áreas locais/offline.",
|
||||
@@ -184,7 +184,8 @@
|
||||
"simulate": "Simular",
|
||||
"productionDescription": "Enviar para o banco de dados OSM ao vivo (visível para todos os usuários)",
|
||||
"sandboxDescription": "Uploads vão para o Sandbox OSM (seguro para testes, redefine regularmente).",
|
||||
"simulateDescription": "Simular uploads (não contacta servidores OSM)"
|
||||
"simulateDescription": "Simular uploads (não contacta servidores OSM)",
|
||||
"cannotChangeWithQueue": "Não é possível alterar o destino de upload enquanto {} itens estão na fila. Limpe a fila primeiro."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "Conta OpenStreetMap",
|
||||
@@ -207,7 +208,20 @@
|
||||
"deleteAccountSubtitle": "Gerencie sua conta OpenStreetMap",
|
||||
"deleteAccountExplanation": "Para excluir sua conta OpenStreetMap, você precisará visitar o site do OpenStreetMap. Isso removerá permanentemente sua conta OSM e todos os dados associados.",
|
||||
"deleteAccountWarning": "Aviso: Esta ação não pode ser desfeita e excluirá permanentemente sua conta OSM.",
|
||||
"goToOSM": "Ir para OpenStreetMap"
|
||||
"goToOSM": "Ir para OpenStreetMap",
|
||||
"accountManagement": "Gerenciamento de Conta",
|
||||
"accountManagementDescription": "Para excluir sua conta do OpenStreetMap, você deve visitar o site do OpenStreetMap apropriado. Isso removerá permanentemente sua conta e todos os dados associados.",
|
||||
"currentDestinationProduction": "Atualmente conectado a: OpenStreetMap de Produção",
|
||||
"currentDestinationSandbox": "Atualmente conectado a: OpenStreetMap Sandbox",
|
||||
"currentDestinationSimulate": "Atualmente em: Modo de simulação (sem conta real)",
|
||||
"viewMessages": "Ver Mensagens no OSM",
|
||||
"unreadMessagesCount": "Você tem {} mensagens não lidas",
|
||||
"noUnreadMessages": "Nenhuma mensagem não lida",
|
||||
"reauthRequired": "Atualizar Autenticação",
|
||||
"reauthExplanation": "Você deve atualizar sua autenticação para receber notificações de mensagens OSM através do aplicativo.",
|
||||
"reauthBenefit": "Isso habilitará pontos de notificação quando você tiver mensagens não lidas no OpenStreetMap.",
|
||||
"reauthNow": "Fazer Agora",
|
||||
"reauthLater": "Mais Tarde"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Fila de Upload",
|
||||
@@ -225,7 +239,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 +249,14 @@
|
||||
"attempts": "Tentativas: {}",
|
||||
"uploadFailedRetry": "Upload falhou. Toque em tentar novamente para tentar novamente.",
|
||||
"retryUpload": "Tentar upload novamente",
|
||||
"clearAll": "Limpar Tudo"
|
||||
"clearAll": "Limpar Tudo",
|
||||
"errorDetails": "Detalhes do Erro",
|
||||
"creatingChangeset": " (Criando changeset...)",
|
||||
"uploading": " (Enviando...)",
|
||||
"closingChangeset": " (Fechando changeset...)",
|
||||
"processingPaused": "Processamento da Fila Pausado",
|
||||
"pausedDueToOffline": "O processamento de upload está pausado porque o modo offline está habilitado.",
|
||||
"pausedByUser": "O processamento de upload está pausado manualmente."
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Provedores de Tiles",
|
||||
@@ -409,15 +430,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",
|
||||
@@ -442,6 +464,7 @@
|
||||
"endSelect": "Fim (selecionar)",
|
||||
"distance": "Distância: {} km",
|
||||
"routeActive": "Rota ativa",
|
||||
"locationsTooClose": "Os locais de início e fim estão muito próximos",
|
||||
"navigationSettings": "Navegação",
|
||||
"navigationSettingsSubtitle": "Configurações de planejamento de rota e evasão",
|
||||
"avoidanceDistance": "Distância de evasão",
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
"aboutSubtitle": "应用程序信息和鸣谢",
|
||||
"languageSubtitle": "选择您的首选语言",
|
||||
"maxNodes": "最大节点绘制数",
|
||||
"maxNodesSubtitle": "设置地图上节点数量的上限(默认:250)。",
|
||||
"maxNodesSubtitle": "设置地图上节点数量的上限。",
|
||||
"maxNodesWarning": "除非您确定有充分的理由,否则您可能不想这样做。",
|
||||
"offlineMode": "离线模式",
|
||||
"offlineModeSubtitle": "禁用除本地/离线区域外的所有网络请求。",
|
||||
@@ -184,7 +184,8 @@
|
||||
"simulate": "模拟",
|
||||
"productionDescription": "上传到实时 OSM 数据库(对所有用户可见)",
|
||||
"sandboxDescription": "上传到 OSM 沙盒(测试安全,定期重置)。",
|
||||
"simulateDescription": "模拟上传(不联系 OSM 服务器)"
|
||||
"simulateDescription": "模拟上传(不联系 OSM 服务器)",
|
||||
"cannotChangeWithQueue": "队列中有 {} 个项目时无法更改上传目标。请先清空队列。"
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "OpenStreetMap 账户",
|
||||
@@ -207,7 +208,20 @@
|
||||
"deleteAccountSubtitle": "管理您的 OpenStreetMap 账户",
|
||||
"deleteAccountExplanation": "要删除您的 OpenStreetMap 账户,您需要访问 OpenStreetMap 网站。这将永久删除您的 OSM 账户和所有相关数据。",
|
||||
"deleteAccountWarning": "警告:此操作无法撤销,将永久删除您的 OSM 账户。",
|
||||
"goToOSM": "前往 OpenStreetMap"
|
||||
"goToOSM": "前往 OpenStreetMap",
|
||||
"accountManagement": "账户管理",
|
||||
"accountManagementDescription": "要删除您的 OpenStreetMap 账户,您需要访问相应的 OpenStreetMap 网站。这将永久删除您的账户和所有相关数据。",
|
||||
"currentDestinationProduction": "当前连接到:生产环境 OpenStreetMap",
|
||||
"currentDestinationSandbox": "当前连接到:沙盒环境 OpenStreetMap",
|
||||
"currentDestinationSimulate": "当前处于:模拟模式(无真实账户)",
|
||||
"viewMessages": "在 OSM 上查看消息",
|
||||
"unreadMessagesCount": "您有 {} 条未读消息",
|
||||
"noUnreadMessages": "没有未读消息",
|
||||
"reauthRequired": "刷新身份验证",
|
||||
"reauthExplanation": "您必须刷新身份验证才能通过应用接收 OSM 消息通知。",
|
||||
"reauthBenefit": "这将在您在 OpenStreetMap 上有未读消息时启用通知点。",
|
||||
"reauthNow": "现在执行",
|
||||
"reauthLater": "稍后"
|
||||
},
|
||||
"queue": {
|
||||
"title": "上传队列",
|
||||
@@ -225,7 +239,7 @@
|
||||
"queueCleared": "队列已清空",
|
||||
"uploadQueueTitle": "上传队列({} 项)",
|
||||
"queueIsEmpty": "队列为空",
|
||||
"cameraWithIndex": "摄像头 {}",
|
||||
"itemWithIndex": "项目 {}",
|
||||
"error": "(错误)",
|
||||
"completing": "(完成中...)",
|
||||
"destination": "目标:{}",
|
||||
@@ -235,7 +249,14 @@
|
||||
"attempts": "尝试次数:{}",
|
||||
"uploadFailedRetry": "上传失败。点击重试再次尝试。",
|
||||
"retryUpload": "重试上传",
|
||||
"clearAll": "全部清空"
|
||||
"clearAll": "全部清空",
|
||||
"errorDetails": "错误详情",
|
||||
"creatingChangeset": " (创建变更集...)",
|
||||
"uploading": " (上传中...)",
|
||||
"closingChangeset": " (关闭变更集...)",
|
||||
"processingPaused": "队列处理已暂停",
|
||||
"pausedDueToOffline": "因为离线模式已启用,上传处理已暂停。",
|
||||
"pausedByUser": "上传处理已手动暂停。"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "瓦片提供商",
|
||||
@@ -409,15 +430,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": "搜索位置",
|
||||
@@ -442,6 +464,7 @@
|
||||
"endSelect": "终点(选择)",
|
||||
"distance": "距离:{} 公里",
|
||||
"routeActive": "路线活跃",
|
||||
"locationsTooClose": "起点和终点位置过于接近",
|
||||
"navigationSettings": "导航",
|
||||
"navigationSettingsSubtitle": "路线规划和回避设置",
|
||||
"avoidanceDistance": "回避距离",
|
||||
|
||||
@@ -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
|
||||
@@ -14,9 +25,18 @@ class PendingUpload {
|
||||
final UploadOperation operation; // Type of operation: create, modify, or delete
|
||||
final int? originalNodeId; // If this is modify/delete, the ID of the original OSM node
|
||||
int? submittedNodeId; // The actual node ID returned by OSM after successful submission
|
||||
int? tempNodeId; // ID of temporary node created in cache (for specific cleanup)
|
||||
int attempts;
|
||||
bool error;
|
||||
bool completing; // True when upload succeeded but item is showing checkmark briefly
|
||||
bool error; // DEPRECATED: Use uploadState instead
|
||||
String? errorMessage; // Detailed error message for debugging
|
||||
bool completing; // DEPRECATED: Use uploadState instead
|
||||
UploadState uploadState; // Current state in the upload pipeline
|
||||
String? changesetId; // ID of changeset that needs closing
|
||||
DateTime? nodeOperationCompletedAt; // When node operation completed (start of 59-minute countdown)
|
||||
int changesetCloseAttempts; // Number of changeset close attempts
|
||||
DateTime? lastChangesetCloseAttemptAt; // When we last tried to close changeset (for retry timing)
|
||||
int nodeSubmissionAttempts; // Number of node submission attempts (separate from overall attempts)
|
||||
DateTime? lastNodeSubmissionAttemptAt; // When we last tried to submit node (for retry timing)
|
||||
|
||||
PendingUpload({
|
||||
required this.coord,
|
||||
@@ -27,9 +47,18 @@ class PendingUpload {
|
||||
required this.operation,
|
||||
this.originalNodeId,
|
||||
this.submittedNodeId,
|
||||
this.tempNodeId,
|
||||
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 +77,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 +137,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
|
||||
@@ -99,9 +257,18 @@ class PendingUpload {
|
||||
'operation': operation.index,
|
||||
'originalNodeId': originalNodeId,
|
||||
'submittedNodeId': submittedNodeId,
|
||||
'tempNodeId': tempNodeId,
|
||||
'attempts': attempts,
|
||||
'error': error,
|
||||
'errorMessage': errorMessage,
|
||||
'completing': completing,
|
||||
'uploadState': uploadState.index,
|
||||
'changesetId': changesetId,
|
||||
'nodeOperationCompletedAt': nodeOperationCompletedAt?.millisecondsSinceEpoch,
|
||||
'changesetCloseAttempts': changesetCloseAttempts,
|
||||
'lastChangesetCloseAttemptAt': lastChangesetCloseAttemptAt?.millisecondsSinceEpoch,
|
||||
'nodeSubmissionAttempts': nodeSubmissionAttempts,
|
||||
'lastNodeSubmissionAttemptAt': lastNodeSubmissionAttemptAt?.millisecondsSinceEpoch,
|
||||
};
|
||||
|
||||
factory PendingUpload.fromJson(Map<String, dynamic> j) => PendingUpload(
|
||||
@@ -121,9 +288,36 @@ class PendingUpload {
|
||||
: (j['originalNodeId'] != null ? UploadOperation.modify : UploadOperation.create), // Legacy compatibility
|
||||
originalNodeId: j['originalNodeId'],
|
||||
submittedNodeId: j['submittedNodeId'],
|
||||
tempNodeId: j['tempNodeId'],
|
||||
attempts: j['attempts'] ?? 0,
|
||||
error: j['error'] ?? false,
|
||||
errorMessage: j['errorMessage'], // Can be null for legacy entries
|
||||
completing: j['completing'] ?? false, // Default to false for legacy entries
|
||||
uploadState: j['uploadState'] != null
|
||||
? UploadState.values[j['uploadState']]
|
||||
: _migrateFromLegacyFields(j), // Migrate from legacy error/completing fields
|
||||
changesetId: j['changesetId'],
|
||||
nodeOperationCompletedAt: j['nodeOperationCompletedAt'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(j['nodeOperationCompletedAt'])
|
||||
: null,
|
||||
changesetCloseAttempts: j['changesetCloseAttempts'] ?? 0,
|
||||
lastChangesetCloseAttemptAt: j['lastChangesetCloseAttemptAt'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(j['lastChangesetCloseAttemptAt'])
|
||||
: null,
|
||||
nodeSubmissionAttempts: j['nodeSubmissionAttempts'] ?? 0,
|
||||
lastNodeSubmissionAttemptAt: j['lastNodeSubmissionAttemptAt'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(j['lastNodeSubmissionAttemptAt'])
|
||||
: null,
|
||||
);
|
||||
|
||||
// Helper to migrate legacy queue items to new state system
|
||||
static UploadState _migrateFromLegacyFields(Map<String, dynamic> j) {
|
||||
final error = j['error'] ?? false;
|
||||
final completing = j['completing'] ?? false;
|
||||
|
||||
if (completing) return UploadState.complete;
|
||||
if (error) return UploadState.error;
|
||||
return UploadState.pending;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,75 +101,11 @@ class AboutScreen extends StatelessWidget {
|
||||
_buildLinkText(context, 'Source Code', 'https://github.com/FoggedLens/deflock-app'),
|
||||
const SizedBox(height: 8),
|
||||
_buildLinkText(context, 'Contact', 'https://deflock.me/contact'),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Divider for account management section
|
||||
Divider(
|
||||
color: Theme.of(context).dividerColor.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Account deletion link (less prominent)
|
||||
_buildAccountDeletionLink(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAccountDeletionLink(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _showDeleteAccountDialog(context, locService),
|
||||
child: Text(
|
||||
locService.t('auth.deleteAccount'),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error.withOpacity(0.7),
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteAccountDialog(BuildContext context, LocalizationService locService) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('auth.deleteAccount')),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(locService.t('auth.deleteAccountExplanation')),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
locService.t('auth.deleteAccountWarning'),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(locService.t('actions.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_launchUrl('https://www.openstreetmap.org/account/deletion', context);
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text(locService.t('auth.goToOSM')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLinkText(BuildContext context, String text, String url) {
|
||||
return GestureDetector(
|
||||
|
||||
128
lib/screens/coordinators/map_interaction_handler.dart
Normal file
128
lib/screens/coordinators/map_interaction_handler.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../models/suspected_location.dart';
|
||||
import '../../models/search_result.dart';
|
||||
|
||||
/// Handles map interaction events including node taps, suspected location taps,
|
||||
/// and search result selection with appropriate map animations and state updates.
|
||||
class MapInteractionHandler {
|
||||
|
||||
/// Handle node tap with highlighting and map centering
|
||||
void handleNodeTap({
|
||||
required BuildContext context,
|
||||
required OsmNode node,
|
||||
required AnimatedMapController mapController,
|
||||
required Function(int?) onSelectedNodeChanged,
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Disable follow-me when user taps a node
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
// Set the selected node for highlighting
|
||||
onSelectedNodeChanged(node.id);
|
||||
|
||||
// Center the map on the selected node with smooth animation
|
||||
try {
|
||||
mapController.animateTo(
|
||||
dest: node.coord,
|
||||
zoom: mapController.mapController.camera.zoom,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[MapInteractionHandler] Could not center map on node: $e');
|
||||
}
|
||||
|
||||
// Note: Edit session is NOT started here - only when user explicitly presses Edit button
|
||||
}
|
||||
|
||||
/// Handle suspected location tap with selection and highlighting
|
||||
void handleSuspectedLocationTap({
|
||||
required BuildContext context,
|
||||
required SuspectedLocation location,
|
||||
required AnimatedMapController mapController,
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
debugPrint('[MapInteractionHandler] Suspected location tapped: ${location.ticketNo}');
|
||||
|
||||
// Disable follow-me when user taps a suspected location
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
// Select the suspected location for highlighting
|
||||
appState.selectSuspectedLocation(location);
|
||||
|
||||
// Center the map on the suspected location
|
||||
try {
|
||||
mapController.animateTo(
|
||||
dest: location.centroid,
|
||||
zoom: mapController.mapController.camera.zoom.clamp(16.0, 18.0), // Zoom in if needed
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[MapInteractionHandler] Could not center map on suspected location: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle search result selection with map animation and routing setup
|
||||
void handleSearchResultSelection({
|
||||
required BuildContext context,
|
||||
required SearchResult result,
|
||||
required AnimatedMapController mapController,
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
debugPrint('[MapInteractionHandler] Search result selected: ${result.displayName}');
|
||||
|
||||
// Disable follow-me to prevent interference with selection
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
// Update app state with the selection
|
||||
appState.selectSearchResult(result);
|
||||
|
||||
// Animate to the selected location
|
||||
try {
|
||||
mapController.animateTo(
|
||||
dest: result.coordinates,
|
||||
zoom: 16.0, // Good zoom level for search results
|
||||
duration: const Duration(milliseconds: 600),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[MapInteractionHandler] Could not animate to search result: $e');
|
||||
// Fallback to immediate positioning
|
||||
try {
|
||||
mapController.mapController.move(result.coordinates, 16.0);
|
||||
} catch (_) {
|
||||
debugPrint('[MapInteractionHandler] Could not move to search result');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear selected node highlighting
|
||||
void clearSelectedNode({
|
||||
required Function(int?) onSelectedNodeChanged,
|
||||
}) {
|
||||
onSelectedNodeChanged(null);
|
||||
}
|
||||
|
||||
/// Handle user gesture on map (clears selections)
|
||||
void handleUserGesture({
|
||||
required BuildContext context,
|
||||
required Function(int?) onSelectedNodeChanged,
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Clear selected node highlighting
|
||||
onSelectedNodeChanged(null);
|
||||
|
||||
// Clear suspected location selection
|
||||
appState.clearSuspectedLocationSelection();
|
||||
}
|
||||
}
|
||||
242
lib/screens/coordinators/navigation_coordinator.dart
Normal file
242
lib/screens/coordinators/navigation_coordinator.dart
Normal file
@@ -0,0 +1,242 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../app_state.dart' show AppState, FollowMeMode;
|
||||
import '../../widgets/map_view.dart';
|
||||
import '../../dev_config.dart';
|
||||
|
||||
/// Coordinates all navigation and routing functionality including route planning,
|
||||
/// map centering, zoom management, and route visualization.
|
||||
class NavigationCoordinator {
|
||||
FollowMeMode? _previousFollowMeMode; // Track follow-me mode before overview
|
||||
|
||||
/// Start a route with automatic follow-me detection and appropriate centering
|
||||
void startRoute({
|
||||
required BuildContext context,
|
||||
required AnimatedMapController mapController,
|
||||
required GlobalKey<MapViewState>? mapViewKey,
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Get user location and check if we should auto-enable follow-me
|
||||
LatLng? userLocation;
|
||||
bool enableFollowMe = false;
|
||||
|
||||
try {
|
||||
userLocation = mapViewKey?.currentState?.getUserLocation();
|
||||
if (userLocation != null && appState.shouldAutoEnableFollowMe(userLocation)) {
|
||||
debugPrint('[NavigationCoordinator] Auto-enabling follow-me mode - user within 1km of start');
|
||||
appState.setFollowMeMode(FollowMeMode.follow);
|
||||
enableFollowMe = true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[NavigationCoordinator] Could not get user location for auto follow-me: $e');
|
||||
}
|
||||
|
||||
// Start the route
|
||||
appState.startRoute();
|
||||
|
||||
// Zoom to level 14 and center appropriately
|
||||
_zoomAndCenterForRoute(
|
||||
mapController: mapController,
|
||||
followMeEnabled: enableFollowMe,
|
||||
userLocation: userLocation,
|
||||
routeStart: appState.routeStart,
|
||||
);
|
||||
}
|
||||
|
||||
/// Resume a route with appropriate centering
|
||||
void resumeRoute({
|
||||
required BuildContext context,
|
||||
required AnimatedMapController mapController,
|
||||
required GlobalKey<MapViewState>? mapViewKey,
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Hide the overview
|
||||
appState.hideRouteOverview();
|
||||
|
||||
// Get user location to determine centering and follow-me behavior
|
||||
LatLng? userLocation;
|
||||
try {
|
||||
userLocation = mapViewKey?.currentState?.getUserLocation();
|
||||
} catch (e) {
|
||||
debugPrint('[NavigationCoordinator] Could not get user location for route resume: $e');
|
||||
}
|
||||
|
||||
// Determine if user is near the route path
|
||||
bool isNearRoute = false;
|
||||
if (userLocation != null && appState.routePath != null) {
|
||||
isNearRoute = _isUserNearRoute(userLocation, appState.routePath!);
|
||||
}
|
||||
|
||||
// Choose center point and follow-me behavior
|
||||
LatLng centerPoint;
|
||||
bool shouldEnableFollowMe = false;
|
||||
|
||||
if (isNearRoute && userLocation != null) {
|
||||
// User is near route - center on GPS and enable follow-me
|
||||
centerPoint = userLocation;
|
||||
shouldEnableFollowMe = true;
|
||||
debugPrint('[NavigationCoordinator] User near route - centering on GPS with follow-me');
|
||||
} else {
|
||||
// User far from route or no GPS - center on route start
|
||||
centerPoint = appState.routeStart ?? userLocation ?? LatLng(0, 0);
|
||||
shouldEnableFollowMe = false;
|
||||
debugPrint('[NavigationCoordinator] User far from route - centering on start without follow-me');
|
||||
}
|
||||
|
||||
// Apply the centering and zoom
|
||||
try {
|
||||
mapController.animateTo(
|
||||
dest: centerPoint,
|
||||
zoom: kResumeNavigationZoomLevel,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[NavigationCoordinator] Could not animate to resume location: $e');
|
||||
}
|
||||
|
||||
// Set follow-me mode based on proximity
|
||||
if (shouldEnableFollowMe) {
|
||||
// Restore previous follow-me mode if user is near route
|
||||
final modeToRestore = _previousFollowMeMode ?? FollowMeMode.follow;
|
||||
appState.setFollowMeMode(modeToRestore);
|
||||
debugPrint('[NavigationCoordinator] Restored follow-me mode: $modeToRestore');
|
||||
} else {
|
||||
// Keep follow-me off if user is far from route
|
||||
debugPrint('[NavigationCoordinator] Keeping follow-me off - user far from route');
|
||||
}
|
||||
|
||||
// Clear stored follow-me mode
|
||||
_previousFollowMeMode = null;
|
||||
}
|
||||
|
||||
/// Handle navigation button press with route overview logic
|
||||
void handleNavigationButtonPress({
|
||||
required BuildContext context,
|
||||
required AnimatedMapController mapController,
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
if (appState.showRouteButton) {
|
||||
// Route button - show route overview and zoom to show route
|
||||
// Store current follow-me mode and disable it to prevent unexpected map jumps during overview
|
||||
_previousFollowMeMode = appState.followMeMode;
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
appState.showRouteOverview();
|
||||
zoomToShowFullRoute(appState: appState, mapController: mapController);
|
||||
} else {
|
||||
// Search button - toggle search mode
|
||||
if (appState.isInSearchMode) {
|
||||
// Exit search mode
|
||||
appState.clearSearchResults();
|
||||
} else {
|
||||
// Enter search mode
|
||||
try {
|
||||
final center = mapController.mapController.camera.center;
|
||||
appState.enterSearchMode(center);
|
||||
} catch (e) {
|
||||
debugPrint('[NavigationCoordinator] Could not get map center for search: $e');
|
||||
// Fallback to default location
|
||||
appState.enterSearchMode(LatLng(37.7749, -122.4194));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zoom to show the full route between start and end points
|
||||
void zoomToShowFullRoute({
|
||||
required AppState appState,
|
||||
required AnimatedMapController mapController,
|
||||
}) {
|
||||
if (appState.routeStart == null || appState.routeEnd == null) return;
|
||||
|
||||
try {
|
||||
// Calculate the bounds of the route
|
||||
final start = appState.routeStart!;
|
||||
final end = appState.routeEnd!;
|
||||
|
||||
// Find the center point between start and end
|
||||
final centerLat = (start.latitude + end.latitude) / 2;
|
||||
final centerLng = (start.longitude + end.longitude) / 2;
|
||||
final center = LatLng(centerLat, centerLng);
|
||||
|
||||
// Calculate distance between points to determine appropriate zoom
|
||||
final distance = const Distance().as(LengthUnit.Meter, start, end);
|
||||
double zoom;
|
||||
if (distance < 500) {
|
||||
zoom = 16.0;
|
||||
} else if (distance < 2000) {
|
||||
zoom = 14.0;
|
||||
} else if (distance < 10000) {
|
||||
zoom = 12.0;
|
||||
} else {
|
||||
zoom = 10.0;
|
||||
}
|
||||
|
||||
debugPrint('[NavigationCoordinator] Zooming to show full route: ${distance.toInt()}m, zoom $zoom');
|
||||
|
||||
mapController.animateTo(
|
||||
dest: center,
|
||||
zoom: zoom,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[NavigationCoordinator] Could not zoom to show full route: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if user location is near the route path
|
||||
bool _isUserNearRoute(LatLng userLocation, List<LatLng> routePath) {
|
||||
if (routePath.isEmpty) return false;
|
||||
|
||||
// Check distance to each point in the route path
|
||||
for (final routePoint in routePath) {
|
||||
final distance = const Distance().as(LengthUnit.Meter, userLocation, routePoint);
|
||||
if (distance <= kRouteProximityThresholdMeters) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Internal method to zoom and center for route start/resume
|
||||
void _zoomAndCenterForRoute({
|
||||
required AnimatedMapController mapController,
|
||||
required bool followMeEnabled,
|
||||
required LatLng? userLocation,
|
||||
required LatLng? routeStart,
|
||||
}) {
|
||||
try {
|
||||
LatLng centerLocation;
|
||||
|
||||
if (followMeEnabled && userLocation != null) {
|
||||
// Center on user if follow-me is enabled
|
||||
centerLocation = userLocation;
|
||||
debugPrint('[NavigationCoordinator] Centering on user location for route start');
|
||||
} else if (routeStart != null) {
|
||||
// Center on start pin if user is far away or no GPS
|
||||
centerLocation = routeStart;
|
||||
debugPrint('[NavigationCoordinator] Centering on route start pin');
|
||||
} else {
|
||||
debugPrint('[NavigationCoordinator] No valid location to center on');
|
||||
return;
|
||||
}
|
||||
|
||||
// Animate to zoom 14 and center location
|
||||
mapController.animateTo(
|
||||
dest: centerLocation,
|
||||
zoom: 14.0,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[NavigationCoordinator] Could not zoom/center for route: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
251
lib/screens/coordinators/sheet_coordinator.dart
Normal file
251
lib/screens/coordinators/sheet_coordinator.dart
Normal file
@@ -0,0 +1,251 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../../widgets/add_node_sheet.dart';
|
||||
import '../../widgets/edit_node_sheet.dart';
|
||||
import '../../widgets/navigation_sheet.dart';
|
||||
import '../../widgets/measured_sheet.dart';
|
||||
|
||||
/// Coordinates all bottom sheet operations including opening, closing, height tracking,
|
||||
/// and sheet-related validation logic.
|
||||
class SheetCoordinator {
|
||||
// Track sheet heights for map positioning
|
||||
double _addSheetHeight = 0.0;
|
||||
double _editSheetHeight = 0.0;
|
||||
double _tagSheetHeight = 0.0;
|
||||
double _navigationSheetHeight = 0.0;
|
||||
|
||||
// Track sheet state for auto-open logic
|
||||
bool _editSheetShown = false;
|
||||
bool _navigationSheetShown = false;
|
||||
|
||||
// Flag to prevent map bounce when transitioning from tag sheet to edit sheet
|
||||
bool _transitioningToEdit = false;
|
||||
|
||||
// Getters for accessing heights
|
||||
double get addSheetHeight => _addSheetHeight;
|
||||
double get editSheetHeight => _editSheetHeight;
|
||||
double get tagSheetHeight => _tagSheetHeight;
|
||||
double get navigationSheetHeight => _navigationSheetHeight;
|
||||
bool get editSheetShown => _editSheetShown;
|
||||
bool get navigationSheetShown => _navigationSheetShown;
|
||||
bool get transitioningToEdit => _transitioningToEdit;
|
||||
|
||||
/// Get the currently active sheet height for map positioning
|
||||
double get activeSheetHeight {
|
||||
if (_addSheetHeight > 0) return _addSheetHeight;
|
||||
if (_editSheetHeight > 0) return _editSheetHeight;
|
||||
if (_navigationSheetHeight > 0) return _navigationSheetHeight;
|
||||
return _tagSheetHeight;
|
||||
}
|
||||
|
||||
/// Update sheet state tracking
|
||||
void setEditSheetShown(bool shown) => _editSheetShown = shown;
|
||||
void setNavigationSheetShown(bool shown) => _navigationSheetShown = shown;
|
||||
void setTransitioningToEdit(bool transitioning) => _transitioningToEdit = transitioning;
|
||||
|
||||
/// Open the add node sheet with validation and setup
|
||||
void openAddNodeSheet({
|
||||
required BuildContext context,
|
||||
required GlobalKey<ScaffoldState> scaffoldKey,
|
||||
required AnimatedMapController mapController,
|
||||
required bool isNodeLimitActive,
|
||||
required VoidCallback onStateChanged,
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Check minimum zoom level before opening sheet
|
||||
final currentZoom = mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForNodeEditingSheets) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('editNode.zoomInRequiredMessage',
|
||||
params: [kMinZoomForNodeEditingSheets.toString()])
|
||||
),
|
||||
duration: const Duration(seconds: 4),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if node limit is active and warn user
|
||||
if (isNodeLimitActive) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('nodeLimitIndicator.editingDisabledMessage')
|
||||
),
|
||||
duration: const Duration(seconds: 4),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable follow-me when adding a node so the map doesn't jump around
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
appState.startAddSession();
|
||||
final session = appState.session!; // guaranteed non‑null now
|
||||
|
||||
final controller = scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom, // Only safe area, no keyboard
|
||||
),
|
||||
child: MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
_addSheetHeight = height + MediaQuery.of(context).padding.bottom;
|
||||
onStateChanged();
|
||||
},
|
||||
child: AddNodeSheet(session: session),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Reset height when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
_addSheetHeight = 0.0;
|
||||
onStateChanged();
|
||||
|
||||
// Handle dismissal by canceling session if still active
|
||||
final appState = context.read<AppState>();
|
||||
if (appState.session != null) {
|
||||
debugPrint('[SheetCoordinator] AddNodeSheet dismissed - canceling session');
|
||||
appState.cancelSession();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the edit node sheet with map centering
|
||||
void openEditNodeSheet({
|
||||
required BuildContext context,
|
||||
required GlobalKey<ScaffoldState> scaffoldKey,
|
||||
required AnimatedMapController mapController,
|
||||
required VoidCallback onStateChanged,
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Disable follow-me when editing a node so the map doesn't jump around
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
final session = appState.editSession!; // should be non-null when this is called
|
||||
|
||||
// Center map on the node being edited
|
||||
try {
|
||||
mapController.animateTo(
|
||||
dest: session.originalNode.coord,
|
||||
zoom: mapController.mapController.camera.zoom,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (_) {
|
||||
// Map controller not ready, fallback to immediate move
|
||||
try {
|
||||
mapController.mapController.move(session.originalNode.coord, mapController.mapController.camera.zoom);
|
||||
} catch (_) {
|
||||
// Controller really not ready, skip centering
|
||||
}
|
||||
}
|
||||
|
||||
// Set transition flag to prevent map bounce
|
||||
_transitioningToEdit = true;
|
||||
|
||||
final controller = scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
final fullHeight = height + MediaQuery.of(context).viewInsets.bottom + MediaQuery.of(context).padding.bottom;
|
||||
_editSheetHeight = fullHeight;
|
||||
onStateChanged();
|
||||
},
|
||||
child: EditNodeSheet(session: session),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Reset height and transition flag when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
_editSheetHeight = 0.0;
|
||||
_transitioningToEdit = false;
|
||||
onStateChanged();
|
||||
|
||||
// Handle dismissal by canceling session if still active
|
||||
final appState = context.read<AppState>();
|
||||
if (appState.editSession != null) {
|
||||
debugPrint('[SheetCoordinator] EditNodeSheet dismissed - canceling edit session');
|
||||
appState.cancelEditSession();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the navigation sheet for search/routing
|
||||
void openNavigationSheet({
|
||||
required BuildContext context,
|
||||
required GlobalKey<ScaffoldState> scaffoldKey,
|
||||
required VoidCallback onStateChanged,
|
||||
required VoidCallback onStartRoute,
|
||||
required VoidCallback onResumeRoute,
|
||||
}) {
|
||||
final controller = scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
final fullHeight = height + MediaQuery.of(context).viewInsets.bottom + MediaQuery.of(context).padding.bottom;
|
||||
_navigationSheetHeight = fullHeight;
|
||||
onStateChanged();
|
||||
},
|
||||
child: NavigationSheet(
|
||||
onStartRoute: onStartRoute,
|
||||
onResumeRoute: onResumeRoute,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Reset height when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
_navigationSheetHeight = 0.0;
|
||||
onStateChanged();
|
||||
|
||||
// Handle different dismissal scenarios (from original HomeScreen logic)
|
||||
if (context.mounted) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
if (appState.isSettingSecondPoint) {
|
||||
// If user dismisses sheet while setting second point, cancel everything
|
||||
debugPrint('[SheetCoordinator] Sheet dismissed during second point selection - canceling navigation');
|
||||
appState.cancelNavigation();
|
||||
} else if (appState.isInRouteMode && appState.showingOverview) {
|
||||
// If we're in route active mode and showing overview, just hide the overview
|
||||
debugPrint('[SheetCoordinator] Sheet dismissed during route overview - hiding overview');
|
||||
appState.hideRouteOverview();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Update tag sheet height (called externally)
|
||||
void updateTagSheetHeight(double height, VoidCallback onStateChanged) {
|
||||
_tagSheetHeight = height;
|
||||
onStateChanged();
|
||||
}
|
||||
|
||||
/// Reset tag sheet height
|
||||
void resetTagSheetHeight(VoidCallback onStateChanged) {
|
||||
_tagSheetHeight = 0.0;
|
||||
onStateChanged();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -25,6 +24,9 @@ import '../models/osm_node.dart';
|
||||
import '../models/suspected_location.dart';
|
||||
import '../models/search_result.dart';
|
||||
import '../services/changelog_service.dart';
|
||||
import 'coordinators/sheet_coordinator.dart';
|
||||
import 'coordinators/navigation_coordinator.dart';
|
||||
import 'coordinators/map_interaction_handler.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
@@ -37,17 +39,14 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
final GlobalKey<MapViewState> _mapViewKey = GlobalKey<MapViewState>();
|
||||
late final AnimatedMapController _mapController;
|
||||
bool _editSheetShown = false;
|
||||
bool _navigationSheetShown = false;
|
||||
|
||||
// Track sheet heights for map positioning
|
||||
double _addSheetHeight = 0.0;
|
||||
double _editSheetHeight = 0.0;
|
||||
double _tagSheetHeight = 0.0;
|
||||
double _navigationSheetHeight = 0.0;
|
||||
// Coordinators for managing different aspects of the home screen
|
||||
late final SheetCoordinator _sheetCoordinator;
|
||||
late final NavigationCoordinator _navigationCoordinator;
|
||||
late final MapInteractionHandler _mapInteractionHandler;
|
||||
|
||||
// Flag to prevent map bounce when transitioning from tag sheet to edit sheet
|
||||
bool _transitioningToEdit = false;
|
||||
// Track node limit state for button disabling
|
||||
bool _isNodeLimitActive = false;
|
||||
|
||||
// Track selected node for highlighting
|
||||
int? _selectedNodeId;
|
||||
@@ -59,6 +58,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mapController = AnimatedMapController(vsync: this);
|
||||
_sheetCoordinator = SheetCoordinator();
|
||||
_navigationCoordinator = NavigationCoordinator();
|
||||
_mapInteractionHandler = MapInteractionHandler();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -102,88 +104,18 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
|
||||
void _openAddNodeSheet() {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Check minimum zoom level before opening sheet
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForNodeEditingSheets) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('editNode.zoomInRequiredMessage',
|
||||
params: [kMinZoomForNodeEditingSheets.toString()])
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable follow-me when adding a camera so the map doesn't jump around
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
appState.startAddSession();
|
||||
final session = appState.session!; // guaranteed non‑null now
|
||||
|
||||
final controller = _scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom, // Only safe area, no keyboard
|
||||
),
|
||||
child: MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
setState(() {
|
||||
_addSheetHeight = height + MediaQuery.of(context).padding.bottom;
|
||||
});
|
||||
},
|
||||
child: AddNodeSheet(session: session),
|
||||
),
|
||||
),
|
||||
_sheetCoordinator.openAddNodeSheet(
|
||||
context: context,
|
||||
scaffoldKey: _scaffoldKey,
|
||||
mapController: _mapController,
|
||||
isNodeLimitActive: _isNodeLimitActive,
|
||||
onStateChanged: () => setState(() {}),
|
||||
);
|
||||
|
||||
// Reset height when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
setState(() {
|
||||
_addSheetHeight = 0.0;
|
||||
});
|
||||
|
||||
// Handle dismissal by canceling session if still active
|
||||
final appState = context.read<AppState>();
|
||||
if (appState.session != null) {
|
||||
debugPrint('[HomeScreen] AddNodeSheet dismissed - canceling session');
|
||||
appState.cancelSession();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _openEditNodeSheet() {
|
||||
final appState = context.read<AppState>();
|
||||
// Disable follow-me when editing a camera so the map doesn't jump around
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
final session = appState.editSession!; // should be non-null when this is called
|
||||
|
||||
// Center map on the node being edited (same animation as openNodeTagSheet)
|
||||
try {
|
||||
_mapController.animateTo(
|
||||
dest: session.originalNode.coord,
|
||||
zoom: _mapController.mapController.camera.zoom,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (_) {
|
||||
// Map controller not ready, fallback to immediate move
|
||||
try {
|
||||
_mapController.mapController.move(session.originalNode.coord, _mapController.mapController.camera.zoom);
|
||||
} catch (_) {
|
||||
// Controller really not ready, skip centering
|
||||
}
|
||||
}
|
||||
|
||||
// Set transition flag to prevent map bounce
|
||||
_transitioningToEdit = true;
|
||||
|
||||
// Close any existing tag sheet first
|
||||
if (_tagSheetHeight > 0) {
|
||||
if (_sheetCoordinator.tagSheetHeight > 0) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@@ -191,84 +123,31 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
if (!mounted) return;
|
||||
|
||||
final controller = _scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom, // Only safe area, no keyboard
|
||||
),
|
||||
child: MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
setState(() {
|
||||
_editSheetHeight = height + MediaQuery.of(context).padding.bottom;
|
||||
// Clear transition flag and reset tag sheet height once edit sheet starts sizing
|
||||
if (height > 0 && _transitioningToEdit) {
|
||||
_transitioningToEdit = false;
|
||||
_tagSheetHeight = 0.0; // Now safe to reset
|
||||
_selectedNodeId = null; // Clear selection when moving to edit
|
||||
}
|
||||
});
|
||||
},
|
||||
child: EditNodeSheet(session: session),
|
||||
),
|
||||
),
|
||||
_sheetCoordinator.openEditNodeSheet(
|
||||
context: context,
|
||||
scaffoldKey: _scaffoldKey,
|
||||
mapController: _mapController,
|
||||
onStateChanged: () {
|
||||
setState(() {
|
||||
// Clear tag sheet height and selected node when transitioning
|
||||
if (_sheetCoordinator.editSheetHeight > 0 && _sheetCoordinator.transitioningToEdit) {
|
||||
_sheetCoordinator.resetTagSheetHeight(() {});
|
||||
_selectedNodeId = null; // Clear selection when moving to edit
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Reset height when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
setState(() {
|
||||
_editSheetHeight = 0.0;
|
||||
_transitioningToEdit = false;
|
||||
});
|
||||
|
||||
// Handle dismissal by canceling edit session if still active
|
||||
final appState = context.read<AppState>();
|
||||
if (appState.editSession != null) {
|
||||
debugPrint('[HomeScreen] EditNodeSheet dismissed - canceling edit session');
|
||||
appState.cancelEditSession();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _openNavigationSheet() {
|
||||
final controller = _scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom, // Only safe area, no keyboard
|
||||
),
|
||||
child: MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
setState(() {
|
||||
_navigationSheetHeight = height + MediaQuery.of(context).padding.bottom;
|
||||
});
|
||||
},
|
||||
child: NavigationSheet(
|
||||
onStartRoute: _onStartRoute,
|
||||
onResumeRoute: _onResumeRoute,
|
||||
),
|
||||
),
|
||||
),
|
||||
_sheetCoordinator.openNavigationSheet(
|
||||
context: context,
|
||||
scaffoldKey: _scaffoldKey,
|
||||
onStateChanged: () => setState(() {}),
|
||||
onStartRoute: _onStartRoute,
|
||||
onResumeRoute: _onResumeRoute,
|
||||
);
|
||||
|
||||
// Reset height when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
setState(() {
|
||||
_navigationSheetHeight = 0.0;
|
||||
});
|
||||
|
||||
// Handle different dismissal scenarios
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
if (appState.isSettingSecondPoint) {
|
||||
// If user dismisses sheet while setting second point, cancel everything
|
||||
debugPrint('[HomeScreen] Sheet dismissed during second point selection - canceling navigation');
|
||||
appState.cancelNavigation();
|
||||
} else if (appState.isInRouteMode && appState.showingOverview) {
|
||||
// If we're in route active mode and showing overview, just hide the overview
|
||||
debugPrint('[HomeScreen] Sheet dismissed during route overview - hiding overview');
|
||||
appState.hideRouteOverview();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check for and display welcome/changelog popup
|
||||
@@ -331,28 +210,11 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
|
||||
void _onStartRoute() {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Get user location and check if we should auto-enable follow-me
|
||||
LatLng? userLocation;
|
||||
bool enableFollowMe = false;
|
||||
|
||||
try {
|
||||
userLocation = _mapViewKey.currentState?.getUserLocation();
|
||||
if (userLocation != null && appState.shouldAutoEnableFollowMe(userLocation)) {
|
||||
debugPrint('[HomeScreen] Auto-enabling follow-me mode - user within 1km of start');
|
||||
appState.setFollowMeMode(FollowMeMode.follow);
|
||||
enableFollowMe = true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[HomeScreen] Could not get user location for auto follow-me: $e');
|
||||
}
|
||||
|
||||
// Start the route
|
||||
appState.startRoute();
|
||||
|
||||
// Zoom to level 14 and center appropriately
|
||||
_zoomAndCenterForRoute(enableFollowMe, userLocation, appState.routeStart);
|
||||
_navigationCoordinator.startRoute(
|
||||
context: context,
|
||||
mapController: _mapController,
|
||||
mapViewKey: _mapViewKey,
|
||||
);
|
||||
}
|
||||
|
||||
void _zoomAndCenterForRoute(bool followMeEnabled, LatLng? userLocation, LatLng? routeStart) {
|
||||
@@ -385,154 +247,51 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
|
||||
void _onResumeRoute() {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Hide the overview
|
||||
appState.hideRouteOverview();
|
||||
|
||||
// Zoom and center for resumed route
|
||||
// For resume, we always center on user if GPS is available, otherwise start pin
|
||||
LatLng? userLocation;
|
||||
try {
|
||||
userLocation = _mapViewKey.currentState?.getUserLocation();
|
||||
} catch (e) {
|
||||
debugPrint('[HomeScreen] Could not get user location for route resume: $e');
|
||||
}
|
||||
|
||||
_zoomAndCenterForRoute(
|
||||
appState.followMeMode != FollowMeMode.off, // Use current follow-me state
|
||||
userLocation,
|
||||
appState.routeStart
|
||||
_navigationCoordinator.resumeRoute(
|
||||
context: context,
|
||||
mapController: _mapController,
|
||||
mapViewKey: _mapViewKey,
|
||||
);
|
||||
}
|
||||
|
||||
void _zoomToShowFullRoute(AppState appState) {
|
||||
if (appState.routeStart == null || appState.routeEnd == null) return;
|
||||
|
||||
try {
|
||||
// Calculate the bounds of the route
|
||||
final start = appState.routeStart!;
|
||||
final end = appState.routeEnd!;
|
||||
|
||||
// Find the center point between start and end
|
||||
final centerLat = (start.latitude + end.latitude) / 2;
|
||||
final centerLng = (start.longitude + end.longitude) / 2;
|
||||
final center = LatLng(centerLat, centerLng);
|
||||
|
||||
// Calculate distance between points to determine appropriate zoom
|
||||
final distance = const Distance().as(LengthUnit.Meter, start, end);
|
||||
double zoom;
|
||||
if (distance < 500) {
|
||||
zoom = 16.0;
|
||||
} else if (distance < 2000) {
|
||||
zoom = 14.0;
|
||||
} else if (distance < 10000) {
|
||||
zoom = 12.0;
|
||||
} else {
|
||||
zoom = 10.0;
|
||||
}
|
||||
|
||||
debugPrint('[HomeScreen] Zooming to show full route - distance: ${distance.toStringAsFixed(0)}m, zoom: $zoom');
|
||||
|
||||
_mapController.animateTo(
|
||||
dest: center,
|
||||
zoom: zoom,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[HomeScreen] Could not zoom to show full route: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void _onNavigationButtonPressed() {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
debugPrint('[HomeScreen] Navigation button pressed - showRouteButton: ${appState.showRouteButton}, navigationMode: ${appState.navigationMode}');
|
||||
|
||||
if (appState.showRouteButton) {
|
||||
// Route button - show route overview and zoom to show route
|
||||
debugPrint('[HomeScreen] Showing route overview');
|
||||
appState.showRouteOverview();
|
||||
|
||||
// Zoom out a bit to show the full route when viewing overview
|
||||
_zoomToShowFullRoute(appState);
|
||||
_navigationCoordinator.zoomToShowFullRoute(
|
||||
appState: appState,
|
||||
mapController: _mapController,
|
||||
);
|
||||
} else {
|
||||
// Search button
|
||||
if (appState.offlineMode) {
|
||||
// Show offline snackbar instead of entering search mode
|
||||
debugPrint('[HomeScreen] Search disabled - offline mode');
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Search not available while offline'),
|
||||
duration: const Duration(seconds: 3),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Enter search mode normally
|
||||
debugPrint('[HomeScreen] Entering search mode');
|
||||
try {
|
||||
final mapCenter = _mapController.mapController.camera.center;
|
||||
debugPrint('[HomeScreen] Map center: $mapCenter');
|
||||
appState.enterSearchMode(mapCenter);
|
||||
} catch (e) {
|
||||
// Controller not ready, use fallback location
|
||||
debugPrint('[HomeScreen] Map controller not ready: $e, using fallback');
|
||||
appState.enterSearchMode(LatLng(37.7749, -122.4194));
|
||||
}
|
||||
}
|
||||
// Search/navigation button - delegate to coordinator
|
||||
_navigationCoordinator.handleNavigationButtonPress(
|
||||
context: context,
|
||||
mapController: _mapController,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchResultSelected(SearchResult result) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Update navigation state with selected result
|
||||
appState.selectSearchResult(result);
|
||||
|
||||
// Jump to the search result location
|
||||
try {
|
||||
_mapController.animateTo(
|
||||
dest: result.coordinates,
|
||||
zoom: 16.0, // Good zoom level for viewing the area
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (_) {
|
||||
// Map controller not ready, fallback to immediate move
|
||||
try {
|
||||
_mapController.mapController.move(result.coordinates, 16.0);
|
||||
} catch (_) {
|
||||
debugPrint('[HomeScreen] Could not move to search result: ${result.coordinates}');
|
||||
}
|
||||
}
|
||||
_mapInteractionHandler.handleSearchResultSelection(
|
||||
context: context,
|
||||
result: result,
|
||||
mapController: _mapController,
|
||||
);
|
||||
}
|
||||
|
||||
void openNodeTagSheet(OsmNode node) {
|
||||
setState(() {
|
||||
_selectedNodeId = node.id; // Track selected node for highlighting
|
||||
});
|
||||
|
||||
// Start smooth centering animation simultaneously with sheet opening
|
||||
// Use the same duration as SheetAwareMap (300ms) for coordinated animation
|
||||
try {
|
||||
_mapController.animateTo(
|
||||
dest: node.coord,
|
||||
zoom: _mapController.mapController.camera.zoom,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (_) {
|
||||
// Map controller not ready, fallback to immediate move
|
||||
try {
|
||||
_mapController.mapController.move(node.coord, _mapController.mapController.camera.zoom);
|
||||
} catch (_) {
|
||||
// Controller really not ready, skip centering
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the map interaction (centering and follow-me disable)
|
||||
_mapInteractionHandler.handleNodeTap(
|
||||
context: context,
|
||||
node: node,
|
||||
mapController: _mapController,
|
||||
onSelectedNodeChanged: (id) => setState(() => _selectedNodeId = id),
|
||||
);
|
||||
|
||||
final controller = _scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
@@ -540,12 +299,14 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
),
|
||||
child: MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
setState(() {
|
||||
_tagSheetHeight = height + MediaQuery.of(context).padding.bottom;
|
||||
});
|
||||
_sheetCoordinator.updateTagSheetHeight(
|
||||
height + MediaQuery.of(context).padding.bottom,
|
||||
() => setState(() {}),
|
||||
);
|
||||
},
|
||||
child: NodeTagSheet(
|
||||
node: node,
|
||||
isNodeLimitActive: _isNodeLimitActive,
|
||||
onEditPressed: () {
|
||||
// Check minimum zoom level before starting edit session
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
@@ -572,37 +333,22 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
|
||||
// Reset height and selection when sheet is dismissed (unless transitioning to edit)
|
||||
controller.closed.then((_) {
|
||||
if (!_transitioningToEdit) {
|
||||
setState(() {
|
||||
_tagSheetHeight = 0.0;
|
||||
_selectedNodeId = null; // Clear selection
|
||||
});
|
||||
if (!_sheetCoordinator.transitioningToEdit) {
|
||||
_sheetCoordinator.resetTagSheetHeight(() => setState(() {}));
|
||||
setState(() => _selectedNodeId = null);
|
||||
}
|
||||
// If transitioning to edit, keep the height until edit sheet takes over
|
||||
});
|
||||
}
|
||||
|
||||
void openSuspectedLocationSheet(SuspectedLocation location) {
|
||||
final appState = context.read<AppState>();
|
||||
appState.selectSuspectedLocation(location);
|
||||
|
||||
// Start smooth centering animation simultaneously with sheet opening
|
||||
try {
|
||||
_mapController.animateTo(
|
||||
dest: location.centroid,
|
||||
zoom: _mapController.mapController.camera.zoom,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (_) {
|
||||
// Map controller not ready, fallback to immediate move
|
||||
try {
|
||||
_mapController.mapController.move(location.centroid, _mapController.mapController.camera.zoom);
|
||||
} catch (_) {
|
||||
// Controller really not ready, skip centering
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the map interaction (centering and selection)
|
||||
_mapInteractionHandler.handleSuspectedLocationTap(
|
||||
context: context,
|
||||
location: location,
|
||||
mapController: _mapController,
|
||||
);
|
||||
|
||||
final controller = _scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
@@ -610,9 +356,10 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
),
|
||||
child: MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
setState(() {
|
||||
_tagSheetHeight = height + MediaQuery.of(context).padding.bottom;
|
||||
});
|
||||
_sheetCoordinator.updateTagSheetHeight(
|
||||
height + MediaQuery.of(context).padding.bottom,
|
||||
() => setState(() {}),
|
||||
);
|
||||
},
|
||||
child: SuspectedLocationSheet(location: location),
|
||||
),
|
||||
@@ -621,10 +368,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
|
||||
// Reset height and clear selection when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
setState(() {
|
||||
_tagSheetHeight = 0.0;
|
||||
});
|
||||
appState.clearSuspectedLocationSelection();
|
||||
_sheetCoordinator.resetTagSheetHeight(() => setState(() {}));
|
||||
context.read<AppState>().clearSuspectedLocationSelection();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -633,46 +378,46 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
// Auto-open edit sheet when edit session starts
|
||||
if (appState.editSession != null && !_editSheetShown) {
|
||||
_editSheetShown = true;
|
||||
if (appState.editSession != null && !_sheetCoordinator.editSheetShown) {
|
||||
_sheetCoordinator.setEditSheetShown(true);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _openEditNodeSheet());
|
||||
} else if (appState.editSession == null) {
|
||||
_editSheetShown = false;
|
||||
_sheetCoordinator.setEditSheetShown(false);
|
||||
}
|
||||
|
||||
// Auto-open navigation sheet when needed - simplified logic (only in dev mode)
|
||||
// Auto-open navigation sheet when needed - only when online and in nav features mode
|
||||
if (kEnableNavigationFeatures) {
|
||||
final shouldShowNavSheet = appState.isInSearchMode || appState.showingOverview;
|
||||
if (shouldShowNavSheet && !_navigationSheetShown) {
|
||||
_navigationSheetShown = true;
|
||||
final shouldShowNavSheet = !appState.offlineMode && (appState.isInSearchMode || appState.showingOverview);
|
||||
if (shouldShowNavSheet && !_sheetCoordinator.navigationSheetShown) {
|
||||
_sheetCoordinator.setNavigationSheetShown(true);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _openNavigationSheet());
|
||||
} else if (!shouldShowNavSheet) {
|
||||
_navigationSheetShown = false;
|
||||
} else if (!shouldShowNavSheet && _sheetCoordinator.navigationSheetShown) {
|
||||
_sheetCoordinator.setNavigationSheetShown(false);
|
||||
// When sheet should close (including going offline), clean up navigation state
|
||||
if (appState.offlineMode) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
appState.cancelNavigation();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for welcome/changelog popup after app is fully initialized
|
||||
if (appState.isInitialized && !_hasCheckedForPopup) {
|
||||
_hasCheckedForPopup = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _checkForPopup());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkForPopup();
|
||||
// Check if re-authentication is needed for message notifications
|
||||
appState.checkAndPromptReauthForMessages(context);
|
||||
});
|
||||
}
|
||||
|
||||
// Pass the active sheet height directly to the map
|
||||
final activeSheetHeight = _addSheetHeight > 0
|
||||
? _addSheetHeight
|
||||
: (_editSheetHeight > 0
|
||||
? _editSheetHeight
|
||||
: (_navigationSheetHeight > 0
|
||||
? _navigationSheetHeight
|
||||
: _tagSheetHeight));
|
||||
final activeSheetHeight = _sheetCoordinator.activeSheetHeight;
|
||||
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<CameraProviderWithCache>(create: (_) => CameraProviderWithCache()),
|
||||
],
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(viewInsets: EdgeInsets.zero),
|
||||
child: Scaffold(
|
||||
return MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(viewInsets: EdgeInsets.zero),
|
||||
child: Scaffold(
|
||||
key: _scaffoldKey,
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false, // Disable automatic back button
|
||||
@@ -698,11 +443,31 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => IconButton(
|
||||
tooltip: LocalizationService.instance.settings,
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () => Navigator.pushNamed(context, '/settings'),
|
||||
),
|
||||
builder: (context, child) {
|
||||
final appState = context.watch<AppState>();
|
||||
return IconButton(
|
||||
tooltip: LocalizationService.instance.settings,
|
||||
icon: Stack(
|
||||
children: [
|
||||
const Icon(Icons.settings),
|
||||
if (appState.hasUnreadMessages)
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: () => Navigator.pushNamed(context, '/settings'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -717,14 +482,23 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
onNodeTap: openNodeTagSheet,
|
||||
onSuspectedLocationTap: openSuspectedLocationSheet,
|
||||
onSearchPressed: _onNavigationButtonPressed,
|
||||
onNodeLimitChanged: (isLimited) {
|
||||
setState(() {
|
||||
_isNodeLimitActive = isLimited;
|
||||
});
|
||||
},
|
||||
onUserGesture: () {
|
||||
_mapInteractionHandler.handleUserGesture(
|
||||
context: context,
|
||||
onSelectedNodeChanged: (id) => setState(() => _selectedNodeId = id),
|
||||
);
|
||||
if (appState.followMeMode != FollowMeMode.off) {
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
}
|
||||
},
|
||||
),
|
||||
// Search bar (slides in when in search mode) - only online since search doesn't work offline
|
||||
if (!appState.offlineMode && appState.isInSearchMode)
|
||||
// Search bar (slides in when in search mode)
|
||||
if (appState.isInSearchMode)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
@@ -828,7 +602,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ class NavigationSettingsScreen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AnimatedBuilder(
|
||||
@@ -26,48 +27,28 @@ class NavigationSettingsScreen extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Coming soon message
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.blue),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Navigation Features',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Navigation and routing settings will be available here. Coming soon:\n\n'
|
||||
'• Surveillance avoidance distance\n'
|
||||
'• Route planning preferences\n'
|
||||
'• Search history management\n'
|
||||
'• Distance units (metric/imperial)',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Placeholder settings (disabled for now)
|
||||
_buildDisabledSetting(
|
||||
context,
|
||||
icon: Icons.warning_outlined,
|
||||
title: locService.t('navigation.avoidanceDistance'),
|
||||
subtitle: locService.t('navigation.avoidanceDistanceSubtitle'),
|
||||
value: '100 ${locService.t('navigation.meters')}',
|
||||
ListTile(
|
||||
leading: const Icon(Icons.social_distance),
|
||||
title: Text(locService.t('navigation.avoidanceDistance')),
|
||||
subtitle: Text(locService.t('navigation.avoidanceDistanceSubtitle')),
|
||||
trailing: SizedBox(
|
||||
width: 80,
|
||||
child: TextFormField(
|
||||
initialValue: appState.navigationAvoidanceDistance.toString(),
|
||||
keyboardType: const TextInputType.numberWithOptions(signed: false, decimal: false),
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
border: OutlineInputBorder(),
|
||||
suffixText: 'm',
|
||||
),
|
||||
onFieldSubmitted: (value) {
|
||||
final distance = int.tryParse(value) ?? 250;
|
||||
appState.setNavigationAvoidanceDistance(distance.clamp(0, 2000));
|
||||
}
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
@@ -126,4 +107,4 @@ class NavigationSettingsScreen extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,28 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../state/settings_state.dart';
|
||||
import '../screens/settings/sections/upload_mode_section.dart';
|
||||
|
||||
class OSMAccountScreen extends StatelessWidget {
|
||||
class OSMAccountScreen extends StatefulWidget {
|
||||
const OSMAccountScreen({super.key});
|
||||
|
||||
@override
|
||||
State<OSMAccountScreen> createState() => _OSMAccountScreenState();
|
||||
}
|
||||
|
||||
class _OSMAccountScreenState extends State<OSMAccountScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Check for messages when screen loads
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final appState = context.read<AppState>();
|
||||
if (appState.isLoggedIn) {
|
||||
appState.checkMessages();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
@@ -90,14 +106,68 @@ class OSMAccountScreen extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
// Only show OSM website buttons when not in simulate mode
|
||||
if (appState.uploadMode != UploadMode.simulate) ...[
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.history),
|
||||
title: Text(locService.t('auth.viewMyEdits')),
|
||||
subtitle: Text(locService.t('auth.viewMyEditsSubtitle')),
|
||||
trailing: const Icon(Icons.open_in_new),
|
||||
onTap: () async {
|
||||
final url = Uri.parse('https://openstreetmap.org/user/${Uri.encodeComponent(appState.username)}/history');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
// Messages button - only show when not in simulate mode
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.history),
|
||||
title: Text(locService.t('auth.viewMyEdits')),
|
||||
subtitle: Text(locService.t('auth.viewMyEditsSubtitle')),
|
||||
leading: Stack(
|
||||
children: [
|
||||
const Icon(Icons.message),
|
||||
if (appState.hasUnreadMessages)
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 12,
|
||||
minHeight: 12,
|
||||
),
|
||||
child: Text(
|
||||
'${appState.unreadMessageCount}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onError,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(locService.t('auth.viewMessages')),
|
||||
subtitle: Text(appState.hasUnreadMessages
|
||||
? locService.t('auth.unreadMessagesCount', params: [appState.unreadMessageCount.toString()])
|
||||
: locService.t('auth.noUnreadMessages')),
|
||||
trailing: const Icon(Icons.open_in_new),
|
||||
onTap: () async {
|
||||
final url = Uri.parse('https://openstreetmap.org/user/${Uri.encodeComponent(appState.username)}/history');
|
||||
final url = Uri.parse(appState.getMessagesUrl());
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
@@ -109,6 +179,7 @@ class OSMAccountScreen extends StatelessWidget {
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -167,10 +238,181 @@ class OSMAccountScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Account deletion section - only show when logged in and not in simulate mode
|
||||
if (appState.isLoggedIn && appState.uploadMode != UploadMode.simulate) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildAccountDeletionSection(context, appState),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAccountDeletionSection(BuildContext context, AppState appState) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('auth.accountManagement'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
locService.t('auth.accountManagementDescription'),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Show current upload destination
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_getCurrentDestinationText(locService, appState.uploadMode),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Delete account button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _showDeleteAccountDialog(context, locService, appState.uploadMode),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: Text(locService.t('auth.deleteAccount')),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.error.withOpacity(0.5)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getCurrentDestinationText(LocalizationService locService, UploadMode uploadMode) {
|
||||
switch (uploadMode) {
|
||||
case UploadMode.production:
|
||||
return locService.t('auth.currentDestinationProduction');
|
||||
case UploadMode.sandbox:
|
||||
return locService.t('auth.currentDestinationSandbox');
|
||||
case UploadMode.simulate:
|
||||
return locService.t('auth.currentDestinationSimulate');
|
||||
}
|
||||
}
|
||||
|
||||
void _showDeleteAccountDialog(BuildContext context, LocalizationService locService, UploadMode uploadMode) {
|
||||
final deleteUrl = _getDeleteAccountUrl(uploadMode);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('auth.deleteAccount')),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(locService.t('auth.deleteAccountExplanation')),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
locService.t('auth.deleteAccountWarning'),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Show which account will be deleted
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.error.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_getCurrentDestinationText(locService, uploadMode),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(locService.t('actions.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_launchDeleteAccountUrl(deleteUrl, context, locService);
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text(locService.t('auth.goToOSM')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getDeleteAccountUrl(UploadMode uploadMode) {
|
||||
switch (uploadMode) {
|
||||
case UploadMode.production:
|
||||
return 'https://www.openstreetmap.org/account/deletion';
|
||||
case UploadMode.sandbox:
|
||||
return 'https://master.apis.dev.openstreetmap.org/account/deletion';
|
||||
case UploadMode.simulate:
|
||||
// For simulate mode, just go to production since it's not a real account
|
||||
return 'https://www.openstreetmap.org/account/deletion';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _launchDeleteAccountUrl(String url, BuildContext context, LocalizationService locService) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ class _MaxNodesSectionState extends State<MaxNodesSection> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final maxNodes = context.read<AppState>().maxCameras;
|
||||
final maxNodes = context.read<AppState>().maxNodes;
|
||||
_controller = TextEditingController(text: maxNodes.toString());
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class _MaxNodesSectionState extends State<MaxNodesSection> {
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
final current = appState.maxCameras;
|
||||
final current = appState.maxNodes;
|
||||
final showWarning = current > 1000;
|
||||
|
||||
return Column(
|
||||
@@ -79,8 +79,8 @@ class _MaxNodesSectionState extends State<MaxNodesSection> {
|
||||
),
|
||||
onFieldSubmitted: (value) {
|
||||
final n = int.tryParse(value) ?? 10;
|
||||
appState.maxCameras = n;
|
||||
_controller.text = appState.maxCameras.toString();
|
||||
appState.maxNodes = n;
|
||||
_controller.text = appState.maxNodes.toString();
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -37,34 +37,78 @@ class UploadModeSection extends StatelessWidget {
|
||||
child: Text(locService.t('uploadMode.simulate')),
|
||||
),
|
||||
],
|
||||
onChanged: (mode) {
|
||||
if (mode != null) appState.setUploadMode(mode);
|
||||
onChanged: appState.pendingCount > 0 ? null : (mode) {
|
||||
if (mode != null) {
|
||||
appState.setUploadMode(mode);
|
||||
// Check if re-authentication is needed after mode change
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
appState.checkAndPromptReauthForMessages(context);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 56, top: 2, right: 16, bottom: 12),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
switch (appState.uploadMode) {
|
||||
case UploadMode.production:
|
||||
return Text(
|
||||
locService.t('uploadMode.productionDescription'),
|
||||
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7))
|
||||
);
|
||||
case UploadMode.sandbox:
|
||||
return Text(
|
||||
locService.t('uploadMode.sandboxDescription'),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orange),
|
||||
);
|
||||
case UploadMode.simulate:
|
||||
default:
|
||||
return Text(
|
||||
locService.t('uploadMode.simulateDescription'),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.deepPurple)
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Upload mode restriction message when queue has items
|
||||
if (appState.pendingCount > 0) ...[
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.orange, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('uploadMode.cannotChangeWithQueue', params: [appState.pendingCount.toString()]),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orange),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
|
||||
// Normal upload mode description
|
||||
Builder(
|
||||
builder: (context) {
|
||||
switch (appState.uploadMode) {
|
||||
case UploadMode.production:
|
||||
return Text(
|
||||
locService.t('uploadMode.productionDescription'),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: appState.pendingCount > 0
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(context).colorScheme.onSurface.withOpacity(0.7)
|
||||
)
|
||||
);
|
||||
case UploadMode.sandbox:
|
||||
return Text(
|
||||
locService.t('uploadMode.sandboxDescription'),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: appState.pendingCount > 0
|
||||
? Theme.of(context).disabledColor
|
||||
: Colors.orange
|
||||
),
|
||||
);
|
||||
case UploadMode.simulate:
|
||||
default:
|
||||
return Text(
|
||||
locService.t('uploadMode.simulateDescription'),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: appState.pendingCount > 0
|
||||
? Theme.of(context).disabledColor
|
||||
: Colors.deepPurple
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/version_service.dart';
|
||||
import '../dev_config.dart';
|
||||
@@ -23,13 +25,7 @@ class SettingsScreen extends StatelessWidget {
|
||||
),
|
||||
children: [
|
||||
// OpenStreetMap Account
|
||||
_buildNavigationTile(
|
||||
context,
|
||||
icon: Icons.account_circle,
|
||||
title: locService.t('auth.osmAccountTitle'),
|
||||
subtitle: locService.t('auth.osmAccountSubtitle'),
|
||||
onTap: () => Navigator.pushNamed(context, '/settings/osm-account'),
|
||||
),
|
||||
_buildOSMAccountTile(context, locService),
|
||||
const Divider(),
|
||||
|
||||
// Upload Queue
|
||||
@@ -117,6 +113,35 @@ class SettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOSMAccountTile(BuildContext context, LocalizationService locService) {
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return ListTile(
|
||||
leading: Stack(
|
||||
children: [
|
||||
const Icon(Icons.account_circle),
|
||||
if (appState.hasUnreadMessages)
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(locService.t('auth.osmAccountTitle')),
|
||||
subtitle: Text(locService.t('auth.osmAccountSubtitle')),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: () => Navigator.pushNamed(context, '/settings/osm-account'),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavigationTile(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
|
||||
@@ -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:
|
||||
@@ -38,6 +93,9 @@ class UploadQueueScreen extends StatelessWidget {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
// Check if queue processing is paused
|
||||
final isQueuePaused = appState.offlineMode || appState.pauseQueueProcessing;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('queue.title')),
|
||||
@@ -50,6 +108,46 @@ class UploadQueueScreen extends StatelessWidget {
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
children: [
|
||||
// Queue processing status indicator
|
||||
if (isQueuePaused && appState.pendingCount > 0)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.pause_circle_outline, color: Colors.orange),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('queue.processingPaused'),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
appState.offlineMode
|
||||
? locService.t('queue.pausedDueToOffline')
|
||||
: locService.t('queue.pausedByUser'),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Clear Upload Queue button - always visible
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -130,16 +228,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 +256,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 +270,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(
|
||||
|
||||
@@ -51,7 +51,7 @@ class AuthService {
|
||||
_helper = OAuth2Helper(
|
||||
client,
|
||||
clientId: clientId,
|
||||
scopes: ['read_prefs', 'write_api'],
|
||||
scopes: ['read_prefs', 'write_api', 'consume_messages'],
|
||||
enablePKCE: true,
|
||||
// tokenStorageKey: _tokenKey, // not supported by this package version
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
158
lib/services/deflock_tile_provider.dart
Normal file
158
lib/services/deflock_tile_provider.dart
Normal file
@@ -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
|
||||
@@ -96,7 +96,7 @@ class MapDataProvider {
|
||||
final localNodes = await fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxNodes: AppState.instance.maxCameras,
|
||||
maxNodes: AppState.instance.maxNodes,
|
||||
);
|
||||
|
||||
// Check if we need to trigger a new pre-fetch (spatial or temporal)
|
||||
@@ -116,18 +116,14 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bulk/paged node fetch for offline downloads (handling paging, dedup, and Overpass retries)
|
||||
/// Only use for offline area download, not for map browsing! Ignores maxCameras config.
|
||||
/// Only use for offline area download, not for map browsing! Ignores maxNodes config.
|
||||
Future<List<OsmNode>> getAllNodesForDownload({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
|
||||
@@ -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) {
|
||||
@@ -98,6 +114,23 @@ class NodeCache {
|
||||
print('[NodeCache] Removed ${nodesToRemove.length} temp nodes at coordinate ${coord.latitude}, ${coord.longitude}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a specific temporary node by its ID (for queue item-specific cleanup)
|
||||
void removeTempNodeById(int tempNodeId) {
|
||||
if (tempNodeId >= 0) {
|
||||
print('[NodeCache] Warning: Attempted to remove non-temp node ID $tempNodeId');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_nodes.remove(tempNodeId) != null) {
|
||||
print('[NodeCache] Removed specific temp node $tempNodeId from cache');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a specific node by ID (returns null if not found)
|
||||
OsmNode? getNodeById(int nodeId) {
|
||||
return _nodes[nodeId];
|
||||
}
|
||||
|
||||
/// Check if two coordinates match within tolerance
|
||||
bool _coordsMatch(LatLng coord1, LatLng coord2, double tolerance) {
|
||||
@@ -107,6 +140,7 @@ class NodeCache {
|
||||
|
||||
/// Find nodes within the specified distance (in meters) of the given coordinate
|
||||
/// Excludes nodes with the excludeNodeId (useful when checking proximity for edited nodes)
|
||||
/// Includes pending nodes to warn about potential duplicates
|
||||
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) {
|
||||
final nearbyNodes = <OsmNode>[];
|
||||
|
||||
@@ -116,11 +150,9 @@ class NodeCache {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip temporary nodes (negative IDs) with pending upload/edit/deletion markers
|
||||
if (node.id < 0 && (
|
||||
node.tags.containsKey('_pending_upload') ||
|
||||
node.tags.containsKey('_pending_edit') ||
|
||||
node.tags.containsKey('_pending_deletion'))) {
|
||||
// Include all nodes (real and pending) to catch potential duplicates
|
||||
// Only skip nodes marked for deletion since they won't actually exist after processing
|
||||
if (node.tags.containsKey('_pending_deletion')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
106
lib/services/osm_messages_service.dart
Normal file
106
lib/services/osm_messages_service.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../state/settings_state.dart';
|
||||
|
||||
/// Service for checking OSM user messages
|
||||
class OSMMessagesService {
|
||||
static const _messageCheckCacheDuration = Duration(minutes: 5);
|
||||
|
||||
DateTime? _lastCheck;
|
||||
int? _lastUnreadCount;
|
||||
UploadMode? _lastMode;
|
||||
|
||||
/// Get the number of unread messages for the current user
|
||||
/// Returns null if not logged in, on error, or in simulate mode
|
||||
Future<int?> getUnreadMessageCount({
|
||||
required String? accessToken,
|
||||
required UploadMode uploadMode,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
// No messages in simulate mode
|
||||
if (uploadMode == UploadMode.simulate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// No access token means not logged in
|
||||
if (accessToken == null || accessToken.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cache unless forced refresh or mode changed
|
||||
if (!forceRefresh &&
|
||||
_lastCheck != null &&
|
||||
_lastUnreadCount != null &&
|
||||
_lastMode == uploadMode &&
|
||||
DateTime.now().difference(_lastCheck!) < _messageCheckCacheDuration) {
|
||||
return _lastUnreadCount;
|
||||
}
|
||||
|
||||
try {
|
||||
final apiHost = _getApiHost(uploadMode);
|
||||
final response = await http.get(
|
||||
Uri.parse('$apiHost/api/0.6/user/details.json'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
final user = data['user'];
|
||||
if (user == null) return null;
|
||||
|
||||
// OSM API returns message counts in user details
|
||||
final messages = user['messages'];
|
||||
if (messages == null) return null;
|
||||
|
||||
// Get unread count from correct path: messages.received.unread
|
||||
final received = messages['received'];
|
||||
if (received == null) return null;
|
||||
|
||||
final unreadCount = received['unread'] ?? 0;
|
||||
|
||||
// Update cache
|
||||
_lastCheck = DateTime.now();
|
||||
_lastUnreadCount = unreadCount;
|
||||
_lastMode = uploadMode;
|
||||
|
||||
return unreadCount;
|
||||
|
||||
} catch (e) {
|
||||
// Don't throw - just return null on any error
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the URL to view messages on OSM website
|
||||
String getMessagesUrl(UploadMode uploadMode) {
|
||||
switch (uploadMode) {
|
||||
case UploadMode.production:
|
||||
return 'https://www.openstreetmap.org/messages/inbox';
|
||||
case UploadMode.sandbox:
|
||||
return 'https://master.apis.dev.openstreetmap.org/messages/inbox';
|
||||
case UploadMode.simulate:
|
||||
return 'https://www.openstreetmap.org/messages/inbox';
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the cache (useful when user logs out or changes mode)
|
||||
void clearCache() {
|
||||
_lastCheck = null;
|
||||
_lastUnreadCount = null;
|
||||
_lastMode = null;
|
||||
}
|
||||
|
||||
String _getApiHost(UploadMode uploadMode) {
|
||||
switch (uploadMode) {
|
||||
case UploadMode.production:
|
||||
return 'https://api.openstreetmap.org';
|
||||
case UploadMode.sandbox:
|
||||
return 'https://api06.dev.openstreetmap.org';
|
||||
case UploadMode.simulate:
|
||||
return 'https://api.openstreetmap.org';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import '../dev_config.dart';
|
||||
import 'map_data_submodules/nodes_from_overpass.dart';
|
||||
import 'node_cache.dart';
|
||||
import 'network_status.dart';
|
||||
import '../widgets/camera_provider_with_cache.dart';
|
||||
import '../widgets/node_provider_with_cache.dart';
|
||||
|
||||
/// Manages pre-fetching larger areas to reduce Overpass API calls.
|
||||
/// Uses zoom level 10 areas and automatically splits if hitting node limits.
|
||||
@@ -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();
|
||||
NodeProviderWithCache.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;
|
||||
|
||||
@@ -2,6 +2,10 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
class RouteResult {
|
||||
final List<LatLng> waypoints;
|
||||
@@ -21,73 +25,85 @@ class RouteResult {
|
||||
}
|
||||
|
||||
class RoutingService {
|
||||
static const String _baseUrl = 'https://router.project-osrm.org';
|
||||
static const String _baseUrl = 'https://alprwatch.org/api/v1/deflock/directions';
|
||||
static const String _userAgent = 'DeFlock/1.0 (OSM surveillance mapping app)';
|
||||
static const Duration _timeout = Duration(seconds: 15);
|
||||
|
||||
/// Calculate route between two points using OSRM
|
||||
// Calculate route between two points using alprwatch
|
||||
Future<RouteResult> calculateRoute({
|
||||
required LatLng start,
|
||||
required LatLng end,
|
||||
String profile = 'driving', // driving, walking, cycling
|
||||
}) async {
|
||||
debugPrint('[RoutingService] Calculating route from $start to $end');
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final avoidance_distance = await prefs.getInt('navigation_avoidance_distance');
|
||||
|
||||
final enabled_profiles = AppState.instance.enabledProfiles.map((p) {
|
||||
final full = p.toJson();
|
||||
return {
|
||||
'id': full['id'],
|
||||
'name': full['name'],
|
||||
'tags': full['tags'],
|
||||
};
|
||||
}).toList();
|
||||
|
||||
// OSRM uses lng,lat order (opposite of LatLng)
|
||||
final startCoord = '${start.longitude},${start.latitude}';
|
||||
final endCoord = '${end.longitude},${end.latitude}';
|
||||
final uri = Uri.parse('$_baseUrl');
|
||||
final params = {
|
||||
'start': {
|
||||
'longitude': start.longitude,
|
||||
'latitude': start.latitude
|
||||
},
|
||||
'end': {
|
||||
'longitude': end.longitude,
|
||||
'latitude': end.latitude
|
||||
},
|
||||
'avoidance_distance': avoidance_distance,
|
||||
'enabled_profiles': enabled_profiles,
|
||||
'show_exclusion_zone': false, // for debugging: if true, returns a GeoJSON Feature MultiPolygon showing what areas are avoided in calculating the route
|
||||
};
|
||||
|
||||
final uri = Uri.parse('$_baseUrl/route/v1/$profile/$startCoord;$endCoord')
|
||||
.replace(queryParameters: {
|
||||
'overview': 'full', // Get full geometry
|
||||
'geometries': 'polyline', // Use polyline encoding (more compact)
|
||||
'steps': 'false', // Don't need turn-by-turn for now
|
||||
});
|
||||
|
||||
debugPrint('[RoutingService] OSRM request: $uri');
|
||||
debugPrint('[RoutingService] alprwatch request: $uri $params');
|
||||
|
||||
try {
|
||||
final response = await http.get(
|
||||
final response = await http.post(
|
||||
uri,
|
||||
headers: {
|
||||
'User-Agent': _userAgent,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
).timeout(_timeout);
|
||||
|
||||
body: json.encode(params)
|
||||
).timeout(kNavigationRoutingTimeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}');
|
||||
}
|
||||
|
||||
final data = json.decode(response.body) as Map<String, dynamic>;
|
||||
debugPrint('[RoutingService] alprwatch response data: $data');
|
||||
|
||||
// Check OSRM response status
|
||||
final code = data['code'] as String?;
|
||||
if (code != 'Ok') {
|
||||
final message = data['message'] as String? ?? 'Unknown routing error';
|
||||
throw RoutingException('OSRM error ($code): $message');
|
||||
// Check alprwatch response status
|
||||
final ok = data['ok'] as bool? ?? false;
|
||||
if ( ! ok ) {
|
||||
final message = data['error'] as String? ?? 'Unknown routing error';
|
||||
throw RoutingException('alprwatch error: $message');
|
||||
}
|
||||
|
||||
final routes = data['routes'] as List<dynamic>?;
|
||||
if (routes == null || routes.isEmpty) {
|
||||
final route = data['result']['route'] as Map<String, dynamic>?;
|
||||
if (route == null) {
|
||||
throw RoutingException('No route found between these points');
|
||||
}
|
||||
|
||||
final route = routes[0] as Map<String, dynamic>;
|
||||
final geometry = route['geometry'] as String?;
|
||||
|
||||
final waypoints = (route['coordinates'] as List<dynamic>?)
|
||||
?.map((inner) {
|
||||
final pair = inner as List<dynamic>;
|
||||
if (pair.length != 2) return null;
|
||||
final lng = (pair[0] as num).toDouble();
|
||||
final lat = (pair[1] as num).toDouble();
|
||||
return LatLng(lat, lng);
|
||||
}).whereType<LatLng>().toList() ?? [];
|
||||
final distance = (route['distance'] as num?)?.toDouble() ?? 0.0;
|
||||
final duration = (route['duration'] as num?)?.toDouble() ?? 0.0;
|
||||
|
||||
if (geometry == null) {
|
||||
throw RoutingException('Route geometry missing from response');
|
||||
}
|
||||
|
||||
// Decode polyline geometry to waypoints
|
||||
final waypoints = _decodePolyline(geometry);
|
||||
|
||||
if (waypoints.isEmpty) {
|
||||
throw RoutingException('Failed to decode route geometry');
|
||||
}
|
||||
|
||||
final result = RouteResult(
|
||||
waypoints: waypoints,
|
||||
distanceMeters: distance,
|
||||
@@ -106,52 +122,6 @@ class RoutingService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode OSRM polyline geometry to LatLng waypoints
|
||||
List<LatLng> _decodePolyline(String encoded) {
|
||||
try {
|
||||
final List<LatLng> points = [];
|
||||
int index = 0;
|
||||
int lat = 0;
|
||||
int lng = 0;
|
||||
|
||||
while (index < encoded.length) {
|
||||
int b;
|
||||
int shift = 0;
|
||||
int result = 0;
|
||||
|
||||
// Decode latitude
|
||||
do {
|
||||
b = encoded.codeUnitAt(index++) - 63;
|
||||
result |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
|
||||
final deltaLat = ((result & 1) != 0 ? ~(result >> 1) : (result >> 1));
|
||||
lat += deltaLat;
|
||||
|
||||
shift = 0;
|
||||
result = 0;
|
||||
|
||||
// Decode longitude
|
||||
do {
|
||||
b = encoded.codeUnitAt(index++) - 63;
|
||||
result |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
|
||||
final deltaLng = ((result & 1) != 0 ? ~(result >> 1) : (result >> 1));
|
||||
lng += deltaLng;
|
||||
|
||||
points.add(LatLng(lat / 1E5, lng / 1E5));
|
||||
}
|
||||
|
||||
return points;
|
||||
} catch (e) {
|
||||
debugPrint('[RoutingService] Manual polyline decoding failed: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RoutingException implements Exception {
|
||||
@@ -161,4 +131,4 @@ class RoutingException implements Exception {
|
||||
|
||||
@override
|
||||
String toString() => 'RoutingException: $message';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import 'map_data_provider.dart';
|
||||
import 'network_status.dart';
|
||||
|
||||
/// Simple HTTP client that routes tile requests through the centralized MapDataProvider.
|
||||
/// This ensures all tile fetching (offline/online routing, retries, etc.) is in one place.
|
||||
class SimpleTileHttpClient extends http.BaseClient {
|
||||
final http.Client _inner = http.Client();
|
||||
final MapDataProvider _mapDataProvider = MapDataProvider();
|
||||
|
||||
// Tile completion tracking (brutalist approach)
|
||||
int _pendingTileRequests = 0;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
// Extract tile coordinates from our custom URL scheme
|
||||
final tileCoords = _extractTileCoords(request.url);
|
||||
if (tileCoords != null) {
|
||||
final z = tileCoords['z']!;
|
||||
final x = tileCoords['x']!;
|
||||
final y = tileCoords['y']!;
|
||||
return _handleTileRequest(z, x, y);
|
||||
}
|
||||
|
||||
// Pass through non-tile requests
|
||||
return _inner.send(request);
|
||||
}
|
||||
|
||||
/// Extract z/x/y coordinates from our fake domain: https://tiles.local/provider/type/z/x/y
|
||||
/// We ignore the provider/type in the URL since we use current AppState for actual fetching
|
||||
Map<String, int>? _extractTileCoords(Uri url) {
|
||||
if (url.host != 'tiles.local') return null;
|
||||
|
||||
final pathSegments = url.pathSegments;
|
||||
if (pathSegments.length != 5) return null;
|
||||
|
||||
// pathSegments[0] = providerId (for cache separation only)
|
||||
// pathSegments[1] = tileTypeId (for cache separation only)
|
||||
final z = int.tryParse(pathSegments[2]);
|
||||
final x = int.tryParse(pathSegments[3]);
|
||||
final y = int.tryParse(pathSegments[4]);
|
||||
|
||||
if (z != null && x != null && y != null) {
|
||||
return {'z': z, 'x': x, 'y': y};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<http.StreamedResponse> _handleTileRequest(int z, int x, int y) async {
|
||||
// Increment pending counter (brutalist completion detection)
|
||||
_pendingTileRequests++;
|
||||
|
||||
try {
|
||||
// Always go through MapDataProvider - it handles offline/online routing
|
||||
// MapDataProvider will get current provider from AppState
|
||||
final tileBytes = await _mapDataProvider.getTile(z: z, x: x, y: y, source: MapSource.auto);
|
||||
|
||||
// Serve tile with proper cache headers
|
||||
return http.StreamedResponse(
|
||||
Stream.value(tileBytes),
|
||||
200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=604800',
|
||||
'Expires': _httpDateFormat(DateTime.now().add(Duration(days: 7))),
|
||||
'Last-Modified': _httpDateFormat(DateTime.now().subtract(Duration(hours: 1))),
|
||||
},
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[SimpleTileService] Could not get tile $z/$x/$y: $e');
|
||||
|
||||
// Return 404 and let flutter_map handle it gracefully
|
||||
return http.StreamedResponse(
|
||||
Stream.value(<int>[]),
|
||||
404,
|
||||
reasonPhrase: 'Tile unavailable: $e',
|
||||
);
|
||||
} finally {
|
||||
// Decrement pending counter and report completion when all done
|
||||
_pendingTileRequests--;
|
||||
if (_pendingTileRequests == 0) {
|
||||
// Only report tile completion if we were in loading state (user-initiated)
|
||||
if (NetworkStatus.instance.currentStatus == NetworkStatusType.waiting) {
|
||||
NetworkStatus.instance.setSuccess();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear any queued tile requests when map view changes
|
||||
void clearTileQueue() {
|
||||
_mapDataProvider.clearTileQueue();
|
||||
}
|
||||
|
||||
/// Clear only tile requests that are no longer visible in the current bounds
|
||||
void clearStaleRequests(LatLngBounds currentBounds) {
|
||||
_mapDataProvider.clearTileQueueSelective(currentBounds);
|
||||
}
|
||||
|
||||
/// Format date for HTTP headers (RFC 7231)
|
||||
String _httpDateFormat(DateTime date) {
|
||||
final utc = date.toUtc();
|
||||
final weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
final weekday = weekdays[utc.weekday - 1];
|
||||
final day = utc.day.toString().padLeft(2, '0');
|
||||
final month = months[utc.month - 1];
|
||||
final year = utc.year;
|
||||
final hour = utc.hour.toString().padLeft(2, '0');
|
||||
final minute = utc.minute.toString().padLeft(2, '0');
|
||||
final second = utc.second.toString().padLeft(2, '0');
|
||||
|
||||
return '$weekday, $day $month $year $hour:$minute:$second GMT';
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
_inner.close();
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
@@ -1,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)}";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
59
lib/state/messages_state.dart
Normal file
59
lib/state/messages_state.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/osm_messages_service.dart';
|
||||
import 'settings_state.dart';
|
||||
|
||||
/// State management for OSM message notifications
|
||||
class MessagesState extends ChangeNotifier {
|
||||
final OSMMessagesService _messagesService = OSMMessagesService();
|
||||
|
||||
int? _unreadCount;
|
||||
bool _isChecking = false;
|
||||
|
||||
// Getters
|
||||
int? get unreadCount => _unreadCount;
|
||||
bool get hasUnreadMessages => (_unreadCount ?? 0) > 0;
|
||||
bool get isChecking => _isChecking;
|
||||
|
||||
/// Check for unread messages
|
||||
Future<void> checkMessages({
|
||||
required String? accessToken,
|
||||
required UploadMode uploadMode,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
if (_isChecking) return; // Prevent concurrent checks
|
||||
|
||||
_isChecking = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final count = await _messagesService.getUnreadMessageCount(
|
||||
accessToken: accessToken,
|
||||
uploadMode: uploadMode,
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
|
||||
if (_unreadCount != count) {
|
||||
_unreadCount = count;
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently handle errors - messages are not critical
|
||||
debugPrint('MessagesState: Error checking messages: $e');
|
||||
} finally {
|
||||
_isChecking = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the URL to view messages
|
||||
String getMessagesUrl(UploadMode uploadMode) {
|
||||
return _messagesService.getMessagesUrl(uploadMode);
|
||||
}
|
||||
|
||||
/// Clear message state (when user logs out or changes mode)
|
||||
void clearMessages() {
|
||||
_unreadCount = null;
|
||||
_messagesService.clearCache();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../models/search_result.dart';
|
||||
import '../services/search_service.dart';
|
||||
import '../services/routing_service.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
/// Simplified navigation modes - brutalist approach
|
||||
enum AppNavigationMode {
|
||||
@@ -75,6 +76,17 @@ class NavigationState extends ChangeNotifier {
|
||||
bool get showSearchButton => _mode == AppNavigationMode.normal;
|
||||
bool get showRouteButton => _mode == AppNavigationMode.routeActive;
|
||||
|
||||
/// Check if the start and end locations are too close together
|
||||
bool get areRoutePointsTooClose {
|
||||
if (!_isSettingSecondPoint || _provisionalPinLocation == null) return false;
|
||||
|
||||
final firstPoint = _nextPointIsStart ? _routeEnd : _routeStart;
|
||||
if (firstPoint == null) return false;
|
||||
|
||||
final distance = const Distance().as(LengthUnit.Meter, firstPoint, _provisionalPinLocation!);
|
||||
return distance < kNavigationMinRouteDistance;
|
||||
}
|
||||
|
||||
/// BRUTALIST: Single entry point to search mode
|
||||
void enterSearchMode(LatLng mapCenter) {
|
||||
debugPrint('[NavigationState] enterSearchMode - current mode: $_mode');
|
||||
@@ -195,8 +207,15 @@ class NavigationState extends ChangeNotifier {
|
||||
_routeEndAddress = _provisionalPinAddress;
|
||||
}
|
||||
|
||||
// BRUTALIST FIX: Set calculating state BEFORE clearing isSettingSecondPoint
|
||||
// to prevent UI from briefly showing route buttons again
|
||||
_isSettingSecondPoint = false;
|
||||
_isCalculating = true;
|
||||
_routingError = null; // Clear any previous errors
|
||||
|
||||
// Notify listeners immediately to update UI before async calculation starts
|
||||
notifyListeners();
|
||||
|
||||
_calculateRoute();
|
||||
}
|
||||
|
||||
@@ -209,11 +228,11 @@ class NavigationState extends ChangeNotifier {
|
||||
_calculateRoute();
|
||||
}
|
||||
|
||||
/// Calculate route using OSRM
|
||||
/// Calculate route using alprwatch
|
||||
void _calculateRoute() {
|
||||
if (_routeStart == null || _routeEnd == null) return;
|
||||
|
||||
debugPrint('[NavigationState] Calculating route with OSRM...');
|
||||
|
||||
debugPrint('[NavigationState] Calculating route with alprwatch...');
|
||||
_isCalculating = true;
|
||||
_routingError = null;
|
||||
notifyListeners();
|
||||
@@ -221,7 +240,6 @@ class NavigationState extends ChangeNotifier {
|
||||
_routingService.calculateRoute(
|
||||
start: _routeStart!,
|
||||
end: _routeEnd!,
|
||||
profile: 'driving', // Could make this configurable later
|
||||
).then((routeResult) {
|
||||
if (!_isCalculating) return; // Canceled while calculating
|
||||
|
||||
@@ -231,7 +249,7 @@ class NavigationState extends ChangeNotifier {
|
||||
_showingOverview = true;
|
||||
_provisionalPinLocation = null; // Hide provisional pin
|
||||
|
||||
debugPrint('[NavigationState] OSRM route calculated: ${routeResult.toString()}');
|
||||
debugPrint('[NavigationState] alprwatch route calculated: ${routeResult.toString()}');
|
||||
notifyListeners();
|
||||
|
||||
}).catchError((error) {
|
||||
@@ -336,4 +354,4 @@ class NavigationState extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ enum FollowMeMode {
|
||||
|
||||
class SettingsState extends ChangeNotifier {
|
||||
static const String _offlineModePrefsKey = 'offline_mode';
|
||||
static const String _maxCamerasPrefsKey = 'max_cameras';
|
||||
static const String _maxNodesPrefsKey = 'max_nodes';
|
||||
static const String _uploadModePrefsKey = 'upload_mode';
|
||||
static const String _tileProvidersPrefsKey = 'tile_providers';
|
||||
static const String _selectedTileTypePrefsKey = 'selected_tile_type';
|
||||
@@ -29,10 +29,11 @@ class SettingsState extends ChangeNotifier {
|
||||
static const String _networkStatusIndicatorEnabledPrefsKey = 'network_status_indicator_enabled';
|
||||
static const String _suspectedLocationMinDistancePrefsKey = 'suspected_location_min_distance';
|
||||
static const String _pauseQueueProcessingPrefsKey = 'pause_queue_processing';
|
||||
static const String _navigationAvoidanceDistancePrefsKey = 'navigation_avoidance_distance';
|
||||
|
||||
bool _offlineMode = false;
|
||||
bool _pauseQueueProcessing = false;
|
||||
int _maxCameras = 250;
|
||||
int _maxNodes = kDefaultMaxNodes;
|
||||
UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production;
|
||||
FollowMeMode _followMeMode = FollowMeMode.follow;
|
||||
bool _proximityAlertsEnabled = false;
|
||||
@@ -41,11 +42,12 @@ class SettingsState extends ChangeNotifier {
|
||||
int _suspectedLocationMinDistance = 100; // meters
|
||||
List<TileProvider> _tileProviders = [];
|
||||
String _selectedTileTypeId = '';
|
||||
int _navigationAvoidanceDistance = 250; // meters
|
||||
|
||||
// Getters
|
||||
bool get offlineMode => _offlineMode;
|
||||
bool get pauseQueueProcessing => _pauseQueueProcessing;
|
||||
int get maxCameras => _maxCameras;
|
||||
int get maxNodes => _maxNodes;
|
||||
UploadMode get uploadMode => _uploadMode;
|
||||
FollowMeMode get followMeMode => _followMeMode;
|
||||
bool get proximityAlertsEnabled => _proximityAlertsEnabled;
|
||||
@@ -54,6 +56,7 @@ class SettingsState extends ChangeNotifier {
|
||||
int get suspectedLocationMinDistance => _suspectedLocationMinDistance;
|
||||
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
|
||||
String get selectedTileTypeId => _selectedTileTypeId;
|
||||
int get navigationAvoidanceDistance => _navigationAvoidanceDistance;
|
||||
|
||||
/// Get the currently selected tile type
|
||||
TileType? get selectedTileType {
|
||||
@@ -98,9 +101,12 @@ class SettingsState extends ChangeNotifier {
|
||||
// Load queue processing setting
|
||||
_pauseQueueProcessing = prefs.getBool(_pauseQueueProcessingPrefsKey) ?? false;
|
||||
|
||||
// Load max cameras
|
||||
if (prefs.containsKey(_maxCamerasPrefsKey)) {
|
||||
_maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250;
|
||||
// Load max nodes
|
||||
_maxNodes = prefs.getInt(_maxNodesPrefsKey) ?? kDefaultMaxNodes;
|
||||
|
||||
// Load navigation avoidance distance
|
||||
if (prefs.containsKey(_navigationAvoidanceDistancePrefsKey)) {
|
||||
_navigationAvoidanceDistance = prefs.getInt(_navigationAvoidanceDistancePrefsKey) ?? 250;
|
||||
}
|
||||
|
||||
// Load proximity alerts settings
|
||||
@@ -225,11 +231,11 @@ class SettingsState extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
set maxCameras(int n) {
|
||||
set maxNodes(int n) {
|
||||
if (n < 10) n = 10; // minimum
|
||||
_maxCameras = n;
|
||||
_maxNodes = n;
|
||||
SharedPreferences.getInstance().then((prefs) {
|
||||
prefs.setInt(_maxCamerasPrefsKey, n);
|
||||
prefs.setInt(_maxNodesPrefsKey, n);
|
||||
});
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -353,4 +359,14 @@ class SettingsState extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
// Set distance for avoidance of nodes during navigation
|
||||
Future<void> setNavigationAvoidanceDistance(int distance) async {
|
||||
if (_navigationAvoidanceDistance != distance) {
|
||||
_navigationAvoidanceDistance = distance;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_navigationAvoidanceDistancePrefsKey, distance);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import '../models/osm_node.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../services/node_cache.dart';
|
||||
import '../services/uploader.dart';
|
||||
import '../widgets/camera_provider_with_cache.dart';
|
||||
import '../widgets/node_provider_with_cache.dart';
|
||||
import 'settings_state.dart';
|
||||
import 'session_state.dart';
|
||||
|
||||
@@ -21,9 +21,100 @@ class UploadQueueState extends ChangeNotifier {
|
||||
int get pendingCount => _queue.length;
|
||||
List<PendingUpload> get pendingUploads => List.unmodifiable(_queue);
|
||||
|
||||
// Initialize by loading queue from storage
|
||||
// Initialize by loading queue from storage and repopulate cache with pending nodes
|
||||
Future<void> init() async {
|
||||
await _loadQueue();
|
||||
print('[UploadQueue] Loaded ${_queue.length} items from storage');
|
||||
_repopulateCacheFromQueue();
|
||||
}
|
||||
|
||||
// Repopulate the cache with pending nodes from the queue on startup
|
||||
void _repopulateCacheFromQueue() {
|
||||
print('[UploadQueue] Repopulating cache from ${_queue.length} queue items');
|
||||
final nodesToAdd = <OsmNode>[];
|
||||
|
||||
for (final upload in _queue) {
|
||||
// Skip completed uploads - they should already be in OSM and will be fetched normally
|
||||
if (upload.isComplete) {
|
||||
print('[UploadQueue] Skipping completed upload at ${upload.coord}');
|
||||
continue;
|
||||
}
|
||||
|
||||
print('[UploadQueue] Processing ${upload.operation} upload at ${upload.coord}');
|
||||
|
||||
if (upload.isDeletion) {
|
||||
// For deletions: mark the original node as pending deletion if it exists in cache
|
||||
if (upload.originalNodeId != null) {
|
||||
final existingNode = NodeCache.instance.getNodeById(upload.originalNodeId!);
|
||||
if (existingNode != null) {
|
||||
final deletionTags = Map<String, String>.from(existingNode.tags);
|
||||
deletionTags['_pending_deletion'] = 'true';
|
||||
|
||||
final nodeWithDeletionTag = OsmNode(
|
||||
id: upload.originalNodeId!,
|
||||
coord: existingNode.coord,
|
||||
tags: deletionTags,
|
||||
);
|
||||
nodesToAdd.add(nodeWithDeletionTag);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For creates, edits, and extracts: recreate temp node if needed
|
||||
// Generate new temp ID if not already stored (for backward compatibility)
|
||||
final tempId = upload.tempNodeId ?? -DateTime.now().millisecondsSinceEpoch - _queue.indexOf(upload);
|
||||
|
||||
final tags = upload.getCombinedTags();
|
||||
tags['_pending_upload'] = 'true';
|
||||
tags['_temp_id'] = tempId.toString();
|
||||
|
||||
// Store temp ID for future cleanup if not already set
|
||||
if (upload.tempNodeId == null) {
|
||||
upload.tempNodeId = tempId;
|
||||
}
|
||||
|
||||
if (upload.isEdit) {
|
||||
// For edits: also mark original with _pending_edit if it exists
|
||||
if (upload.originalNodeId != null) {
|
||||
final existingOriginal = NodeCache.instance.getNodeById(upload.originalNodeId!);
|
||||
if (existingOriginal != null) {
|
||||
final originalTags = Map<String, String>.from(existingOriginal.tags);
|
||||
originalTags['_pending_edit'] = 'true';
|
||||
|
||||
final originalWithEdit = OsmNode(
|
||||
id: upload.originalNodeId!,
|
||||
coord: existingOriginal.coord,
|
||||
tags: originalTags,
|
||||
);
|
||||
nodesToAdd.add(originalWithEdit);
|
||||
}
|
||||
}
|
||||
|
||||
// Add connection line marker
|
||||
tags['_original_node_id'] = upload.originalNodeId.toString();
|
||||
} else if (upload.operation == UploadOperation.extract) {
|
||||
// For extracts: add connection line marker
|
||||
tags['_original_node_id'] = upload.originalNodeId.toString();
|
||||
}
|
||||
|
||||
final tempNode = OsmNode(
|
||||
id: tempId,
|
||||
coord: upload.coord,
|
||||
tags: tags,
|
||||
);
|
||||
nodesToAdd.add(tempNode);
|
||||
}
|
||||
}
|
||||
|
||||
if (nodesToAdd.isNotEmpty) {
|
||||
NodeCache.instance.addOrUpdate(nodesToAdd);
|
||||
print('[UploadQueue] Repopulated cache with ${nodesToAdd.length} pending nodes from queue');
|
||||
|
||||
// Save queue if we updated any temp IDs for backward compatibility
|
||||
_saveQueue();
|
||||
|
||||
// Notify node provider to update the map
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Add a completed session to the upload queue
|
||||
@@ -46,6 +137,10 @@ class UploadQueueState extends ChangeNotifier {
|
||||
final tempId = -DateTime.now().millisecondsSinceEpoch;
|
||||
final tags = upload.getCombinedTags();
|
||||
tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction
|
||||
tags['_temp_id'] = tempId.toString(); // Store temp ID for specific removal
|
||||
|
||||
// Store the temp ID in the upload for cleanup purposes
|
||||
upload.tempNodeId = tempId;
|
||||
|
||||
final tempNode = OsmNode(
|
||||
id: tempId,
|
||||
@@ -55,7 +150,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
|
||||
NodeCache.instance.addOrUpdate([tempNode]);
|
||||
// Notify node provider to update the map
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -100,6 +195,10 @@ class UploadQueueState extends ChangeNotifier {
|
||||
final extractedTags = upload.getCombinedTags();
|
||||
extractedTags['_pending_upload'] = 'true'; // Mark as pending upload
|
||||
extractedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing
|
||||
extractedTags['_temp_id'] = tempId.toString(); // Store temp ID for specific removal
|
||||
|
||||
// Store the temp ID in the upload for cleanup purposes
|
||||
upload.tempNodeId = tempId;
|
||||
|
||||
final extractedNode = OsmNode(
|
||||
id: tempId,
|
||||
@@ -125,6 +224,10 @@ class UploadQueueState extends ChangeNotifier {
|
||||
final editedTags = upload.getCombinedTags();
|
||||
editedTags['_pending_upload'] = 'true'; // Mark as pending upload
|
||||
editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing
|
||||
editedTags['_temp_id'] = tempId.toString(); // Store temp ID for specific removal
|
||||
|
||||
// Store the temp ID in the upload for cleanup purposes
|
||||
upload.tempNodeId = tempId;
|
||||
|
||||
final editedNode = OsmNode(
|
||||
id: tempId,
|
||||
@@ -135,7 +238,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
NodeCache.instance.addOrUpdate([originalNode, editedNode]);
|
||||
}
|
||||
// Notify node provider to update the map
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -166,25 +269,39 @@ class UploadQueueState extends ChangeNotifier {
|
||||
|
||||
NodeCache.instance.addOrUpdate([nodeWithDeletionTag]);
|
||||
// Notify node provider to update the map
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearQueue() {
|
||||
// Clean up all pending nodes from cache before clearing queue
|
||||
for (final upload in _queue) {
|
||||
_cleanupPendingNodeFromCache(upload);
|
||||
}
|
||||
|
||||
_queue.clear();
|
||||
_saveQueue();
|
||||
|
||||
// Notify node provider to update the map
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void removeFromQueue(PendingUpload upload) {
|
||||
// Clean up pending node from cache before removing from queue
|
||||
_cleanupPendingNodeFromCache(upload);
|
||||
|
||||
_queue.remove(upload);
|
||||
_saveQueue();
|
||||
|
||||
// Notify node provider to update the map
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void retryUpload(PendingUpload upload) {
|
||||
upload.error = false;
|
||||
upload.clearError();
|
||||
upload.attempts = 0;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
@@ -208,53 +325,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) {
|
||||
@@ -304,8 +651,10 @@ class UploadQueueState extends ChangeNotifier {
|
||||
// Add/update the cache with the real node
|
||||
NodeCache.instance.addOrUpdate([realNode]);
|
||||
|
||||
// Clean up any temp nodes at the same coordinate
|
||||
NodeCache.instance.removeTempNodesByCoordinate(item.coord);
|
||||
// Clean up the specific temp node for this upload
|
||||
if (item.tempNodeId != null) {
|
||||
NodeCache.instance.removeTempNodeById(item.tempNodeId!);
|
||||
}
|
||||
|
||||
// For modify operations, clean up the original node's _pending_edit marker
|
||||
// For extract operations, we don't modify the original node so leave it unchanged
|
||||
@@ -316,7 +665,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// Notify node provider to update the map
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
}
|
||||
|
||||
// Handle successful deletion by removing the node from cache
|
||||
@@ -326,7 +675,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
NodeCache.instance.removeNodeById(item.originalNodeId!);
|
||||
|
||||
// Notify node provider to update the map
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +706,34 @@ 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 the specific temp node and the _pending_edit marker from original
|
||||
if (upload.tempNodeId != null) {
|
||||
NodeCache.instance.removeTempNodeById(upload.tempNodeId!);
|
||||
}
|
||||
if (upload.originalNodeId != null) {
|
||||
NodeCache.instance.removePendingEditMarker(upload.originalNodeId!);
|
||||
}
|
||||
} else if (upload.operation == UploadOperation.extract) {
|
||||
// For extracts: remove the specific temp node (leave original unchanged)
|
||||
if (upload.tempNodeId != null) {
|
||||
NodeCache.instance.removeTempNodeById(upload.tempNodeId!);
|
||||
}
|
||||
} else {
|
||||
// For creates: remove the specific temp node
|
||||
if (upload.tempNodeId != null) {
|
||||
NodeCache.instance.removeTempNodeById(upload.tempNodeId!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Queue persistence ----------
|
||||
Future<void> _saveQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -374,6 +751,17 @@ class UploadQueueState extends ChangeNotifier {
|
||||
..addAll(list.map((e) => PendingUpload.fromJson(e)));
|
||||
}
|
||||
|
||||
// Public method for migration purposes
|
||||
Future<void> reloadQueue() async {
|
||||
await _loadQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Public method to manually trigger cache repopulation (useful for debugging or after cache clears)
|
||||
void repopulateCacheFromQueue() {
|
||||
_repopulateCacheFromQueue();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_uploadTimer?.cancel();
|
||||
|
||||
@@ -59,10 +59,16 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
final minZoom = 1; // Always start from zoom 1 to show area overview when zoomed out
|
||||
final maxZoom = _zoom.toInt();
|
||||
|
||||
// Calculate maximum possible zoom based on tile count limit
|
||||
// Calculate maximum possible zoom based on tile count limit and tile provider max zoom
|
||||
final maxPossibleZoom = _calculateMaxZoomForTileLimit(bounds, minZoom);
|
||||
|
||||
final nTiles = computeTileList(bounds, minZoom, maxZoom).length;
|
||||
// Clamp current zoom to the effective maximum if it exceeds it
|
||||
if (_zoom > maxPossibleZoom) {
|
||||
_zoom = maxPossibleZoom.toDouble();
|
||||
}
|
||||
|
||||
final actualMaxZoom = _zoom.toInt();
|
||||
final nTiles = computeTileList(bounds, minZoom, actualMaxZoom).length;
|
||||
final tileEstimateKb = _getTileEstimateKb();
|
||||
final totalMb = (nTiles * tileEstimateKb) / 1024.0;
|
||||
final roundedMb = (totalMb * 10).round() / 10; // Round to nearest tenth
|
||||
@@ -76,15 +82,22 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
}
|
||||
|
||||
/// Calculate the maximum zoom level that keeps tile count under the absolute limit
|
||||
/// and respects the selected tile type's maximum zoom level
|
||||
int _calculateMaxZoomForTileLimit(LatLngBounds bounds, int minZoom) {
|
||||
for (int zoom = minZoom; zoom <= kAbsoluteMaxZoom; zoom++) {
|
||||
final appState = context.read<AppState>();
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
|
||||
// Use tile type's max zoom if available, otherwise fall back to absolute max
|
||||
final effectiveMaxZoom = selectedTileType?.maxZoom ?? kAbsoluteMaxZoom;
|
||||
|
||||
for (int zoom = minZoom; zoom <= effectiveMaxZoom; zoom++) {
|
||||
final tileCount = computeTileList(bounds, minZoom, zoom).length;
|
||||
if (tileCount > kAbsoluteMaxTileCount) {
|
||||
// Return the previous zoom level that was still under the absolute limit
|
||||
return math.max(minZoom, zoom - 1);
|
||||
}
|
||||
}
|
||||
return kAbsoluteMaxZoom;
|
||||
return effectiveMaxZoom;
|
||||
}
|
||||
|
||||
/// Get tile size estimate in KB, using preview tile data if available, otherwise fallback to constant
|
||||
|
||||
145
lib/widgets/map/map_data_manager.dart
Normal file
145
lib/widgets/map/map_data_manager.dart
Normal file
@@ -0,0 +1,145 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../node_provider_with_cache.dart';
|
||||
import '../../dev_config.dart';
|
||||
|
||||
/// Manages data fetching, filtering, and node limit logic for the map.
|
||||
/// Handles profile changes, zoom level restrictions, and node rendering limits.
|
||||
class MapDataManager {
|
||||
// Track node limit state for parent notification
|
||||
bool _lastNodeLimitState = false;
|
||||
|
||||
/// Get minimum zoom level for node fetching based on upload mode
|
||||
int getMinZoomForNodes(UploadMode uploadMode) {
|
||||
// OSM API (sandbox mode) needs higher zoom level due to bbox size limits
|
||||
if (uploadMode == UploadMode.sandbox) {
|
||||
return kOsmApiMinZoomLevel;
|
||||
} else {
|
||||
return kNodeMinZoomLevel;
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand bounds by the given multiplier, maintaining center point.
|
||||
/// Used to expand rendering bounds to prevent nodes blinking at screen edges.
|
||||
LatLngBounds _expandBounds(LatLngBounds bounds, double multiplier) {
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
final centerLng = (bounds.east + bounds.west) / 2;
|
||||
|
||||
final latSpan = (bounds.north - bounds.south) * multiplier / 2;
|
||||
final lngSpan = (bounds.east - bounds.west) * multiplier / 2;
|
||||
|
||||
return LatLngBounds(
|
||||
LatLng(centerLat - latSpan, centerLng - lngSpan),
|
||||
LatLng(centerLat + latSpan, centerLng + lngSpan),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get nodes to render based on current map state
|
||||
/// Returns a MapDataResult containing all relevant node data and limit state
|
||||
MapDataResult getNodesForRendering({
|
||||
required double currentZoom,
|
||||
required LatLngBounds? mapBounds,
|
||||
required UploadMode uploadMode,
|
||||
required int maxNodes,
|
||||
void Function(bool isLimited)? onNodeLimitChanged,
|
||||
}) {
|
||||
final minZoom = getMinZoomForNodes(uploadMode);
|
||||
List<OsmNode> allNodes;
|
||||
List<OsmNode> nodesToRender;
|
||||
bool isLimitActive = false;
|
||||
|
||||
if (currentZoom >= minZoom) {
|
||||
// Above minimum zoom - get cached nodes with expanded bounds to prevent edge blinking
|
||||
if (mapBounds != null) {
|
||||
final expandedBounds = _expandBounds(mapBounds, kNodeRenderingBoundsExpansion);
|
||||
allNodes = NodeProviderWithCache.instance.getCachedNodesForBounds(expandedBounds);
|
||||
} else {
|
||||
allNodes = <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
|
||||
if (validNodes.length > maxNodes) {
|
||||
nodesToRender = validNodes.take(maxNodes).toList();
|
||||
isLimitActive = true;
|
||||
debugPrint('[MapDataManager] 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((_) {
|
||||
onNodeLimitChanged?.call(isLimitActive);
|
||||
});
|
||||
}
|
||||
|
||||
return MapDataResult(
|
||||
allNodes: allNodes,
|
||||
nodesToRender: nodesToRender,
|
||||
isLimitActive: isLimitActive,
|
||||
validNodesCount: 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,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show zoom warning if user is below minimum zoom level
|
||||
void showZoomWarningIfNeeded(BuildContext context, double currentZoom, UploadMode uploadMode) {
|
||||
final minZoom = getMinZoomForNodes(uploadMode);
|
||||
|
||||
// Only show warning once per zoom level to avoid spam
|
||||
if (currentZoom.floor() == (minZoom - 1)) {
|
||||
final message = uploadMode == UploadMode.sandbox
|
||||
? 'Zoom to level $minZoom or higher to see nodes in sandbox mode (OSM API bbox limit)'
|
||||
: 'Zoom to level $minZoom or higher to see surveillance nodes';
|
||||
|
||||
// Show a brief snackbar
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
duration: const Duration(seconds: 4),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result object containing node data and rendering state
|
||||
class MapDataResult {
|
||||
final List<OsmNode> allNodes;
|
||||
final List<OsmNode> nodesToRender;
|
||||
final bool isLimitActive;
|
||||
final int validNodesCount;
|
||||
|
||||
const MapDataResult({
|
||||
required this.allNodes,
|
||||
required this.nodesToRender,
|
||||
required this.isLimitActive,
|
||||
required this.validNodesCount,
|
||||
});
|
||||
}
|
||||
57
lib/widgets/map/map_interaction_manager.dart
Normal file
57
lib/widgets/map/map_interaction_manager.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../state/session_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
|
||||
/// Manages map interaction options and gesture handling logic.
|
||||
/// Handles constrained node interactions, zoom restrictions, and gesture configuration.
|
||||
class MapInteractionManager {
|
||||
|
||||
/// Get interaction options for the map based on whether we're editing a constrained node.
|
||||
/// Allows zoom and rotation but disables all forms of panning for constrained nodes unless extract is enabled.
|
||||
InteractionOptions getInteractionOptions(EditNodeSession? editSession) {
|
||||
// Check if we're editing a constrained node that's not being extracted
|
||||
if (editSession?.originalNode.isConstrained == true && editSession?.extractFromWay != true) {
|
||||
// Constrained node (not extracting): only allow pinch zoom and rotation, disable ALL panning
|
||||
return const InteractionOptions(
|
||||
enableMultiFingerGestureRace: true,
|
||||
flags: InteractiveFlag.pinchZoom | InteractiveFlag.rotate,
|
||||
scrollWheelVelocity: kScrollWheelVelocity,
|
||||
pinchZoomThreshold: kPinchZoomThreshold,
|
||||
pinchMoveThreshold: kPinchMoveThreshold,
|
||||
);
|
||||
}
|
||||
|
||||
// Normal case: all interactions allowed with gesture race to prevent accidental rotation during zoom
|
||||
return const InteractionOptions(
|
||||
enableMultiFingerGestureRace: true,
|
||||
flags: InteractiveFlag.doubleTapDragZoom |
|
||||
InteractiveFlag.doubleTapZoom |
|
||||
InteractiveFlag.drag |
|
||||
InteractiveFlag.flingAnimation |
|
||||
InteractiveFlag.pinchZoom |
|
||||
InteractiveFlag.rotate |
|
||||
InteractiveFlag.scrollWheelZoom,
|
||||
scrollWheelVelocity: kScrollWheelVelocity,
|
||||
pinchZoomThreshold: kPinchZoomThreshold,
|
||||
pinchMoveThreshold: kPinchMoveThreshold,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if the map has moved significantly enough to cancel stale tile requests.
|
||||
/// Uses a simple distance threshold - roughly equivalent to 1/4 screen width at zoom 15.
|
||||
bool mapMovedSignificantly(LatLng? newCenter, LatLng? oldCenter) {
|
||||
if (newCenter == null || oldCenter == null) return false;
|
||||
|
||||
// Calculate approximate distance in meters (rough calculation for performance)
|
||||
final latDiff = (newCenter.latitude - oldCenter.latitude).abs();
|
||||
final lngDiff = (newCenter.longitude - oldCenter.longitude).abs();
|
||||
|
||||
// Threshold: ~500 meters (roughly 1/4 screen at zoom 15)
|
||||
// This prevents excessive cancellations on small movements while catching real pans
|
||||
const double significantMovementThreshold = 0.005; // degrees (~500m at equator)
|
||||
|
||||
return latDiff > significantMovementThreshold || lngDiff > significantMovementThreshold;
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,7 @@ class MapOverlays extends StatelessWidget {
|
||||
children: [
|
||||
// Search/Navigation button - show search button always, show route button only in dev mode when online
|
||||
if (onSearchPressed != null) ...[
|
||||
if (appState.showSearchButton || (enableNavigationFeatures(offlineMode: appState.offlineMode) && appState.showRouteButton)) ...[
|
||||
if ((!appState.offlineMode && appState.showSearchButton) || appState.showRouteButton) ...[
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
heroTag: "search_nav",
|
||||
|
||||
248
lib/widgets/map/marker_layer_builder.dart
Normal file
248
lib/widgets/map/marker_layer_builder.dart
Normal file
@@ -0,0 +1,248 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../models/suspected_location.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../state/session_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
import '../camera_icon.dart';
|
||||
import '../provisional_pin.dart';
|
||||
import 'node_markers.dart';
|
||||
import 'suspected_location_markers.dart';
|
||||
|
||||
/// Enumeration for different pin types in navigation
|
||||
enum PinType { start, end }
|
||||
|
||||
/// Simple location pin widget for route visualization
|
||||
class LocationPin extends StatelessWidget {
|
||||
final PinType type;
|
||||
|
||||
const LocationPin({super.key, required this.type});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 32.0,
|
||||
height: 32.0,
|
||||
decoration: BoxDecoration(
|
||||
color: type == PinType.start ? Colors.green : Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
child: Icon(
|
||||
type == PinType.start ? Icons.play_arrow : Icons.stop,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds all marker layers for the map including surveillance nodes, suspected locations,
|
||||
/// session markers, navigation pins, and route visualization.
|
||||
class MarkerLayerBuilder {
|
||||
|
||||
/// Build complete marker layers for the map
|
||||
static Widget buildMarkerLayers({
|
||||
required List<OsmNode> nodesToRender,
|
||||
required AnimatedMapController mapController,
|
||||
required AppState appState,
|
||||
required AddNodeSession? session,
|
||||
required EditNodeSession? editSession,
|
||||
required int? selectedNodeId,
|
||||
required LatLng? userLocation,
|
||||
required double currentZoom,
|
||||
required LatLngBounds? mapBounds,
|
||||
required Function(OsmNode)? onNodeTap,
|
||||
required Function(SuspectedLocation)? onSuspectedLocationTap,
|
||||
}) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
|
||||
// Determine if nodes should be dimmed and/or disabled
|
||||
final shouldDimNodes = appState.selectedSuspectedLocation != null ||
|
||||
appState.isInSearchMode ||
|
||||
appState.showingOverview;
|
||||
|
||||
// Disable node interactions when navigation is in conflicting state
|
||||
final shouldDisableNodeTaps = appState.isInSearchMode || appState.showingOverview;
|
||||
|
||||
final markers = NodeMarkersBuilder.buildNodeMarkers(
|
||||
nodes: nodesToRender,
|
||||
mapController: mapController.mapController,
|
||||
userLocation: userLocation,
|
||||
selectedNodeId: selectedNodeId,
|
||||
onNodeTap: onNodeTap, // Keep the original callback
|
||||
shouldDim: shouldDimNodes,
|
||||
enabled: !shouldDisableNodeTaps, // Use enabled parameter instead
|
||||
);
|
||||
|
||||
// Build suspected location markers (respect same zoom and count limits as nodes)
|
||||
final suspectedLocationMarkers = <Marker>[];
|
||||
if (appState.suspectedLocationsEnabled && mapBounds != null &&
|
||||
currentZoom >= (appState.uploadMode == UploadMode.sandbox ? kOsmApiMinZoomLevel : kNodeMinZoomLevel)) {
|
||||
final suspectedLocations = appState.getSuspectedLocationsInBounds(
|
||||
north: mapBounds.north,
|
||||
south: mapBounds.south,
|
||||
east: mapBounds.east,
|
||||
west: mapBounds.west,
|
||||
);
|
||||
|
||||
// Apply same node count limit as surveillance nodes
|
||||
final maxNodes = appState.maxNodes;
|
||||
final limitedSuspectedLocations = suspectedLocations.take(maxNodes).toList();
|
||||
|
||||
// Filter out suspected locations that are too close to real nodes
|
||||
final filteredSuspectedLocations = _filterSuspectedLocationsByProximity(
|
||||
suspectedLocations: limitedSuspectedLocations,
|
||||
realNodes: nodesToRender,
|
||||
minDistance: appState.suspectedLocationMinDistance,
|
||||
);
|
||||
|
||||
suspectedLocationMarkers.addAll(
|
||||
SuspectedLocationMarkersBuilder.buildSuspectedLocationMarkers(
|
||||
locations: filteredSuspectedLocations,
|
||||
mapController: mapController.mapController,
|
||||
selectedLocationId: appState.selectedSuspectedLocation?.ticketNo,
|
||||
onLocationTap: onSuspectedLocationTap, // Keep the original callback
|
||||
shouldDimAll: shouldDisableNodeTaps,
|
||||
enabled: !shouldDisableNodeTaps, // Use enabled parameter instead
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Build center marker for add/edit sessions
|
||||
final centerMarkers = _buildSessionMarkers(
|
||||
mapController: mapController,
|
||||
session: session,
|
||||
editSession: editSession,
|
||||
);
|
||||
|
||||
// Build provisional pin for navigation/search mode
|
||||
final navigationMarkers = _buildNavigationMarkers(appState);
|
||||
|
||||
// Build start/end pins for route visualization
|
||||
final routeMarkers = _buildRouteMarkers(appState);
|
||||
|
||||
return MarkerLayer(
|
||||
markers: [
|
||||
...suspectedLocationMarkers,
|
||||
...markers,
|
||||
...centerMarkers,
|
||||
...navigationMarkers,
|
||||
...routeMarkers,
|
||||
]
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build center markers for add/edit sessions
|
||||
static List<Marker> _buildSessionMarkers({
|
||||
required AnimatedMapController mapController,
|
||||
required AddNodeSession? session,
|
||||
required EditNodeSession? editSession,
|
||||
}) {
|
||||
final centerMarkers = <Marker>[];
|
||||
if (session != null || editSession != null) {
|
||||
try {
|
||||
final center = mapController.mapController.camera.center;
|
||||
centerMarkers.add(
|
||||
Marker(
|
||||
point: center,
|
||||
width: kNodeIconDiameter,
|
||||
height: kNodeIconDiameter,
|
||||
child: CameraIcon(
|
||||
type: editSession != null ? CameraIconType.editing : CameraIconType.mock,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
// Controller not ready yet
|
||||
}
|
||||
}
|
||||
return centerMarkers;
|
||||
}
|
||||
|
||||
/// Build provisional pin for navigation/search mode
|
||||
static List<Marker> _buildNavigationMarkers(AppState appState) {
|
||||
final markers = <Marker>[];
|
||||
if (appState.showProvisionalPin && appState.provisionalPinLocation != null) {
|
||||
markers.add(
|
||||
Marker(
|
||||
point: appState.provisionalPinLocation!,
|
||||
width: 32.0,
|
||||
height: 32.0,
|
||||
child: const ProvisionalPin(),
|
||||
),
|
||||
);
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
/// Build start/end pins for route visualization
|
||||
static List<Marker> _buildRouteMarkers(AppState appState) {
|
||||
final markers = <Marker>[];
|
||||
if (appState.showingOverview || appState.isInRouteMode || appState.isSettingSecondPoint) {
|
||||
if (appState.routeStart != null) {
|
||||
markers.add(
|
||||
Marker(
|
||||
point: appState.routeStart!,
|
||||
width: 32.0,
|
||||
height: 32.0,
|
||||
child: const LocationPin(type: PinType.start),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (appState.routeEnd != null) {
|
||||
markers.add(
|
||||
Marker(
|
||||
point: appState.routeEnd!,
|
||||
width: 32.0,
|
||||
height: 32.0,
|
||||
child: const LocationPin(type: PinType.end),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
/// Filter suspected locations that are too close to real nodes
|
||||
static List<SuspectedLocation> _filterSuspectedLocationsByProximity({
|
||||
required List<SuspectedLocation> suspectedLocations,
|
||||
required List<OsmNode> realNodes,
|
||||
required int minDistance, // in meters
|
||||
}) {
|
||||
if (minDistance <= 0) return suspectedLocations;
|
||||
|
||||
const distance = Distance();
|
||||
final filteredLocations = <SuspectedLocation>[];
|
||||
|
||||
for (final suspected in suspectedLocations) {
|
||||
bool tooClose = false;
|
||||
|
||||
for (final realNode in realNodes) {
|
||||
final distanceMeters = distance.as(
|
||||
LengthUnit.Meter,
|
||||
suspected.centroid,
|
||||
realNode.coord,
|
||||
);
|
||||
|
||||
if (distanceMeters < minDistance) {
|
||||
tooClose = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!tooClose) {
|
||||
filteredLocations.add(suspected);
|
||||
}
|
||||
}
|
||||
|
||||
return filteredLocations;
|
||||
}
|
||||
}
|
||||
@@ -8,29 +8,33 @@ import '../../models/osm_node.dart';
|
||||
import '../node_tag_sheet.dart';
|
||||
import '../camera_icon.dart';
|
||||
|
||||
/// Smart marker widget for camera with single/double tap distinction
|
||||
class CameraMapMarker extends StatefulWidget {
|
||||
/// Smart marker widget for surveillance node with single/double tap distinction
|
||||
class NodeMapMarker extends StatefulWidget {
|
||||
final OsmNode node;
|
||||
final MapController mapController;
|
||||
final void Function(OsmNode)? onNodeTap;
|
||||
final bool enabled;
|
||||
|
||||
const CameraMapMarker({
|
||||
const NodeMapMarker({
|
||||
required this.node,
|
||||
required this.mapController,
|
||||
this.onNodeTap,
|
||||
this.enabled = true,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CameraMapMarker> createState() => _CameraMapMarkerState();
|
||||
State<NodeMapMarker> createState() => _NodeMapMarkerState();
|
||||
}
|
||||
|
||||
class _CameraMapMarkerState extends State<CameraMapMarker> {
|
||||
class _NodeMapMarkerState extends State<NodeMapMarker> {
|
||||
Timer? _tapTimer;
|
||||
// From dev_config.dart for build-time parameters
|
||||
static const Duration tapTimeout = kMarkerTapTimeout;
|
||||
|
||||
void _onTap() {
|
||||
if (!widget.enabled) return; // Don't respond to taps when disabled
|
||||
|
||||
_tapTimer = Timer(tapTimeout, () {
|
||||
// Don't center immediately - let the sheet opening handle the coordinated animation
|
||||
|
||||
@@ -48,6 +52,8 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
|
||||
}
|
||||
|
||||
void _onDoubleTap() {
|
||||
if (!widget.enabled) return; // Don't respond to double taps when disabled
|
||||
|
||||
_tapTimer?.cancel();
|
||||
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta);
|
||||
}
|
||||
@@ -60,7 +66,7 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Check camera state
|
||||
// Check node state
|
||||
final isPendingUpload = widget.node.tags.containsKey('_pending_upload') &&
|
||||
widget.node.tags['_pending_upload'] == 'true';
|
||||
final isPendingEdit = widget.node.tags.containsKey('_pending_edit') &&
|
||||
@@ -87,20 +93,21 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper class to build marker layers for cameras and user location
|
||||
class CameraMarkersBuilder {
|
||||
static List<Marker> buildCameraMarkers({
|
||||
required List<OsmNode> cameras,
|
||||
/// Helper class to build marker layers for surveillance nodes and user location
|
||||
class NodeMarkersBuilder {
|
||||
static List<Marker> buildNodeMarkers({
|
||||
required List<OsmNode> nodes,
|
||||
required MapController mapController,
|
||||
LatLng? userLocation,
|
||||
int? selectedNodeId,
|
||||
void Function(OsmNode)? onNodeTap,
|
||||
bool shouldDim = false,
|
||||
bool enabled = true,
|
||||
}) {
|
||||
final markers = <Marker>[
|
||||
// Camera markers
|
||||
...cameras
|
||||
.where(_isValidCameraCoordinate)
|
||||
// Node markers
|
||||
...nodes
|
||||
.where(_isValidNodeCoordinate)
|
||||
.map((n) {
|
||||
// Check if this node should be highlighted (selected) or dimmed
|
||||
final isSelected = selectedNodeId == n.id;
|
||||
@@ -112,10 +119,11 @@ class CameraMarkersBuilder {
|
||||
height: kNodeIconDiameter,
|
||||
child: Opacity(
|
||||
opacity: shouldDimNode ? 0.5 : 1.0,
|
||||
child: CameraMapMarker(
|
||||
child: NodeMapMarker(
|
||||
node: n,
|
||||
mapController: mapController,
|
||||
onNodeTap: onNodeTap,
|
||||
enabled: enabled,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -134,7 +142,7 @@ class CameraMarkersBuilder {
|
||||
return markers;
|
||||
}
|
||||
|
||||
static bool _isValidCameraCoordinate(OsmNode node) {
|
||||
static bool _isValidNodeCoordinate(OsmNode node) {
|
||||
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||
node.coord.latitude.abs() <= 90 &&
|
||||
node.coord.longitude.abs() <= 180;
|
||||
@@ -6,31 +6,31 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../../models/node_profile.dart';
|
||||
import '../../app_state.dart' show UploadMode;
|
||||
import '../../services/prefetch_area_service.dart';
|
||||
import '../camera_provider_with_cache.dart';
|
||||
import '../node_provider_with_cache.dart';
|
||||
import '../../dev_config.dart';
|
||||
|
||||
/// Manages camera data refreshing, profile change detection, and camera cache operations.
|
||||
/// Handles debounced camera fetching and profile-based cache invalidation.
|
||||
class CameraRefreshController {
|
||||
late final CameraProviderWithCache _cameraProvider;
|
||||
/// Manages node data refreshing, profile change detection, and node cache operations.
|
||||
/// Handles debounced node fetching and profile-based cache invalidation.
|
||||
class NodeRefreshController {
|
||||
late final NodeProviderWithCache _nodeProvider;
|
||||
List<NodeProfile>? _lastEnabledProfiles;
|
||||
VoidCallback? _onCamerasUpdated;
|
||||
VoidCallback? _onNodesUpdated;
|
||||
|
||||
/// Initialize the camera refresh controller
|
||||
void initialize({required VoidCallback onCamerasUpdated}) {
|
||||
_cameraProvider = CameraProviderWithCache.instance;
|
||||
_onCamerasUpdated = onCamerasUpdated;
|
||||
_cameraProvider.addListener(_onCamerasUpdated!);
|
||||
/// Initialize the node refresh controller
|
||||
void initialize({required VoidCallback onNodesUpdated}) {
|
||||
_nodeProvider = NodeProviderWithCache.instance;
|
||||
_onNodesUpdated = onNodesUpdated;
|
||||
_nodeProvider.addListener(_onNodesUpdated!);
|
||||
}
|
||||
|
||||
/// Dispose of resources and listeners
|
||||
void dispose() {
|
||||
if (_onCamerasUpdated != null) {
|
||||
_cameraProvider.removeListener(_onCamerasUpdated!);
|
||||
if (_onNodesUpdated != null) {
|
||||
_nodeProvider.removeListener(_onNodesUpdated!);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if camera profiles changed and handle cache clearing if needed.
|
||||
/// Check if node profiles changed and handle cache clearing if needed.
|
||||
/// Returns true if profiles changed (triggering a refresh).
|
||||
bool checkAndHandleProfileChanges({
|
||||
required List<NodeProfile> currentEnabledProfiles,
|
||||
@@ -42,13 +42,13 @@ class CameraRefreshController {
|
||||
|
||||
// Handle profile change with cache clearing and refresh
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Clear camera cache to ensure fresh data for new profile combination
|
||||
_cameraProvider.clearCache();
|
||||
// Clear node cache to ensure fresh data for new profile combination
|
||||
_nodeProvider.clearCache();
|
||||
// Clear pre-fetch area since profiles changed
|
||||
PrefetchAreaService().clearPreFetchedArea();
|
||||
// Force display refresh first (for immediate UI update)
|
||||
_cameraProvider.refreshDisplay();
|
||||
// Notify that profiles changed (triggers camera refresh)
|
||||
_nodeProvider.refreshDisplay();
|
||||
// Notify that profiles changed (triggers node refresh)
|
||||
onProfilesChanged();
|
||||
});
|
||||
|
||||
@@ -57,8 +57,8 @@ class CameraRefreshController {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Refresh cameras from provider for the current map view
|
||||
void refreshCamerasFromProvider({
|
||||
/// Refresh nodes from provider for the current map view
|
||||
void refreshNodesFromProvider({
|
||||
required AnimatedMapController controller,
|
||||
required List<NodeProfile> enabledProfiles,
|
||||
required UploadMode uploadMode,
|
||||
@@ -85,15 +85,15 @@ class CameraRefreshController {
|
||||
return;
|
||||
}
|
||||
|
||||
_cameraProvider.fetchAndUpdate(
|
||||
_nodeProvider.fetchAndUpdate(
|
||||
bounds: bounds,
|
||||
profiles: enabledProfiles,
|
||||
uploadMode: uploadMode,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get the camera provider instance for external access
|
||||
CameraProviderWithCache get cameraProvider => _cameraProvider;
|
||||
/// Get the node provider instance for external access
|
||||
NodeProviderWithCache get nodeProvider => _nodeProvider;
|
||||
|
||||
/// Helper to check if two profile lists are equal by comparing IDs
|
||||
bool _profileListsEqual(List<NodeProfile> list1, List<NodeProfile> list2) {
|
||||
114
lib/widgets/map/overlay_layer_builder.dart
Normal file
114
lib/widgets/map/overlay_layer_builder.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../state/session_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
import 'direction_cones.dart';
|
||||
|
||||
/// Builds overlay layers including direction cones, edit lines, selected location bounds, and route paths.
|
||||
class OverlayLayerBuilder {
|
||||
|
||||
/// Build all overlay layers for the map
|
||||
static List<Widget> buildOverlayLayers({
|
||||
required List<OsmNode> nodesToRender,
|
||||
required double currentZoom,
|
||||
required AddNodeSession? session,
|
||||
required EditNodeSession? editSession,
|
||||
required AppState appState,
|
||||
required BuildContext context,
|
||||
}) {
|
||||
final layers = <Widget>[];
|
||||
|
||||
// Direction cones (polygons)
|
||||
final overlays = DirectionConesBuilder.buildDirectionCones(
|
||||
cameras: nodesToRender,
|
||||
zoom: currentZoom,
|
||||
session: session,
|
||||
editSession: editSession,
|
||||
context: context,
|
||||
);
|
||||
|
||||
// Add suspected location bounds if one is selected
|
||||
if (appState.selectedSuspectedLocation != null) {
|
||||
final selectedLocation = appState.selectedSuspectedLocation!;
|
||||
if (selectedLocation.bounds.isNotEmpty) {
|
||||
overlays.add(
|
||||
Polygon(
|
||||
points: selectedLocation.bounds,
|
||||
color: Colors.orange.withOpacity(0.3),
|
||||
borderColor: Colors.orange,
|
||||
borderStrokeWidth: 2.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add polygon layer
|
||||
layers.add(PolygonLayer(polygons: overlays));
|
||||
|
||||
// Build edit lines connecting original nodes to their edited positions
|
||||
final editLines = _buildEditLines(nodesToRender);
|
||||
if (editLines.isNotEmpty) {
|
||||
layers.add(PolylineLayer(polylines: editLines));
|
||||
}
|
||||
|
||||
// Build route path visualization
|
||||
final routeLines = _buildRouteLines(appState);
|
||||
if (routeLines.isNotEmpty) {
|
||||
layers.add(PolylineLayer(polylines: routeLines));
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
/// Build polylines connecting original cameras to their edited positions
|
||||
static List<Polyline> _buildEditLines(List<OsmNode> nodes) {
|
||||
final lines = <Polyline>[];
|
||||
|
||||
// Create a lookup map of original node IDs to their coordinates
|
||||
final originalNodes = <int, LatLng>{};
|
||||
for (final node in nodes) {
|
||||
if (node.tags['_pending_edit'] == 'true') {
|
||||
originalNodes[node.id] = node.coord;
|
||||
}
|
||||
}
|
||||
|
||||
// Find edited nodes and draw lines to their originals
|
||||
for (final node in nodes) {
|
||||
final originalIdStr = node.tags['_original_node_id'];
|
||||
if (originalIdStr != null && node.tags['_pending_upload'] == 'true') {
|
||||
final originalId = int.tryParse(originalIdStr);
|
||||
final originalCoord = originalId != null ? originalNodes[originalId] : null;
|
||||
|
||||
if (originalCoord != null) {
|
||||
lines.add(Polyline(
|
||||
points: [originalCoord, node.coord],
|
||||
color: kNodeRingColorPending,
|
||||
strokeWidth: 3.0,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// Build route path visualization
|
||||
static List<Polyline> _buildRouteLines(AppState appState) {
|
||||
final routeLines = <Polyline>[];
|
||||
if (appState.routePath != null && appState.routePath!.length > 1) {
|
||||
// Show route line during overview or active route
|
||||
if (appState.showingOverview || appState.isInRouteMode) {
|
||||
routeLines.add(Polyline(
|
||||
points: appState.routePath!,
|
||||
color: Colors.blue,
|
||||
strokeWidth: 4.0,
|
||||
));
|
||||
}
|
||||
}
|
||||
return routeLines;
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,13 @@ class SuspectedLocationMapMarker extends StatefulWidget {
|
||||
final SuspectedLocation location;
|
||||
final MapController mapController;
|
||||
final void Function(SuspectedLocation)? onLocationTap;
|
||||
final bool enabled;
|
||||
|
||||
const SuspectedLocationMapMarker({
|
||||
required this.location,
|
||||
required this.mapController,
|
||||
this.onLocationTap,
|
||||
this.enabled = true,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@@ -31,6 +33,8 @@ class _SuspectedLocationMapMarkerState extends State<SuspectedLocationMapMarker>
|
||||
static const Duration tapTimeout = kMarkerTapTimeout;
|
||||
|
||||
void _onTap() {
|
||||
if (!widget.enabled) return; // Don't respond to taps when disabled
|
||||
|
||||
_tapTimer = Timer(tapTimeout, () {
|
||||
// Use callback if provided, otherwise fallback to direct modal
|
||||
if (widget.onLocationTap != null) {
|
||||
@@ -46,6 +50,8 @@ class _SuspectedLocationMapMarkerState extends State<SuspectedLocationMapMarker>
|
||||
}
|
||||
|
||||
void _onDoubleTap() {
|
||||
if (!widget.enabled) return; // Don't respond to double taps when disabled
|
||||
|
||||
_tapTimer?.cancel();
|
||||
widget.mapController.move(widget.location.centroid, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta);
|
||||
}
|
||||
@@ -73,6 +79,8 @@ class SuspectedLocationMarkersBuilder {
|
||||
required MapController mapController,
|
||||
String? selectedLocationId,
|
||||
void Function(SuspectedLocation)? onLocationTap,
|
||||
bool shouldDimAll = false,
|
||||
bool enabled = true,
|
||||
}) {
|
||||
final markers = <Marker>[];
|
||||
|
||||
@@ -81,7 +89,7 @@ class SuspectedLocationMarkersBuilder {
|
||||
|
||||
// Check if this location should be highlighted (selected) or dimmed
|
||||
final isSelected = selectedLocationId == location.ticketNo;
|
||||
final shouldDim = selectedLocationId != null && !isSelected;
|
||||
final shouldDim = shouldDimAll || (selectedLocationId != null && !isSelected);
|
||||
|
||||
markers.add(
|
||||
Marker(
|
||||
@@ -94,6 +102,7 @@ class SuspectedLocationMarkersBuilder {
|
||||
location: location,
|
||||
mapController: mapController,
|
||||
onLocationTap: onLocationTap,
|
||||
enabled: enabled,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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!,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,18 +14,18 @@ import '../models/suspected_location.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'debouncer.dart';
|
||||
import 'camera_provider_with_cache.dart';
|
||||
import 'camera_icon.dart';
|
||||
import 'map/camera_markers.dart';
|
||||
import 'map/direction_cones.dart';
|
||||
import 'node_provider_with_cache.dart';
|
||||
import 'map/map_overlays.dart';
|
||||
import 'map/map_position_manager.dart';
|
||||
import 'map/tile_layer_manager.dart';
|
||||
import 'map/camera_refresh_controller.dart';
|
||||
import 'map/node_refresh_controller.dart';
|
||||
import 'map/gps_controller.dart';
|
||||
import 'map/suspected_location_markers.dart';
|
||||
import 'map/map_data_manager.dart';
|
||||
import 'map/map_interaction_manager.dart';
|
||||
import 'map/marker_layer_builder.dart';
|
||||
import 'map/overlay_layer_builder.dart';
|
||||
import 'network_status_indicator.dart';
|
||||
import 'provisional_pin.dart';
|
||||
import 'node_limit_indicator.dart';
|
||||
import 'proximity_alert_banner.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../app_state.dart' show FollowMeMode;
|
||||
@@ -44,6 +44,7 @@ class MapView extends StatefulWidget {
|
||||
this.onNodeTap,
|
||||
this.onSuspectedLocationTap,
|
||||
this.onSearchPressed,
|
||||
this.onNodeLimitChanged,
|
||||
});
|
||||
|
||||
final FollowMeMode followMeMode;
|
||||
@@ -53,6 +54,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();
|
||||
@@ -67,8 +69,10 @@ class MapViewState extends State<MapView> {
|
||||
|
||||
late final MapPositionManager _positionManager;
|
||||
late final TileLayerManager _tileManager;
|
||||
late final CameraRefreshController _cameraController;
|
||||
late final NodeRefreshController _nodeController;
|
||||
late final GpsController _gpsController;
|
||||
late final MapDataManager _dataManager;
|
||||
late final MapInteractionManager _interactionManager;
|
||||
|
||||
// Track zoom to clear queue on zoom changes
|
||||
double? _lastZoom;
|
||||
@@ -76,8 +80,6 @@ class MapViewState extends State<MapView> {
|
||||
// Track map center to clear queue on significant panning
|
||||
LatLng? _lastCenter;
|
||||
|
||||
|
||||
|
||||
// State for proximity alert banner
|
||||
bool _showProximityBanner = false;
|
||||
|
||||
@@ -91,9 +93,11 @@ class MapViewState extends State<MapView> {
|
||||
_positionManager = MapPositionManager();
|
||||
_tileManager = TileLayerManager();
|
||||
_tileManager.initialize();
|
||||
_cameraController = CameraRefreshController();
|
||||
_cameraController.initialize(onCamerasUpdated: _onNodesUpdated);
|
||||
_nodeController = NodeRefreshController();
|
||||
_nodeController.initialize(onNodesUpdated: _onNodesUpdated);
|
||||
_gpsController = GpsController();
|
||||
_dataManager = MapDataManager();
|
||||
_interactionManager = MapInteractionManager();
|
||||
|
||||
// Initialize proximity alert service
|
||||
ProximityAlertService().initialize(
|
||||
@@ -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)
|
||||
? NodeProviderWithCache.instance.getCachedNodesForBounds(mapBounds)
|
||||
: [];
|
||||
} catch (e) {
|
||||
debugPrint('[MapView] Could not get nearby nodes: $e');
|
||||
@@ -206,7 +209,7 @@ class MapViewState extends State<MapView> {
|
||||
_cameraDebounce.dispose();
|
||||
_tileDebounce.dispose();
|
||||
_mapPositionDebounce.dispose();
|
||||
_cameraController.dispose();
|
||||
_nodeController.dispose();
|
||||
_tileManager.dispose();
|
||||
_gpsController.dispose();
|
||||
PrefetchAreaService().dispose();
|
||||
@@ -233,93 +236,12 @@ class MapViewState extends State<MapView> {
|
||||
static Future<void> clearStoredMapPosition() =>
|
||||
MapPositionManager.clearStoredMapPosition();
|
||||
|
||||
/// Get minimum zoom level for node fetching based on upload mode
|
||||
int _getMinZoomForNodes(BuildContext context) {
|
||||
final appState = context.read<AppState>();
|
||||
final uploadMode = appState.uploadMode;
|
||||
|
||||
// OSM API (sandbox mode) needs higher zoom level due to bbox size limits
|
||||
if (uploadMode == UploadMode.sandbox) {
|
||||
return kOsmApiMinZoomLevel;
|
||||
} else {
|
||||
return kNodeMinZoomLevel;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the map has moved significantly enough to cancel stale tile requests.
|
||||
/// Uses a simple distance threshold - roughly equivalent to 1/4 screen width at zoom 15.
|
||||
bool _mapMovedSignificantly(LatLng? newCenter, LatLng? oldCenter) {
|
||||
if (newCenter == null || oldCenter == null) return false;
|
||||
|
||||
// Calculate approximate distance in meters (rough calculation for performance)
|
||||
final latDiff = (newCenter.latitude - oldCenter.latitude).abs();
|
||||
final lngDiff = (newCenter.longitude - oldCenter.longitude).abs();
|
||||
|
||||
// Threshold: ~500 meters (roughly 1/4 screen at zoom 15)
|
||||
// This prevents excessive cancellations on small movements while catching real pans
|
||||
const double significantMovementThreshold = 0.005; // degrees (~500m at equator)
|
||||
|
||||
return latDiff > significantMovementThreshold || lngDiff > significantMovementThreshold;
|
||||
}
|
||||
|
||||
/// Get interaction options for the map based on whether we're editing a constrained node.
|
||||
/// Allows zoom and rotation but disables all forms of panning for constrained nodes unless extract is enabled.
|
||||
InteractionOptions _getInteractionOptions(EditNodeSession? editSession) {
|
||||
// Check if we're editing a constrained node that's not being extracted
|
||||
if (editSession?.originalNode.isConstrained == true && editSession?.extractFromWay != true) {
|
||||
// Constrained node (not extracting): only allow pinch zoom and rotation, disable ALL panning
|
||||
return const InteractionOptions(
|
||||
enableMultiFingerGestureRace: true,
|
||||
flags: InteractiveFlag.pinchZoom | InteractiveFlag.rotate,
|
||||
scrollWheelVelocity: kScrollWheelVelocity,
|
||||
pinchZoomThreshold: kPinchZoomThreshold,
|
||||
pinchMoveThreshold: kPinchMoveThreshold,
|
||||
);
|
||||
}
|
||||
|
||||
// Normal case: all interactions allowed with gesture race to prevent accidental rotation during zoom
|
||||
return const InteractionOptions(
|
||||
enableMultiFingerGestureRace: true,
|
||||
flags: InteractiveFlag.doubleTapDragZoom |
|
||||
InteractiveFlag.doubleTapZoom |
|
||||
InteractiveFlag.drag |
|
||||
InteractiveFlag.flingAnimation |
|
||||
InteractiveFlag.pinchZoom |
|
||||
InteractiveFlag.rotate |
|
||||
InteractiveFlag.scrollWheelZoom,
|
||||
scrollWheelVelocity: kScrollWheelVelocity,
|
||||
pinchZoomThreshold: kPinchZoomThreshold,
|
||||
pinchMoveThreshold: kPinchMoveThreshold,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show zoom warning if user is below minimum zoom level
|
||||
void _showZoomWarningIfNeeded(BuildContext context, double currentZoom, int minZoom) {
|
||||
// Only show warning once per zoom level to avoid spam
|
||||
if (currentZoom.floor() == (minZoom - 1)) {
|
||||
final appState = context.read<AppState>();
|
||||
final uploadMode = appState.uploadMode;
|
||||
|
||||
final message = uploadMode == UploadMode.sandbox
|
||||
? 'Zoom to level $minZoom or higher to see nodes in sandbox mode (OSM API bbox limit)'
|
||||
: 'Zoom to level $minZoom or higher to see surveillance nodes';
|
||||
|
||||
// Show a brief snackbar
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
duration: const Duration(seconds: 4),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void _refreshNodesFromProvider() {
|
||||
final appState = context.read<AppState>();
|
||||
_cameraController.refreshCamerasFromProvider(
|
||||
_nodeController.refreshNodesFromProvider(
|
||||
controller: _controller,
|
||||
enabledProfiles: appState.enabledProfiles,
|
||||
uploadMode: appState.uploadMode,
|
||||
@@ -351,8 +273,8 @@ class MapViewState extends State<MapView> {
|
||||
final session = appState.session;
|
||||
final editSession = appState.editSession;
|
||||
|
||||
// Check if enabled profiles changed and refresh cameras if needed
|
||||
_cameraController.checkAndHandleProfileChanges(
|
||||
// Check if enabled profiles changed and refresh nodes if needed
|
||||
_nodeController.checkAndHandleProfileChanges(
|
||||
currentEnabledProfiles: appState.enabledProfiles,
|
||||
onProfilesChanged: _refreshNodesFromProvider,
|
||||
);
|
||||
@@ -395,187 +317,79 @@ 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;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Get node data using the data manager
|
||||
final nodeData = _dataManager.getNodesForRendering(
|
||||
currentZoom: currentZoom,
|
||||
mapBounds: mapBounds,
|
||||
uploadMode: appState.uploadMode,
|
||||
maxNodes: appState.maxNodes,
|
||||
onNodeLimitChanged: widget.onNodeLimitChanged,
|
||||
);
|
||||
|
||||
// Build camera layers using the limited nodes
|
||||
Widget cameraLayers = LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
|
||||
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>[];
|
||||
}
|
||||
|
||||
// Determine if we should dim node markers (when suspected location is selected)
|
||||
final shouldDimNodes = appState.selectedSuspectedLocation != null;
|
||||
|
||||
final markers = CameraMarkersBuilder.buildCameraMarkers(
|
||||
cameras: nodes,
|
||||
mapController: _controller.mapController,
|
||||
userLocation: _gpsController.currentLocation,
|
||||
selectedNodeId: widget.selectedNodeId,
|
||||
onNodeTap: widget.onNodeTap,
|
||||
shouldDim: shouldDimNodes,
|
||||
);
|
||||
|
||||
// Build suspected location markers (respect same zoom and count limits as nodes)
|
||||
final suspectedLocationMarkers = <Marker>[];
|
||||
if (appState.suspectedLocationsEnabled && mapBounds != null && currentZoom >= minZoom) {
|
||||
final suspectedLocations = appState.getSuspectedLocationsInBounds(
|
||||
north: mapBounds.north,
|
||||
south: mapBounds.south,
|
||||
east: mapBounds.east,
|
||||
west: mapBounds.west,
|
||||
);
|
||||
|
||||
// Apply same node count limit as surveillance nodes
|
||||
final maxNodes = appState.maxCameras;
|
||||
final limitedSuspectedLocations = suspectedLocations.take(maxNodes).toList();
|
||||
|
||||
// Filter out suspected locations that are too close to real nodes
|
||||
final filteredSuspectedLocations = _filterSuspectedLocationsByProximity(
|
||||
suspectedLocations: limitedSuspectedLocations,
|
||||
realNodes: nodes,
|
||||
minDistance: appState.suspectedLocationMinDistance,
|
||||
);
|
||||
|
||||
suspectedLocationMarkers.addAll(
|
||||
SuspectedLocationMarkersBuilder.buildSuspectedLocationMarkers(
|
||||
locations: filteredSuspectedLocations,
|
||||
mapController: _controller.mapController,
|
||||
selectedLocationId: appState.selectedSuspectedLocation?.ticketNo,
|
||||
onLocationTap: widget.onSuspectedLocationTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Get current zoom level for direction cones (already have currentZoom)
|
||||
try {
|
||||
currentZoom = _controller.mapController.camera.zoom;
|
||||
} catch (_) {
|
||||
// Controller not ready yet, use fallback
|
||||
}
|
||||
|
||||
final overlays = DirectionConesBuilder.buildDirectionCones(
|
||||
cameras: nodes,
|
||||
zoom: currentZoom,
|
||||
// Build all marker layers
|
||||
final markerLayer = MarkerLayerBuilder.buildMarkerLayers(
|
||||
nodesToRender: nodeData.nodesToRender,
|
||||
mapController: _controller,
|
||||
appState: appState,
|
||||
session: session,
|
||||
editSession: editSession,
|
||||
selectedNodeId: widget.selectedNodeId,
|
||||
userLocation: _gpsController.currentLocation,
|
||||
currentZoom: currentZoom,
|
||||
mapBounds: mapBounds,
|
||||
onNodeTap: widget.onNodeTap,
|
||||
onSuspectedLocationTap: widget.onSuspectedLocationTap,
|
||||
);
|
||||
|
||||
// Build all overlay layers
|
||||
final overlayLayers = OverlayLayerBuilder.buildOverlayLayers(
|
||||
nodesToRender: nodeData.nodesToRender,
|
||||
currentZoom: currentZoom,
|
||||
session: session,
|
||||
editSession: editSession,
|
||||
appState: appState,
|
||||
context: context,
|
||||
);
|
||||
|
||||
// Add suspected location bounds if one is selected
|
||||
if (appState.selectedSuspectedLocation != null) {
|
||||
final selectedLocation = appState.selectedSuspectedLocation!;
|
||||
if (selectedLocation.bounds.isNotEmpty) {
|
||||
overlays.add(
|
||||
Polygon(
|
||||
points: selectedLocation.bounds,
|
||||
color: Colors.orange.withOpacity(0.3),
|
||||
borderColor: Colors.orange,
|
||||
borderStrokeWidth: 2.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build edit lines connecting original nodes to their edited positions
|
||||
final editLines = _buildEditLines(nodes);
|
||||
|
||||
// Build center marker for add/edit sessions
|
||||
final centerMarkers = <Marker>[];
|
||||
if (session != null || editSession != null) {
|
||||
try {
|
||||
final center = _controller.mapController.camera.center;
|
||||
centerMarkers.add(
|
||||
Marker(
|
||||
point: center,
|
||||
width: kNodeIconDiameter,
|
||||
height: kNodeIconDiameter,
|
||||
child: CameraIcon(
|
||||
type: editSession != null ? CameraIconType.editing : CameraIconType.mock,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
// Controller not ready yet
|
||||
}
|
||||
}
|
||||
|
||||
// Build provisional pin for navigation/search mode
|
||||
if (appState.showProvisionalPin && appState.provisionalPinLocation != null) {
|
||||
centerMarkers.add(
|
||||
Marker(
|
||||
point: appState.provisionalPinLocation!,
|
||||
width: 32.0,
|
||||
height: 32.0,
|
||||
child: const ProvisionalPin(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Build start/end pins for route visualization
|
||||
if (appState.showingOverview || appState.isInRouteMode || appState.isSettingSecondPoint) {
|
||||
if (appState.routeStart != null) {
|
||||
centerMarkers.add(
|
||||
Marker(
|
||||
point: appState.routeStart!,
|
||||
width: 32.0,
|
||||
height: 32.0,
|
||||
child: const LocationPin(type: PinType.start),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (appState.routeEnd != null) {
|
||||
centerMarkers.add(
|
||||
Marker(
|
||||
point: appState.routeEnd!,
|
||||
width: 32.0,
|
||||
height: 32.0,
|
||||
child: const LocationPin(type: PinType.end),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build route path visualization
|
||||
final routeLines = <Polyline>[];
|
||||
if (appState.routePath != null && appState.routePath!.length > 1) {
|
||||
// Show route line during overview or active route
|
||||
if (appState.showingOverview || appState.isInRouteMode) {
|
||||
routeLines.add(Polyline(
|
||||
points: appState.routePath!,
|
||||
color: Colors.blue,
|
||||
strokeWidth: 4.0,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
PolygonLayer(polygons: overlays),
|
||||
if (editLines.isNotEmpty) PolylineLayer(polylines: editLines),
|
||||
if (routeLines.isNotEmpty) PolylineLayer(polylines: routeLines),
|
||||
MarkerLayer(markers: [...suspectedLocationMarkers, ...markers, ...centerMarkers]),
|
||||
...overlayLayers,
|
||||
markerLayer,
|
||||
|
||||
// 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: nodeData.isLimitActive,
|
||||
renderedCount: nodeData.nodesToRender.length,
|
||||
totalCount: nodeData.validNodesCount,
|
||||
top: 8.0 + searchBarOffset,
|
||||
left: 8.0,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return Stack(
|
||||
@@ -590,7 +404,7 @@ class MapViewState extends State<MapView> {
|
||||
initialZoom: _positionManager.initialZoom ?? 15,
|
||||
minZoom: 1.0,
|
||||
maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(),
|
||||
interactionOptions: _getInteractionOptions(editSession),
|
||||
interactionOptions: _interactionManager.getInteractionOptions(editSession),
|
||||
onPositionChanged: (pos, gesture) {
|
||||
setState(() {}); // Instant UI update for zoom, etc.
|
||||
if (gesture) {
|
||||
@@ -655,7 +469,7 @@ class MapViewState extends State<MapView> {
|
||||
final currentTileLevel = currentZoom.round();
|
||||
final lastTileLevel = _lastZoom?.round();
|
||||
final tileLevelChanged = lastTileLevel != null && currentTileLevel != lastTileLevel;
|
||||
final centerMoved = _mapMovedSignificantly(currentCenter, _lastCenter);
|
||||
final centerMoved = _interactionManager.mapMovedSignificantly(currentCenter, _lastCenter);
|
||||
|
||||
if (tileLevelChanged || centerMoved) {
|
||||
_tileDebounce(() {
|
||||
@@ -679,13 +493,13 @@ class MapViewState extends State<MapView> {
|
||||
});
|
||||
|
||||
// Request more nodes on any map movement/zoom at valid zoom level (slower debounce)
|
||||
final minZoom = _getMinZoomForNodes(context);
|
||||
final minZoom = _dataManager.getMinZoomForNodes(appState.uploadMode);
|
||||
if (pos.zoom >= minZoom) {
|
||||
_cameraDebounce(_refreshNodesFromProvider);
|
||||
} else {
|
||||
// Skip nodes at low zoom - no loading state needed
|
||||
// Show zoom warning if needed
|
||||
_showZoomWarningIfNeeded(context, pos.zoom, minZoom);
|
||||
_dataManager.showZoomWarningIfNeeded(context, pos.zoom, appState.uploadMode);
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -728,7 +542,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 = nodeData.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(
|
||||
@@ -742,72 +567,5 @@ class MapViewState extends State<MapView> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Build polylines connecting original cameras to their edited positions
|
||||
List<Polyline> _buildEditLines(List<OsmNode> nodes) {
|
||||
final lines = <Polyline>[];
|
||||
|
||||
// Create a lookup map of original node IDs to their coordinates
|
||||
final originalNodes = <int, LatLng>{};
|
||||
for (final node in nodes) {
|
||||
if (node.tags['_pending_edit'] == 'true') {
|
||||
originalNodes[node.id] = node.coord;
|
||||
}
|
||||
}
|
||||
|
||||
// Find edited nodes and draw lines to their originals
|
||||
for (final node in nodes) {
|
||||
final originalIdStr = node.tags['_original_node_id'];
|
||||
if (originalIdStr != null && node.tags['_pending_upload'] == 'true') {
|
||||
final originalId = int.tryParse(originalIdStr);
|
||||
final originalCoord = originalId != null ? originalNodes[originalId] : null;
|
||||
|
||||
if (originalCoord != null) {
|
||||
lines.add(Polyline(
|
||||
points: [originalCoord, node.coord],
|
||||
color: kNodeRingColorPending,
|
||||
strokeWidth: 3.0,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// Filter suspected locations that are too close to real nodes
|
||||
List<SuspectedLocation> _filterSuspectedLocationsByProximity({
|
||||
required List<SuspectedLocation> suspectedLocations,
|
||||
required List<OsmNode> realNodes,
|
||||
required int minDistance, // in meters
|
||||
}) {
|
||||
if (minDistance <= 0) return suspectedLocations;
|
||||
|
||||
const distance = Distance();
|
||||
final filteredLocations = <SuspectedLocation>[];
|
||||
|
||||
for (final suspected in suspectedLocations) {
|
||||
bool tooClose = false;
|
||||
|
||||
for (final realNode in realNodes) {
|
||||
final distanceMeters = distance.as(
|
||||
LengthUnit.Meter,
|
||||
suspected.centroid,
|
||||
realNode.coord,
|
||||
);
|
||||
|
||||
if (distanceMeters < minDistance) {
|
||||
tooClose = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!tooClose) {
|
||||
filteredLocations.add(suspected);
|
||||
}
|
||||
}
|
||||
|
||||
return filteredLocations;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,40 +93,44 @@ class NavigationSheet extends StatelessWidget {
|
||||
children: [
|
||||
_buildDragHandle(),
|
||||
|
||||
// SEARCH MODE: Initial location with route options
|
||||
if (navigationMode == AppNavigationMode.search && !appState.isSettingSecondPoint && !appState.isCalculating && !appState.showingOverview && provisionalLocation != null) ...[
|
||||
// SEARCH MODE: Initial location with route options (only when no route points are set yet)
|
||||
if (navigationMode == AppNavigationMode.search &&
|
||||
!appState.isSettingSecondPoint &&
|
||||
!appState.isCalculating &&
|
||||
!appState.showingOverview &&
|
||||
provisionalLocation != null &&
|
||||
appState.routeStart == null &&
|
||||
appState.routeEnd == null) ...[
|
||||
_buildLocationInfo(
|
||||
label: LocalizationService.instance.t('navigation.location'),
|
||||
coordinates: provisionalLocation,
|
||||
address: provisionalAddress,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Only show routing buttons if navigation features are enabled
|
||||
if (enableNavigationFeatures(offlineMode: appState.offlineMode)) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.directions),
|
||||
label: Text(LocalizationService.instance.t('navigation.routeTo')),
|
||||
onPressed: () {
|
||||
appState.startRoutePlanning(thisLocationIsStart: false);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.my_location),
|
||||
label: Text(LocalizationService.instance.t('navigation.routeFrom')),
|
||||
onPressed: () {
|
||||
appState.startRoutePlanning(thisLocationIsStart: true);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
// Show routing buttons (sheet only opens when online, so always available)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.directions),
|
||||
label: Text(LocalizationService.instance.t('navigation.routeTo')),
|
||||
onPressed: () {
|
||||
appState.startRoutePlanning(thisLocationIsStart: false);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.my_location),
|
||||
label: Text(LocalizationService.instance.t('navigation.routeFrom')),
|
||||
onPressed: () {
|
||||
appState.startRoutePlanning(thisLocationIsStart: true);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
// SETTING SECOND POINT: Show both points and select button
|
||||
@@ -159,13 +163,55 @@ class NavigationSheet extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.check),
|
||||
label: Text(LocalizationService.instance.t('navigation.selectLocation')),
|
||||
onPressed: () {
|
||||
debugPrint('[NavigationSheet] Select Location button pressed');
|
||||
appState.selectSecondRoutePoint();
|
||||
},
|
||||
// Show warning message if locations are too close
|
||||
if (appState.areRoutePointsTooClose) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning, color: Colors.orange[700], size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
LocalizationService.instance.t('navigation.locationsTooClose'),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.orange[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.check),
|
||||
label: Text(LocalizationService.instance.t('navigation.selectLocation')),
|
||||
onPressed: appState.areRoutePointsTooClose ? null : () {
|
||||
debugPrint('[NavigationSheet] Select Location button pressed');
|
||||
appState.selectSecondRoutePoint();
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.close),
|
||||
label: Text(LocalizationService.instance.t('actions.cancel')),
|
||||
onPressed: () => appState.cancelNavigation(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -306,4 +352,4 @@ class NavigationSheet extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
63
lib/widgets/node_limit_indicator.dart
Normal file
63
lib/widgets/node_limit_indicator.dart
Normal file
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,15 @@ import '../app_state.dart';
|
||||
|
||||
/// Provides surveillance nodes for a map view, using an in-memory cache and optionally
|
||||
/// merging in new results from Overpass via MapDataProvider when not offline.
|
||||
class CameraProviderWithCache extends ChangeNotifier {
|
||||
static final CameraProviderWithCache instance = CameraProviderWithCache._internal();
|
||||
factory CameraProviderWithCache() => instance;
|
||||
CameraProviderWithCache._internal();
|
||||
class NodeProviderWithCache extends ChangeNotifier {
|
||||
static final NodeProviderWithCache instance = NodeProviderWithCache._internal();
|
||||
factory NodeProviderWithCache() => instance;
|
||||
NodeProviderWithCache._internal();
|
||||
|
||||
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;
|
||||
@@ -61,18 +61,39 @@ class CameraProviderWithCache extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[CameraProviderWithCache] Node fetch failed: $e');
|
||||
debugPrint('[NodeProviderWithCache] Node fetch failed: $e');
|
||||
// Cache already holds whatever is available for the view
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Optionally: clear the cache (could be used for testing/dev)
|
||||
/// Clear the cache and repopulate with pending nodes from upload queue
|
||||
void clearCache() {
|
||||
NodeCache.instance.clear();
|
||||
// Repopulate with pending nodes from upload queue if available
|
||||
_repopulatePendingNodesAfterClear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Repopulate pending nodes after cache clear
|
||||
void _repopulatePendingNodesAfterClear() {
|
||||
// We need access to the upload queue state, but we don't have direct access here
|
||||
// Instead, we'll trigger a callback that the app state can handle
|
||||
// For now, let's use a more direct approach through a global service access
|
||||
// This could be refactored to use proper dependency injection later
|
||||
Future.microtask(() {
|
||||
// This will be called from app state when cache clears happen
|
||||
_onCacheCleared?.call();
|
||||
});
|
||||
}
|
||||
|
||||
VoidCallback? _onCacheCleared;
|
||||
|
||||
/// Set callback for when cache is cleared (used by app state to repopulate pending nodes)
|
||||
void setOnCacheClearedCallback(VoidCallback? callback) {
|
||||
_onCacheCleared = callback;
|
||||
}
|
||||
|
||||
/// Force refresh the display (useful when filters change but cache doesn't)
|
||||
void refreshDisplay() {
|
||||
notifyListeners();
|
||||
@@ -93,4 +114,4 @@ class CameraProviderWithCache extends ChangeNotifier {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -30,7 +36,25 @@ class NodeTagSheet extends StatelessWidget {
|
||||
(!node.tags.containsKey('_pending_deletion') ||
|
||||
node.tags['_pending_deletion'] != 'true');
|
||||
|
||||
// Check if this is a real OSM node (not pending) - for "View on OSM" button
|
||||
final isRealOSMNode = !node.tags.containsKey('_pending_upload') &&
|
||||
node.id > 0; // Real OSM nodes have positive IDs
|
||||
|
||||
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 {
|
||||
@@ -175,12 +199,14 @@ class NodeTagSheet extends StatelessWidget {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => _viewOnOSM(),
|
||||
icon: const Icon(Icons.open_in_new, size: 16),
|
||||
label: Text(locService.t('actions.viewOnOSM')),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (isRealOSMNode) ...[
|
||||
TextButton.icon(
|
||||
onPressed: () => _viewOnOSM(),
|
||||
icon: const Icon(Icons.open_in_new, size: 16),
|
||||
label: Text(locService.t('actions.viewOnOSM')),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (isEditable) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: _openAdvancedEdit,
|
||||
|
||||
84
lib/widgets/reauth_messages_dialog.dart
Normal file
84
lib/widgets/reauth_messages_dialog.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
/// Dialog to prompt user to re-authenticate for message notifications
|
||||
class ReauthMessagesDialog extends StatelessWidget {
|
||||
final VoidCallback onReauth;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
const ReauthMessagesDialog({
|
||||
super.key,
|
||||
required this.onReauth,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.message_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(locService.t('auth.reauthRequired')),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(locService.t('auth.reauthExplanation')),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('auth.reauthBenefit'),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onDismiss();
|
||||
},
|
||||
child: Text(locService.t('auth.reauthLater')),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onReauth();
|
||||
},
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: Text(locService.t('auth.reauthNow')),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.6.3+29 # 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+
|
||||
|
||||
104
test/services/deflock_tile_provider_test.dart
Normal file
104
test/services/deflock_tile_provider_test.dart
Normal file
@@ -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