Compare commits

..

42 Commits

Author SHA1 Message Date
stopflock
6707efebbe Fix color of "get more" link text in profile dropdown 2026-01-30 21:49:27 -06:00
stopflock
69ebd43e0d bump to 2.6 2026-01-30 21:37:12 -06:00
stopflock
79d2fe711d Monolithic reimplementation of node fetching from overpass/offline areas. Prevent submissions in areas without cache coverage. Also fixes offline node loading. 2026-01-30 21:34:55 -06:00
stopflock
4a36c52982 Node fetch rework 2026-01-30 19:11:00 -06:00
stopflock
f478a3eb2d Clean up FOV cone debug logging 2026-01-30 18:55:29 -06:00
stopflock
9621e5f35a "Get more" link in profile dropdown, suggest identify page when creating profile 2026-01-30 12:56:50 -06:00
stopflock
f048ebc7db Merge pull request #28 from heathdutton/issue-27-search-viewbox
Pass (rough) viewbox to search for location-biased results
2026-01-29 12:45:35 -06:00
Heath Dutton🕴️
33ae6473bb pass viewbox to nominatim search for location-biased results 2026-01-29 10:42:56 -05:00
stopflock
0957670a15 roadmap 2026-01-28 20:36:46 -06:00
stopflock
3fc3a72cde Fixes for 360-deg FOVs 2026-01-28 20:21:25 -06:00
stopflock
1d65d5ecca v2.4.1, adds profile import via deeplink, moves profile save button, fixes FOV clearing, disable direction slider while submitting with 360-fov profile 2026-01-28 18:13:49 -06:00
stopflock
1873d6e768 profile import from deeplinks 2026-01-28 15:20:25 -06:00
stopflock
4638a18887 roadmap 2026-01-28 15:20:25 -06:00
stopflock
6bfdfadd97 Merge pull request #29 from pbaehr/main
Update running instructions in DEVELOPER.md
2026-01-25 16:37:52 -06:00
Peter Baehr
72f3c9ee79 Update running instructions in DEVELOPER.md
Add script execution and client ID definition to run instructions
2026-01-21 16:29:03 -05:00
stopflock
05e2e4e7c6 roadmap 2026-01-14 12:23:28 -06:00
stopflock
2e679c9a7e roadmap 2026-01-13 15:06:55 -06:00
stopflock
3ef053126b fix changelog syntax issue - missing comma 2026-01-13 15:03:07 -06:00
stopflock
ae354c43a4 drop approx location support, restore follow me mode on sheet close 2025-12-24 15:29:32 -06:00
stopflock
34eac41a96 bump ver 2025-12-23 18:18:16 -06:00
stopflock
816dadfbd1 devibe changelog 2025-12-23 17:58:06 -06:00
stopflock
607ecbafaf Concurrent submissions 2025-12-23 17:56:16 -06:00
stopflock
8b44b3abf5 Better loading indicator 2025-12-23 16:17:06 -06:00
stopflock
a675cf185a roadmap 2025-12-23 13:47:05 -06:00
stopflock
26b479bf20 forgot to update roadmap 2025-12-23 12:04:30 -06:00
stopflock
ae795a7607 configurable overpass query timeout; increased to 45s 2025-12-23 12:03:53 -06:00
stopflock
a05e03567e shorten nav timeout to reasonable number 2025-12-23 11:39:25 -06:00
stopflock
da6887f7d3 update roadmap 2025-12-23 11:38:54 -06:00
stopflock
89fb0d9bbd Trying again to fix GPS. 2025-12-22 16:35:02 -06:00
stopflock
9db7c11a49 Fix approximate location after second GPS refactor 2025-12-17 18:13:39 -06:00
stopflock
c3752fd17e log in button, submission guide cancel, filter nsi by popularity and entered text 2025-12-17 17:36:59 -06:00
stopflock
aab4f6d445 Add new tags to top of list when editing profiles 2025-12-17 16:49:43 -06:00
stopflock
f8643da8e2 Refactor GPS again, fix losing GPS lock 2025-12-17 14:49:13 -06:00
stopflock
45a72ede30 roadmap 2025-12-17 12:47:50 -06:00
stopflock
0c324fc78f roadmap 2025-12-16 15:24:41 -06:00
stopflock
42b5707d0e roadmap / bugfixes 2025-12-16 14:50:43 -06:00
stopflock
a941a5a5f0 Get rid of vibe-y notes, update roadmap 2025-12-15 16:00:56 -06:00
stopflock
6363cabacf roadmap 2025-12-12 17:53:09 -06:00
stopflock
5312456a15 Better location / gps maybe 2025-12-12 16:26:50 -06:00
stopflock
8493679526 Nodes stay dimmed while one is selected 2025-12-11 20:30:14 -06:00
stopflock
2047645e89 clean up debug logging 2025-12-11 16:46:43 -06:00
stopflock
656dbc8ce8 positioning tutorial 2025-12-11 16:01:45 -06:00
58 changed files with 2928 additions and 2209 deletions

View File

@@ -202,15 +202,24 @@ Deletions don't need position dragging or tag editing - they just need confirmat
- Retries: Exponential backoff up to 59 minutes
- Failures: OSM auto-closes after 60 minutes, so we eventually give up
**Queue processing workflow:**
**Queue processing workflow (v2.3.0+ concurrent processing):**
1. User action (add/edit/delete) → `PendingUpload` created with `UploadState.pending`
2. Immediate visual feedback (cache updated with temp markers)
3. Background uploader processes queue when online:
3. Background uploader starts new uploads every 5 seconds (configurable via `kUploadQueueProcessingInterval`):
- **Concurrency limit**: Maximum 5 uploads processing simultaneously (`kMaxConcurrentUploads`)
- **Individual lifecycles**: Each upload processes through all three stages independently
- **Timer role**: Only used to start new pending uploads, not control stage progression
4. Each upload processes through stages without waiting for other uploads:
- **Pending** → Create changeset → **CreatingChangeset****Uploading**
- **Uploading** → Upload node → **ClosingChangeset**
- **ClosingChangeset** → Close changeset → **Complete**
4. Success → cache updated with real data, temp markers removed
5. Failures → appropriate retry logic based on which stage failed
5. Success → cache updated with real data, temp markers removed
6. Failures → appropriate retry logic based on which stage failed
**Performance improvement (v2.3.0):**
- **Before**: Sequential processing with 10-second delays between each stage of each upload
- **After**: Concurrent processing with uploads completing in 10-30 seconds regardless of queue size
- **User benefit**: 3-5x faster upload processing for users with good internet connections
**Why three explicit stages:**
The previous implementation conflated changeset creation + node operation as one step, making error handling unclear. The new approach:
@@ -242,6 +251,10 @@ Users expect instant response to their actions. By immediately updating the cach
- **Orange ring**: Node currently being edited
- **Red ring**: Nodes pending deletion
**Node dimming behavior:**
- **Dimmed (50% opacity)**: Non-selected nodes when a specific node is selected for tag viewing, or all nodes during search/navigation modes
- **Selection persistence**: When viewing a node's tag sheet, other nodes remain dimmed even when the map is moved, until the sheet is closed (v2.1.3+ fix)
**Direction cone visual states:**
- **Full opacity**: Active session direction (currently being edited)
- **Reduced opacity (40%)**: Inactive session directions
@@ -796,7 +809,8 @@ cd ios && pod install
### Running
```bash
flutter pub get
flutter run
./gen_icons_splashes.sh
flutter run --dart-define=OSM_PROD_CLIENT_ID=[your OAuth2 client ID]
```
### Testing

View File

@@ -53,6 +53,12 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
- **Queue management**: Review, edit, retry, or cancel pending uploads
- **Changeset tracking**: Automatic grouping and commenting for organized contributions
### Profile Import & Sharing
- **Deep link support**: Import custom profiles via `deflockapp://profiles/add?p=<base64>` URLs
- **Website integration**: Generate profile import links from [deflock.me](https://deflock.me)
- **Pre-filled editor**: Imported profiles open in the profile editor for review and modification
- **Seamless workflow**: Edit imported profiles like any custom profile before saving
### Offline Operations
- **Smart area downloads**: Automatically calculate tile counts and storage requirements
- **Device caching**: Offline areas include surveillance device data for complete functionality without network
@@ -98,27 +104,32 @@ cp lib/keys.dart.example lib/keys.dart
## Roadmap
### Needed Bugfixes
- Make submission guide scarier
- Node data fetching super slow; retries not working?
- Tile cache trimming? Does fluttermap handle?
- Filter NSI suggestions based on what has already been typed in
- NSI sometimes doesn't populate a dropdown, maybe always on the second tag added during an edit session?
- Clean cache when nodes have been deleted by others
- Are offline areas preferred for fast loading even when online? Check working.
### Current Development
- Optional reason message when deleting
- Option to import profiles from deflock identify page?
### On Pause
- Import/Export map providers, profiles
- Clean cache when nodes have been deleted by others
- Improve offline area node refresh live display
- Add ability to downvote suspected locations which are old enough
- Turn by turn navigation or at least swipe nav sheet up to see a list
- Import/Export map providers
### Future Features & Wishlist
- Update offline area nodes while browsing?
- Optional reason message when deleting
- Update offline area data while browsing?
- Save named locations to more easily navigate to home or work
- Offline navigation (pending vector map tiles)
- Android Auto / CarPlay
### Maybes
- "Universal Links" for better handling of profile import when app not installed?
- Yellow ring for devices missing specific tag details
- "Cache accumulating" offline area
- "Offline areas" as tile provider
- Grab the full latest database for each profile just like for suspected locations (instead of overpass)
- Android Auto / CarPlay
- "Cache accumulating" offline area?
- "Offline areas" as tile provider?
- Grab the full latest database for each profile just like for suspected locations (instead of overpass)?
- Optional custom icons for profiles to aid identification
- Custom device providers and OSM/Overpass alternatives
- Offer options for extracting nodes which are attached to a way/relation:

View File

@@ -1,243 +0,0 @@
# 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.

View File

@@ -1,125 +0,0 @@
# 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**.

View File

@@ -1,147 +0,0 @@
# 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.

View File

@@ -1,108 +0,0 @@
# v1.8.0 Changes Summary: Suspected Locations Database Migration
## Problem Solved
The CSV file containing suspected surveillance locations from alprwatch.org has grown beyond 100MB, causing significant performance issues:
- Long app startup times when the feature was enabled
- Memory pressure from loading entire CSV into memory
- Slow suspected location queries due to in-memory iteration
## Solution: SQLite Database Migration
### Brutalist Approach
Following the project's "brutalist code" philosophy, we chose SQLite as the simplest, most reliable solution:
- **Simple**: Well-understood, stable technology
- **Efficient**: Proper indexing for geographic queries
- **Cross-platform**: Works consistently on iOS and Android
- **No cleverness**: Straightforward database operations
### Key Changes
#### 1. New Database Service (`SuspectedLocationDatabase`)
- SQLite database with proper geographic indexing
- Batch insertion for handling large datasets
- Efficient bounds queries without loading full dataset
- Automatic database migration and cleanup
#### 2. Hybrid Caching System (`SuspectedLocationCache`)
- **Async caching**: Background database queries with proper notification
- **Sync caching**: Immediate response for UI with async fetch trigger
- **Smart memory management**: Limited cache sizes to prevent memory issues
- **Progressive loading**: UI shows empty initially, updates when data loads
#### 3. API Compatibility
- Maintained existing API surface for minimal UI changes
- Added sync versions of methods for immediate UI responsiveness
- Async methods for complete data fetching where appropriate
#### 4. Migration Support
- Automatic migration of existing SharedPreferences-based data
- Clean legacy data cleanup after successful migration
- Graceful fallback if migration fails
#### 5. Updated Dependencies
- Added `sqflite: ^2.4.1` for SQLite support
- Added explicit `path: ^1.8.3` dependency
### Database Schema
```sql
-- Main suspected locations table
CREATE TABLE suspected_locations (
ticket_no TEXT PRIMARY KEY, -- Unique identifier
centroid_lat REAL NOT NULL, -- Latitude for spatial queries
centroid_lng REAL NOT NULL, -- Longitude for spatial queries
bounds TEXT, -- JSON array of boundary points
geo_json TEXT, -- Original GeoJSON geometry
all_fields TEXT NOT NULL -- All other CSV fields as JSON
);
-- Spatial index for efficient bounds queries
CREATE INDEX idx_centroid ON suspected_locations (centroid_lat, centroid_lng);
-- Metadata table for tracking fetch times
CREATE TABLE metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
```
### Performance Improvements
#### Before (v1.7.0 and earlier):
- **Startup**: 5-15 seconds to load 100MB+ CSV into memory
- **Memory usage**: 200-400MB for suspected location data
- **Query time**: 100-500ms to iterate through all entries
- **Storage**: SharedPreferences JSON (slower serialization)
#### After (v1.8.0):
- **Startup**: <1 second (database already optimized)
- **Memory usage**: <10MB for suspected location data
- **Query time**: 10-50ms with indexed geographic queries
- **Storage**: SQLite with proper indexing
### UI Changes
- **Minimal**: Existing UI largely unchanged
- **Progressive loading**: Suspected locations appear as data becomes available
- **Settings**: Last fetch time now loads asynchronously (converted to StatefulWidget)
- **Error handling**: Better error recovery and user feedback
### Migration Process
1. **Startup detection**: Check for legacy SharedPreferences data
2. **Data conversion**: Parse legacy format into raw CSV data
3. **Database insertion**: Use new batch insertion process
4. **Cleanup**: Remove legacy data after successful migration
5. **Graceful failure**: Migration errors don't break the app
### Testing Notes
- **No data loss**: Existing users' suspected location data is preserved
- **Backward compatibility**: Users can safely downgrade if needed (will re-fetch data)
- **Fresh installs**: New users get optimal database storage from start
- **Legacy cleanup**: Old storage is automatically cleaned up after migration
### Code Quality
- **Error handling**: Comprehensive try-catch with meaningful debug output
- **Memory management**: Bounded cache sizes, efficient batch processing
- **Async safety**: Proper `mounted` checks and state management
- **Debug logging**: Detailed progress tracking for troubleshooting
This change follows the project's brutalist philosophy: solving the real problem (performance) with the simplest reliable solution (SQLite), avoiding clever optimizations in favor of well-understood, maintainable code.

View File

@@ -1,166 +0,0 @@
# v1.8.2 Sheet Positioning Fix
## Problem Identified
The node tags sheet and suspected location sheet were not properly adjusting the map positioning to keep the visual center in the middle of the viewable area above the sheet, unlike the working add/edit node sheets.
## Root Cause Analysis
Upon investigation, the infrastructure was already in place and should have been working:
1. Both sheets use `MeasuredSheet` wrapper to track height
2. Both sheets call `SheetCoordinator.updateTagSheetHeight()`
3. `SheetCoordinator.activeSheetHeight` includes tag sheet height as the lowest priority
4. `SheetAwareMap` receives this height and positions the map accordingly
However, a **race condition** was discovered in the sheet transition logic when moving from tag sheet to edit sheet:
### The Race Condition
1. User taps "Edit" in NodeTagSheet
2. `appState.startEditSession(node)` is called
3. Auto-show logic calls `_openEditNodeSheet()`
4. `_openEditNodeSheet()` calls `Navigator.of(context).pop()` to close the tag sheet
5. **The tag sheet's `.closed.then(...)` callback runs and calls `resetTagSheetHeight` because `_transitioningToEdit` is still false**
6. **Only THEN** does `_openEditNodeSheet()` call `_sheetCoordinator.openEditNodeSheet()` which sets `_transitioningToEdit = true`
This caused the map to bounce during edit sheet transitions, and potentially interfered with proper height coordination.
## Solution Implemented
### 1. Fixed Race Condition in Sheet Transitions
**File**: `lib/screens/home_screen.dart`
- Set `_transitioningToEdit = true` **BEFORE** closing the tag sheet
- This prevents the tag sheet's close callback from resetting the height prematurely
- Ensures smooth transitions without map bounce
```dart
void _openEditNodeSheet() {
// Set transition flag BEFORE closing tag sheet to prevent map bounce
_sheetCoordinator.setTransitioningToEdit(true);
// Close any existing tag sheet first...
```
### 2. Enhanced Debugging and Monitoring
**Files**:
- `lib/widgets/measured_sheet.dart` - Added optional debug labels and height change logging
- `lib/screens/coordinators/sheet_coordinator.dart` - Added debug logging for height updates and active height calculation
- `lib/screens/home_screen.dart` - Added debug labels to all MeasuredSheet instances
**Debug Labels Added**:
- `NodeTag` - For node tag sheets
- `SuspectedLocation` - For suspected location sheets
- `AddNode` - For add node sheets
- `EditNode` - For edit node sheets
- `Navigation` - For navigation sheets
### 3. Improved Fallback Robustness
**Files**:
- `lib/widgets/map/node_markers.dart`
- `lib/widgets/map/suspected_location_markers.dart`
Added warning messages to fallback behavior to help identify if callbacks are not being provided properly (though this should not happen under normal operation).
## Technical Details
### Sheet Height Priority Order
The `activeSheetHeight` calculation follows this priority:
1. Add sheet height (highest priority)
2. Edit sheet height
3. Navigation sheet height
4. Tag sheet height (lowest priority - used for both node tags and suspected locations)
This ensures that session-based sheets (add/edit) always take precedence over informational sheets (tag/suspected location).
### Debugging Output
When debugging is enabled, you'll see console output like:
```
[MeasuredSheet-NodeTag] Height changed: 0.0 -> 320.0
[SheetCoordinator] Updating tag sheet height: 0.0 -> 364.0
[SheetCoordinator] Active sheet height: 364.0 (add: 0.0, edit: 0.0, nav: 0.0, tag: 364.0)
```
This helps trace the height measurement and coordination flow.
### SheetAwareMap Behavior
The `SheetAwareMap` widget:
- Moves the map up by `sheetHeight` pixels (`top: -sheetHeight`)
- Extends the map rendering area by the same amount (`height: availableHeight + sheetHeight`)
- This keeps the visual center in the middle of the area above the sheet
- Uses smooth animation (300ms duration with `Curves.easeOut`)
## Files Modified
### Core Fix
- `lib/screens/home_screen.dart` - Fixed race condition in `_openEditNodeSheet()`
### Enhanced Debugging
- `lib/widgets/measured_sheet.dart` - Added debug labels and logging
- `lib/screens/coordinators/sheet_coordinator.dart` - Added debug logging for height coordination
- `lib/widgets/map/node_markers.dart` - Enhanced fallback robustness
- `lib/widgets/map/suspected_location_markers.dart` - Enhanced fallback robustness
### Version & Release
- `pubspec.yaml` - Updated version to 1.8.2+32
- `assets/changelog.json` - Added v1.8.2 changelog entry
## Expected Behavior After Fix
### Node Tag Sheets
1. Tap a surveillance device marker
2. Tag sheet opens with smooth animation
3. **Map shifts up so the device marker appears in the center of the visible area above the sheet**
4. Tap "Edit" button
5. Transition to edit sheet is smooth without map bounce
6. Map remains properly positioned during edit session
### Suspected Location Sheets
1. Tap a suspected location marker (yellow diamond)
2. Sheet opens with smooth animation
3. **Map shifts up so the suspected location appears in the center of the visible area above the sheet**
4. Tap "Close"
5. Map returns to original position with smooth animation
### Consistency
Both tag sheets now behave identically to the add/edit node sheets in terms of map positioning.
## Testing Recommendations
### Basic Functionality
1. **Node tag sheets**: Tap various surveillance device markers and verify map positioning
2. **Suspected location sheets**: Tap suspected location markers and verify map positioning
3. **Sheet transitions**: Open tag sheet → tap Edit → verify smooth transition without bounce
4. **Different devices**: Test on both phones and tablets in portrait/landscape
5. **Different sheet heights**: Test with nodes having many tags vs few tags
### Edge Cases
1. **Quick transitions**: Rapidly tap Edit button to test race condition fix
2. **Orientation changes**: Rotate device while sheets are open
3. **Background/foreground**: Send app to background and return
4. **Memory pressure**: Test with multiple apps running
### Debug Console Monitoring
Monitor console output for:
- Height measurement logging from `MeasuredSheet-*` components
- Height coordination logging from `SheetCoordinator`
- Any warning messages from fallback behavior (should not appear)
## Brutalist Code Principles Applied
### 1. Simple, Explicit Solution
- Fixed the race condition with one clear line: set the flag before the operation that depends on it
- No complex state machine or coordination logic
### 2. Enhanced Debugging Without Complexity
- Added simple debug labels and logging
- Minimal overhead, easy to enable/disable
- Helps troubleshoot without changing behavior
### 3. Robust Fallbacks
- Enhanced existing fallback behavior with warning messages
- Maintains functionality even if something goes wrong
- Clear indication in logs if fallback is used
### 4. Consistent Pattern Application
- All MeasuredSheet instances now have debug labels
- All sheet types follow the same coordination pattern
- Uniform debugging approach across components
This fix maintains the project's brutalist philosophy by solving the core problem simply and directly while adding appropriate safeguards and debugging capabilities.

View File

@@ -1,69 +0,0 @@
# V1.8.3 Node Limit Indicator Fix
## Problem
The node limit indicator would disappear when the navigation sheet opened during search/routing, particularly noticeable on Android. The indicator would appear correctly when just the search bar showed, but disappear when the navigation sheet auto-opened.
## Root Cause
The issue was in the **map positioning architecture**, specifically with `SheetAwareMap`. Here's what happens:
1. **Search activated**: Search bar appears → node limit indicator shifts down 60px (works correctly)
2. **Navigation sheet opens**: Navigation sheet auto-opens → `sheetHeight` changes from 0 to ~300px
3. **Map repositioning**: `SheetAwareMap` uses `AnimatedPositioned` with `top: -sheetHeight` to move the entire map up
4. **Indicator disappears**: The node limit indicator, positioned at `top: 8.0 + searchBarOffset`, gets moved up by 300px along with the map, placing it off-screen
The indicators were positioned relative to the map's coordinate system, but when the sheet opened, the entire map (including indicators) was moved up by the sheet height to keep the center visible above the sheet.
## Solution
**Brutalist fix**: Move the node limit indicator out of the map coordinate system and into screen coordinates alongside other UI overlays.
### Files Changed
- **map_view.dart**: Moved node limit indicator from inside SheetAwareMap to main Stack
- **pubspec.yaml**: Version bump to 1.8.3+33
- **changelog.json**: Added release notes
### Architecture Changes
```dart
// BEFORE - mixed coordinate systems (confusing!)
return Stack([
SheetAwareMap( // Map coordinates
child: FlutterMap([
cameraLayers: Stack([
NodeLimitIndicator(...) // ❌ Map coordinates (moves with map)
])
])
),
NetworkStatusIndicator(...), // ✅ Screen coordinates (fixed to screen)
]);
// AFTER - consistent coordinate system (clean!)
return Stack([
SheetAwareMap( // Map coordinates
child: FlutterMap([
cameraLayers: Stack([
// Only map data (nodes, overlays) - no UI indicators
])
])
),
NodeLimitIndicator(...), // ✅ Screen coordinates (fixed to screen)
NetworkStatusIndicator(...), // ✅ Screen coordinates (fixed to screen)
]);
```
## Architecture Insight
The fix revealed a **mixed coordinate system anti-pattern**. All UI overlays (compass, search box, zoom buttons, indicators) should use screen coordinates for consistency. Only map data (nodes, overlays, FOV cones) should be in map coordinates.
## Result
- Node limit indicator stays visible when navigation sheets open
- Network status indicator also fixed for consistency
- Indicators maintain correct screen position during all sheet transitions
- Consistent behavior across iOS and Android
## Testing Notes
To test this fix:
1. Start app and wait for nodes to load (node limit indicator should appear if >max nodes)
2. Tap search button → search bar appears, indicator shifts down 60px
3. Navigation sheet auto-opens → indicator stays visible in screen position (no longer affected by map movement)
4. Cancel search → indicator returns to original position
5. Repeat workflow → should work reliably every time
The fix ensures indicators stay in their intended screen positions using consistent coordinate system architecture.

View File

@@ -1,46 +0,0 @@
# Overpass Query Optimization - v2.1.1
## Problem
The app was generating one Overpass query clause for each enabled profile, resulting in unnecessarily complex queries. With the default 11 built-in profiles, this created queries with 11 separate node clauses, even though many profiles were redundant (e.g., manufacturer-specific ALPR profiles that are just generic ALPR + manufacturer tags).
## Solution: Profile Subsumption Deduplication
Implemented intelligent query deduplication that removes redundant profiles from Overpass queries based on tag subsumption:
- **Subsumption Rule**: Profile A subsumes Profile B if all of A's non-empty tags exist in B with identical values
- **Example**: `Generic ALPR` subsumes `Flock`, `Motorola`, etc. (same base tags + manufacturer-specific additions)
- **Query Reduction**: Default profile set reduces from 11 to 2 clauses (Generic ALPR + Generic Gunshot)
## Implementation Details
**Location**: `lib/services/map_data_submodules/nodes_from_overpass.dart`
**New Functions**:
- `_deduplicateProfilesForQuery()` - Removes subsumed profiles from query generation
- `_profileSubsumes()` - Determines if one profile subsumes another
**Integration**: Modified `_buildOverpassQuery()` to deduplicate profiles before generating node clauses
## Key Benefits
**~80% query complexity reduction** for default profile setup
**Zero UI changes** - all profiles still used for post-query filtering
**Backwards compatible** - works with any profile combination
**Custom profile safe** - generic algorithm handles user-created profiles
**Same results** - broader profiles capture all nodes that specific ones would
## Performance Impact
- **Query clauses**: 11 → 2 (for default profiles)
- **Overpass load**: Significantly reduced query parsing/execution time
- **Network efficiency**: Smaller query payloads
- **User experience**: Faster data loading, especially in dense areas
## Architecture Preservation
This optimization maintains the app's "brutalist code" philosophy:
- **Simple algorithm**: Clear subsumption logic without special cases
- **Generic approach**: Works for any profile combination, not just built-ins
- **Explicit behavior**: Profiles are still used everywhere else unchanged
- **Clean separation**: Query optimization separate from UI/filtering logic
The change is purely a query efficiency optimization - all existing profile matching, UI display, and user functionality remains identical.

View File

@@ -35,6 +35,14 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Profile import deep links -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="deflockapp" android:host="profiles"/>
</intent-filter>
</activity>
<!-- flutter_web_auth_2 callback activity (V2 embedding) -->

View File

@@ -1,4 +1,85 @@
{
"2.6.0": {
"content": [
"• Fix slow node loading, offline node loading",
"• Prevent submissions when we have no data in that area"
]
},
"2.5.0": {
"content": [
"• NEW: 'Get more...' button in profile dropdowns - easily browse and import profiles from deflock.me/identify",
"• NEW: Profile creation choice dialog - when adding profiles in settings, choose between creating custom profiles or importing from website",
"• Enhanced profile discovery workflow - clearer path for users to find and import community-created profiles"
]
},
"2.4.4": {
"content": [
"• Search results now prioritize locations near your current map view"
]
},
"2.4.3": {
"content": [
"• Fixed 360° FOV rendering - devices with full circle coverage now render as complete rings instead of having a wedge cut out or being a line",
"• Fixed 360° FOV submission - now correctly submits '0-360' to OpenStreetMap instead of incorrect '180-180' values, disables direction slider"
]
},
"2.4.1": {
"content": [
"• Save button moved to top-right corner of profile editor screens",
"• Fixed issue where FOV values could not be removed from profiles",
"• Direction slider is now disabled for profiles with 360° FOV"
]
},
"2.4.0": {
"content": [
"• Profile import from website links",
"• Visit deflock.me for profile links to auto-populate custom profiles"
]
},
"2.3.1": {
"content": [
"• Follow-me mode now automatically restores when add/edit/tag sheets are closed",
"• Follow-me button is greyed out while node sheets are open (add/edit/tag) since following doesn't make sense during node operations",
"• Drop support for approximate location since I can't get it to work reliably; apologies"
]
},
"2.3.0": {
"content": [
"• Concurrent upload queue processing",
"• Each submission is now much faster"
]
},
"2.2.1": {
"content": [
"• Fixed network status indicator timing out prematurely",
"• Improved GPS follow-me reliability - fixed sync issues that could cause tracking to stop working",
"• Network status now accurately shows 'taking a while' when requests split or backoff, and only shows 'timed out' for actual network failures"
]
},
"2.2.0": {
"content": [
"• Fixed follow-me sync issues where tracking would sometimes stop working after mode changes",
"• Added cancel button to submission guide dialog - users can now go back and revise their submissions",
"• When not logged in, submit/edit buttons now say 'Log In' and navigate to account settings instead of being disabled",
"• Improved NSI tag suggestions: now only shows values with sufficient usage (100+ occurrences) to avoid rare/unhelpful suggestions like for 'image=' tags",
"• Enhanced tag refinement: refine tags sheet now allows arbitrary text entry like the profile editor, not just dropdown selection",
"• New tags are now added to the top of the profile tag list for immediate visibility instead of being hidden at the bottom"
]
},
"2.1.3": {
"content": [
"• Fixed nodes losing their greyed-out appearance when map is moved while viewing a node's tag sheet",
"• Improved GPS location handling - follow-me button is now greyed out when location is unavailable",
"• Added approximate location fallback - if precise location is denied, app will use approximate location",
"• Higher frequency GPS updates when follow-me modes are active for smoother tracking (1-meter updates vs 5-meter)"
]
},
"2.1.2": {
"content": [
"• New positioning tutorial - first-time users must drag the map to refine location when creating or editing nodes, helping ensure accurate positioning",
"• Tutorial automatically dismisses after moving the map at least 1 meter and never shows again"
]
},
"2.1.0": {
"content": [
"• Profile tag refinement system - any profile tag with an empty value now shows a dropdown in refine tags",
@@ -235,4 +316,4 @@
"• New suspected locations feature"
]
}
}
}

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -14,10 +15,12 @@ import 'models/suspected_location.dart';
import 'models/tile_provider.dart';
import 'models/search_result.dart';
import 'services/offline_area_service.dart';
import 'services/node_cache.dart';
import 'services/map_data_provider.dart';
import 'services/node_data_manager.dart';
import 'services/tile_preview_service.dart';
import 'services/changelog_service.dart';
import 'services/operator_profile_service.dart';
import 'services/deep_link_service.dart';
import 'widgets/node_provider_with_cache.dart';
import 'services/profile_service.dart';
import 'widgets/proximity_warning_dialog.dart';
@@ -56,6 +59,10 @@ class AppState extends ChangeNotifier {
late final UploadQueueState _uploadQueueState;
bool _isInitialized = false;
// Positioning tutorial state
LatLng? _tutorialStartPosition; // Track where the tutorial started
VoidCallback? _tutorialCompletionCallback; // Callback when tutorial is completed
Timer? _messageCheckTimer;
AppState() {
@@ -235,11 +242,19 @@ class AppState extends ChangeNotifier {
// Initialize OfflineAreaService to ensure offline areas are loaded
await OfflineAreaService().ensureInitialized();
// Preload offline nodes into cache for immediate display
await NodeDataManager().preloadOfflineNodes();
// Start uploader if conditions are met
_startUploader();
_isInitialized = true;
// Check for initial deep link after a small delay to let navigation settle
Future.delayed(const Duration(milliseconds: 500), () {
DeepLinkService().checkInitialLink();
});
// Start periodic message checking
_startMessageCheckTimer();
@@ -437,6 +452,11 @@ class AppState extends ChangeNotifier {
target: target,
refinedTags: refinedTags,
);
// Check tutorial completion if position changed
if (target != null) {
_checkTutorialCompletion(target);
}
}
void updateEditSession({
@@ -455,6 +475,11 @@ class AppState extends ChangeNotifier {
extractFromWay: extractFromWay,
refinedTags: refinedTags,
);
// Check tutorial completion if position changed
if (target != null) {
_checkTutorialCompletion(target);
}
}
// For map view to check for pending snap backs
@@ -462,6 +487,40 @@ class AppState extends ChangeNotifier {
return _sessionState.consumePendingSnapBack();
}
// Positioning tutorial methods
void registerTutorialCallback(VoidCallback onComplete) {
_tutorialCompletionCallback = onComplete;
// Record the starting position when tutorial begins
if (session?.target != null) {
_tutorialStartPosition = session!.target;
} else if (editSession?.target != null) {
_tutorialStartPosition = editSession!.target;
}
}
void clearTutorialCallback() {
_tutorialCompletionCallback = null;
_tutorialStartPosition = null;
}
void _checkTutorialCompletion(LatLng newPosition) {
if (_tutorialCompletionCallback == null || _tutorialStartPosition == null) return;
// Calculate distance moved
final distance = Distance();
final distanceMoved = distance.as(LengthUnit.Meter, _tutorialStartPosition!, newPosition);
if (distanceMoved >= kPositioningTutorialMinMovementMeters) {
// Tutorial completed! Mark as complete and notify callback immediately
final callback = _tutorialCompletionCallback;
clearTutorialCallback();
callback?.call();
// Mark as complete in background (don't await to avoid delays)
ChangelogService().markPositioningTutorialCompleted();
}
}
void addDirection() {
_sessionState.addDirection();
}
@@ -515,8 +574,8 @@ class AppState extends ChangeNotifier {
}
// ---------- Navigation Methods - Simplified ----------
void enterSearchMode(LatLng mapCenter) {
_navigationState.enterSearchMode(mapCenter);
void enterSearchMode(LatLng mapCenter, {LatLngBounds? viewbox}) {
_navigationState.enterSearchMode(mapCenter, viewbox: viewbox);
}
void cancelNavigation() {
@@ -603,7 +662,7 @@ class AppState extends ChangeNotifier {
Future<void> setUploadMode(UploadMode mode) async {
// Clear node cache when switching upload modes to prevent mixing production/sandbox data
NodeCache.instance.clear();
MapDataProvider().clearCache();
debugPrint('[AppState] Cleared node cache due to upload mode change');
await _settingsState.setUploadMode(mode);

View File

@@ -55,13 +55,18 @@ const String kClientName = 'DeFlock';
// Upload and changeset configuration
const Duration kUploadHttpTimeout = Duration(seconds: 30); // HTTP request timeout for uploads
const Duration kUploadQueueProcessingInterval = Duration(seconds: 5); // How often to check for new uploads to start
const int kMaxConcurrentUploads = 5; // Maximum number of uploads processing simultaneously
const Duration kChangesetCloseInitialRetryDelay = Duration(seconds: 10);
const Duration kChangesetCloseMaxRetryDelay = Duration(minutes: 5); // Cap at 5 minutes
const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close
const double kChangesetCloseBackoffMultiplier = 2.0;
// Navigation routing configuration
const Duration kNavigationRoutingTimeout = Duration(seconds: 120); // HTTP timeout for routing requests
const Duration kNavigationRoutingTimeout = Duration(seconds: 90); // HTTP timeout for routing requests
// Overpass API configuration
const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded)
// Suspected locations CSV URL
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-latest.csv';
@@ -126,6 +131,10 @@ const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown betw
// Node proximity warning configuration (for new/edited nodes that are too close to existing ones)
const double kNodeProximityWarningDistance = 15.0; // meters - distance threshold to show warning
// Positioning tutorial configuration
const double kPositioningTutorialBlurSigma = 3.0; // Blur strength for sheet overlay
const double kPositioningTutorialMinMovementMeters = 1.0; // Minimum map movement to complete tutorial
// Navigation route planning configuration
const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance between start and end points
const double kNavigationDistanceWarningThreshold = 20000.0; // meters - distance threshold for timeout warning (30km)
@@ -133,6 +142,9 @@ const double kNavigationDistanceWarningThreshold = 20000.0; // meters - distance
// Node display configuration
const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render on the map at once
// NSI (Name Suggestion Index) configuration
const int kNSIMinimumHitCount = 500; // Minimum hit count for NSI suggestions to be considered useful
// 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)

View File

@@ -15,6 +15,7 @@
"ok": "OK",
"close": "Schließen",
"submit": "Senden",
"logIn": "Anmelden",
"saveEdit": "Bearbeitung Speichern",
"clear": "Löschen",
"viewOnOSM": "Auf OSM anzeigen",
@@ -102,6 +103,7 @@
"mustBeLoggedIn": "Sie müssen angemeldet sein, um neue Knoten zu übertragen. Bitte melden Sie sich über die Einstellungen an.",
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um neue Knoten zu übertragen.",
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um neue Knoten zu übertragen.",
"loadingAreaData": "Lade Bereichsdaten... Bitte warten Sie vor dem Übertragen.",
"refineTags": "Tags Verfeinern",
"refineTagsWithProfile": "Tags Verfeinern ({})"
},
@@ -117,6 +119,7 @@
"sandboxModeWarning": "Bearbeitungen von Produktionsknoten können nicht an die Sandbox übertragen werden. Wechseln Sie in den Produktionsmodus in den Einstellungen, um Knoten zu bearbeiten.",
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um Knoten zu bearbeiten.",
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um Knoten zu bearbeiten.",
"loadingAreaData": "Lade Bereichsdaten... Bitte warten Sie vor dem Übertragen.",
"cannotMoveConstrainedNode": "Kann diese Kamera nicht verschieben - sie ist mit einem anderen Kartenelement verbunden (OSM-Weg/Relation). Sie können trotzdem ihre Tags und Richtung bearbeiten.",
"zoomInRequiredMessage": "Zoomen Sie auf mindestens Stufe {} heran, um Überwachungsknoten hinzuzufügen oder zu bearbeiten. Dies gewährleistet eine präzise Positionierung für genaues Kartieren.",
"extractFromWay": "Knoten aus Weg/Relation extrahieren",
@@ -278,7 +281,14 @@
"view": "Anzeigen",
"deleteProfile": "Profil Löschen",
"deleteProfileConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
"profileDeleted": "Profil gelöscht"
"profileDeleted": "Profil gelöscht",
"getMore": "Weitere anzeigen...",
"addProfileChoice": "Profil Hinzufügen",
"addProfileChoiceMessage": "Wie möchten Sie ein Profil hinzufügen?",
"createCustomProfile": "Benutzerdefiniertes Profil Erstellen",
"createCustomProfileDescription": "Erstellen Sie ein Profil von Grund auf mit Ihren eigenen Tags",
"importFromWebsite": "Von Webseite Importieren",
"importFromWebsiteDescription": "Profile von deflock.me/identify durchsuchen und importieren"
},
"mapTiles": {
"title": "Karten-Kacheln",
@@ -446,6 +456,11 @@
"dontShowAgain": "Diese Anleitung nicht mehr anzeigen",
"gotIt": "Verstanden!"
},
"positioningTutorial": {
"title": "Position verfeinern",
"instructions": "Ziehen Sie die Karte, um die Geräte-Markierung präzise über dem Standort des Überwachungsgeräts zu positionieren.",
"hint": "Sie können für bessere Genauigkeit vor der Positionierung hineinzoomen."
},
"navigation": {
"searchLocation": "Ort suchen",
"searchPlaceholder": "Orte oder Koordinaten suchen...",

View File

@@ -37,6 +37,11 @@
"dontShowAgain": "Don't show this guide again",
"gotIt": "Got It!"
},
"positioningTutorial": {
"title": "Refine Your Location",
"instructions": "Drag the map to position the device marker precisely over the surveillance device's location.",
"hint": "You can zoom in for better accuracy before positioning."
},
"actions": {
"tagNode": "New Node",
"download": "Download",
@@ -47,6 +52,7 @@
"ok": "OK",
"close": "Close",
"submit": "Submit",
"logIn": "Log In",
"saveEdit": "Save Edit",
"clear": "Clear",
"viewOnOSM": "View on OSM",
@@ -134,6 +140,7 @@
"mustBeLoggedIn": "You must be logged in to submit new nodes. Please log in via Settings.",
"enableSubmittableProfile": "Enable a submittable profile in Settings to submit new nodes.",
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to submit new nodes.",
"loadingAreaData": "Loading area data... Please wait before submitting.",
"refineTags": "Refine Tags",
"refineTagsWithProfile": "Refine Tags ({})"
},
@@ -149,6 +156,7 @@
"sandboxModeWarning": "Cannot submit edits on production nodes to sandbox. Switch to Production mode in Settings to edit nodes.",
"enableSubmittableProfile": "Enable a submittable profile in Settings to edit nodes.",
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to edit nodes.",
"loadingAreaData": "Loading area data... Please wait before submitting.",
"cannotMoveConstrainedNode": "Cannot move this camera - it's connected to another map element (OSM way/relation). You can still edit its tags and direction.",
"zoomInRequiredMessage": "Zoom in to at least level {} to add or edit surveillance nodes. This ensures precise positioning for accurate mapping.",
"extractFromWay": "Extract node from way/relation",
@@ -310,7 +318,14 @@
"view": "View",
"deleteProfile": "Delete Profile",
"deleteProfileConfirm": "Are you sure you want to delete \"{}\"?",
"profileDeleted": "Profile deleted"
"profileDeleted": "Profile deleted",
"getMore": "Get more...",
"addProfileChoice": "Add Profile",
"addProfileChoiceMessage": "How would you like to add a profile?",
"createCustomProfile": "Create Custom Profile",
"createCustomProfileDescription": "Build a profile from scratch with your own tags",
"importFromWebsite": "Import from Website",
"importFromWebsiteDescription": "Browse and import profiles from deflock.me/identify"
},
"mapTiles": {
"title": "Map Tiles",

View File

@@ -37,6 +37,11 @@
"dontShowAgain": "No mostrar esta guía otra vez",
"gotIt": "¡Entendido!"
},
"positioningTutorial": {
"title": "Refinar Ubicación",
"instructions": "Arrastra el mapa para posicionar el marcador del dispositivo con precisión sobre la ubicación del dispositivo de vigilancia.",
"hint": "Puedes acercar el zoom para obtener mejor precisión antes de posicionar."
},
"actions": {
"tagNode": "Nuevo Nodo",
"download": "Descargar",
@@ -47,6 +52,7 @@
"ok": "Aceptar",
"close": "Cerrar",
"submit": "Enviar",
"logIn": "Iniciar Sesión",
"saveEdit": "Guardar Edición",
"clear": "Limpiar",
"viewOnOSM": "Ver en OSM",
@@ -134,6 +140,7 @@
"mustBeLoggedIn": "Debe estar conectado para enviar nuevos nodos. Por favor, inicie sesión a través de Configuración.",
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para enviar nuevos nodos.",
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para enviar nuevos nodos.",
"loadingAreaData": "Cargando datos del área... Por favor espere antes de enviar.",
"refineTags": "Refinar Etiquetas",
"refineTagsWithProfile": "Refinar Etiquetas ({})"
},
@@ -149,6 +156,7 @@
"sandboxModeWarning": "No se pueden enviar ediciones de nodos de producción al sandbox. Cambie al modo Producción en Configuración para editar nodos.",
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para editar nodos.",
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para editar nodos.",
"loadingAreaData": "Cargando datos del área... Por favor espere antes de enviar.",
"cannotMoveConstrainedNode": "No se puede mover esta cámara - está conectada a otro elemento del mapa (OSM way/relation). Aún puede editar sus etiquetas y dirección.",
"zoomInRequiredMessage": "Amplíe al menos al nivel {} para agregar o editar nodos de vigilancia. Esto garantiza un posicionamiento preciso para un mapeo exacto.",
"extractFromWay": "Extraer nodo de way/relation",
@@ -310,7 +318,14 @@
"view": "Ver",
"deleteProfile": "Eliminar Perfil",
"deleteProfileConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
"profileDeleted": "Perfil eliminado"
"profileDeleted": "Perfil eliminado",
"getMore": "Obtener más...",
"addProfileChoice": "Añadir Perfil",
"addProfileChoiceMessage": "¿Cómo desea añadir un perfil?",
"createCustomProfile": "Crear Perfil Personalizado",
"createCustomProfileDescription": "Crear un perfil desde cero con sus propias etiquetas",
"importFromWebsite": "Importar desde Sitio Web",
"importFromWebsiteDescription": "Explorar e importar perfiles desde deflock.me/identify"
},
"mapTiles": {
"title": "Tiles de Mapa",

View File

@@ -37,6 +37,11 @@
"dontShowAgain": "Ne plus afficher ce guide",
"gotIt": "Compris !"
},
"positioningTutorial": {
"title": "Affiner la Position",
"instructions": "Faites glisser la carte pour positionner le marqueur de l'appareil précisément au-dessus de l'emplacement du dispositif de surveillance.",
"hint": "Vous pouvez zoomer pour une meilleure précision avant de positionner."
},
"actions": {
"tagNode": "Nouveau Nœud",
"download": "Télécharger",
@@ -47,6 +52,7 @@
"ok": "OK",
"close": "Fermer",
"submit": "Soumettre",
"logIn": "Se Connecter",
"saveEdit": "Sauvegarder Modification",
"clear": "Effacer",
"viewOnOSM": "Voir sur OSM",
@@ -134,6 +140,7 @@
"mustBeLoggedIn": "Vous devez être connecté pour soumettre de nouveaux nœuds. Veuillez vous connecter via les Paramètres.",
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour soumettre de nouveaux nœuds.",
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour soumettre de nouveaux nœuds.",
"loadingAreaData": "Chargement des données de zone... Veuillez patienter avant de soumettre.",
"refineTags": "Affiner Balises",
"refineTagsWithProfile": "Affiner Balises ({})"
},
@@ -149,6 +156,7 @@
"sandboxModeWarning": "Impossible de soumettre des modifications de nœuds de production au sandbox. Passez au mode Production dans les Paramètres pour modifier les nœuds.",
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour modifier les nœuds.",
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour modifier les nœuds.",
"loadingAreaData": "Chargement des données de zone... Veuillez patienter avant de soumettre.",
"cannotMoveConstrainedNode": "Impossible de déplacer cette caméra - elle est connectée à un autre élément de carte (OSM way/relation). Vous pouvez toujours modifier ses balises et sa direction.",
"zoomInRequiredMessage": "Zoomez au moins au niveau {} pour ajouter ou modifier des nœuds de surveillance. Cela garantit un positionnement précis pour une cartographie exacte.",
"extractFromWay": "Extraire le nœud du way/relation",
@@ -310,7 +318,14 @@
"view": "Voir",
"deleteProfile": "Supprimer Profil",
"deleteProfileConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
"profileDeleted": "Profil supprimé"
"profileDeleted": "Profil supprimé",
"getMore": "En obtenir plus...",
"addProfileChoice": "Ajouter Profil",
"addProfileChoiceMessage": "Comment souhaitez-vous ajouter un profil?",
"createCustomProfile": "Créer Profil Personnalisé",
"createCustomProfileDescription": "Créer un profil à partir de zéro avec vos propres balises",
"importFromWebsite": "Importer depuis Site Web",
"importFromWebsiteDescription": "Parcourir et importer des profils depuis deflock.me/identify"
},
"mapTiles": {
"title": "Tuiles de Carte",

View File

@@ -37,6 +37,11 @@
"dontShowAgain": "Non mostrare più questa guida",
"gotIt": "Capito!"
},
"positioningTutorial": {
"title": "Affinare la Posizione",
"instructions": "Trascina la mappa per posizionare il marcatore del dispositivo precisamente sopra la posizione del dispositivo di sorveglianza.",
"hint": "Puoi ingrandire per una maggiore precisione prima di posizionare."
},
"actions": {
"tagNode": "Nuovo Nodo",
"download": "Scarica",
@@ -47,6 +52,7 @@
"ok": "OK",
"close": "Chiudi",
"submit": "Invia",
"logIn": "Accedi",
"saveEdit": "Salva Modifica",
"clear": "Pulisci",
"viewOnOSM": "Visualizza su OSM",
@@ -134,6 +140,7 @@
"mustBeLoggedIn": "Devi essere loggato per inviare nuovi nodi. Per favore accedi tramite Impostazioni.",
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per inviare nuovi nodi.",
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per inviare nuovi nodi.",
"loadingAreaData": "Caricamento dati area... Per favore attendi prima di inviare.",
"refineTags": "Affina Tag",
"refineTagsWithProfile": "Affina Tag ({})"
},
@@ -149,6 +156,7 @@
"sandboxModeWarning": "Impossibile inviare modifiche di nodi di produzione alla sandbox. Passa alla modalità Produzione nelle Impostazioni per modificare i nodi.",
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per modificare i nodi.",
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per modificare i nodi.",
"loadingAreaData": "Caricamento dati area... Per favore attendi prima di inviare.",
"cannotMoveConstrainedNode": "Impossibile spostare questa telecamera - è collegata a un altro elemento della mappa (OSM way/relation). Puoi ancora modificare i suoi tag e direzione.",
"zoomInRequiredMessage": "Ingrandisci almeno al livello {} per aggiungere o modificare nodi di sorveglianza. Questo garantisce un posizionamento preciso per una mappatura accurata.",
"extractFromWay": "Estrai nodo da way/relation",
@@ -310,7 +318,14 @@
"view": "Visualizza",
"deleteProfile": "Elimina Profilo",
"deleteProfileConfirm": "Sei sicuro di voler eliminare \"{}\"?",
"profileDeleted": "Profilo eliminato"
"profileDeleted": "Profilo eliminato",
"getMore": "Ottieni altri...",
"addProfileChoice": "Aggiungi Profilo",
"addProfileChoiceMessage": "Come desideri aggiungere un profilo?",
"createCustomProfile": "Crea Profilo Personalizzato",
"createCustomProfileDescription": "Crea un profilo da zero con i tuoi tag",
"importFromWebsite": "Importa da Sito Web",
"importFromWebsiteDescription": "Sfoglia e importa profili da deflock.me/identify"
},
"mapTiles": {
"title": "Tile Mappa",

View File

@@ -37,6 +37,11 @@
"dontShowAgain": "Não mostrar este guia novamente",
"gotIt": "Entendi!"
},
"positioningTutorial": {
"title": "Refinar Posição",
"instructions": "Arraste o mapa para posicionar o marcador do dispositivo precisamente sobre a localização do dispositivo de vigilância.",
"hint": "Você pode aumentar o zoom para melhor precisão antes de posicionar."
},
"actions": {
"tagNode": "Novo Nó",
"download": "Baixar",
@@ -47,6 +52,7 @@
"ok": "OK",
"close": "Fechar",
"submit": "Enviar",
"logIn": "Entrar",
"saveEdit": "Salvar Edição",
"clear": "Limpar",
"viewOnOSM": "Ver no OSM",
@@ -134,6 +140,7 @@
"mustBeLoggedIn": "Você deve estar logado para enviar novos nós. Por favor, faça login via Configurações.",
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para enviar novos nós.",
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para enviar novos nós.",
"loadingAreaData": "Carregando dados da área... Por favor aguarde antes de enviar.",
"refineTags": "Refinar Tags",
"refineTagsWithProfile": "Refinar Tags ({})"
},
@@ -149,6 +156,7 @@
"sandboxModeWarning": "Não é possível enviar edições de nós de produção para o sandbox. Mude para o modo Produção nas Configurações para editar nós.",
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para editar nós.",
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para editar nós.",
"loadingAreaData": "Carregando dados da área... Por favor aguarde antes de enviar.",
"cannotMoveConstrainedNode": "Não é possível mover esta câmera - ela está conectada a outro elemento do mapa (OSM way/relation). Você ainda pode editar suas tags e direção.",
"zoomInRequiredMessage": "Amplie para pelo menos o nível {} para adicionar ou editar nós de vigilância. Isto garante um posicionamento preciso para mapeamento exato.",
"extractFromWay": "Extrair nó do way/relation",
@@ -310,7 +318,14 @@
"view": "Ver",
"deleteProfile": "Excluir Perfil",
"deleteProfileConfirm": "Tem certeza de que deseja excluir \"{}\"?",
"profileDeleted": "Perfil excluído"
"profileDeleted": "Perfil excluído",
"getMore": "Obter mais...",
"addProfileChoice": "Adicionar Perfil",
"addProfileChoiceMessage": "Como gostaria de adicionar um perfil?",
"createCustomProfile": "Criar Perfil Personalizado",
"createCustomProfileDescription": "Construir um perfil do zero com suas próprias tags",
"importFromWebsite": "Importar do Site",
"importFromWebsiteDescription": "Navegar e importar perfis do deflock.me/identify"
},
"mapTiles": {
"title": "Tiles do Mapa",

View File

@@ -37,6 +37,11 @@
"dontShowAgain": "不再显示此指南",
"gotIt": "明白了!"
},
"positioningTutorial": {
"title": "精确定位",
"instructions": "拖动地图将设备标记精确定位在监控设备的位置上。",
"hint": "您可以在定位前放大地图以获得更高的精度。"
},
"actions": {
"tagNode": "新建节点",
"download": "下载",
@@ -47,6 +52,7 @@
"ok": "确定",
"close": "关闭",
"submit": "提交",
"logIn": "登录",
"saveEdit": "保存编辑",
"clear": "清空",
"viewOnOSM": "在OSM上查看",
@@ -134,6 +140,7 @@
"mustBeLoggedIn": "您必须登录才能提交新节点。请通过设置登录。",
"enableSubmittableProfile": "在设置中启用可提交的配置文件以提交新节点。",
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来提交新节点。",
"loadingAreaData": "正在加载区域数据...提交前请稍候。",
"refineTags": "细化标签",
"refineTagsWithProfile": "细化标签({}"
},
@@ -149,6 +156,7 @@
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
"loadingAreaData": "正在加载区域数据...提交前请稍候。",
"cannotMoveConstrainedNode": "无法移动此相机 - 它连接到另一个地图元素OSM way/relation。您仍可以编辑其标签和方向。",
"zoomInRequiredMessage": "请放大至至少第{}级来添加或编辑监控节点。这确保精确定位以便准确制图。",
"extractFromWay": "从way/relation中提取节点",
@@ -310,7 +318,14 @@
"view": "查看",
"deleteProfile": "删除配置文件",
"deleteProfileConfirm": "您确定要删除 \"{}\" 吗?",
"profileDeleted": "配置文件已删除"
"profileDeleted": "配置文件已删除",
"getMore": "获取更多...",
"addProfileChoice": "添加配置文件",
"addProfileChoiceMessage": "您希望如何添加配置文件?",
"createCustomProfile": "创建自定义配置文件",
"createCustomProfileDescription": "从头开始构建带有您自己标签的配置文件",
"importFromWebsite": "从网站导入",
"importFromWebsiteDescription": "浏览并从 deflock.me/identify 导入配置文件"
},
"mapTiles": {
"title": "地图瓦片",

View File

@@ -15,6 +15,7 @@ import 'screens/osm_account_screen.dart';
import 'screens/upload_queue_screen.dart';
import 'services/localization_service.dart';
import 'services/version_service.dart';
import 'services/deep_link_service.dart';
@@ -27,6 +28,10 @@ Future<void> main() async {
// Initialize localization service
await LocalizationService.instance.init();
// Initialize deep link service
await DeepLinkService().init();
DeepLinkService().setNavigatorKey(_navigatorKey);
runApp(
ChangeNotifierProvider(
create: (_) => AppState(),
@@ -68,6 +73,7 @@ class DeFlockApp extends StatelessWidget {
),
useMaterial3: true,
),
navigatorKey: _navigatorKey,
routes: {
'/': (context) => const HomeScreen(),
'/settings': (context) => const SettingsScreen(),
@@ -82,7 +88,11 @@ class DeFlockApp extends StatelessWidget {
'/settings/release-notes': (context) => const ReleaseNotesScreen(),
},
initialRoute: '/',
);
}
}
// Global navigator key for deep link navigation
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();

View File

@@ -1,5 +1,8 @@
import 'package:uuid/uuid.dart';
/// Sentinel value for copyWith methods to distinguish between null and not provided
const Object _notProvided = Object();
/// A bundle of preset OSM tags that describe a particular surveillance node model/type.
class NodeProfile {
final String id;
@@ -217,7 +220,7 @@ class NodeProfile {
bool? requiresDirection,
bool? submittable,
bool? editable,
double? fov,
Object? fov = _notProvided,
}) =>
NodeProfile(
id: id ?? this.id,
@@ -227,7 +230,7 @@ class NodeProfile {
requiresDirection: requiresDirection ?? this.requiresDirection,
submittable: submittable ?? this.submittable,
editable: editable ?? this.editable,
fov: fov ?? this.fov,
fov: fov == _notProvided ? this.fov : fov as double?,
);
Map<String, dynamic> toJson() => {

View File

@@ -107,6 +107,11 @@ class OsmNode {
start = ((start % 360) + 360) % 360;
end = ((end % 360) + 360) % 360;
// Special case: if start equals end, this represents 360° FOV
if (start == end) {
return DirectionFov(start, 360.0);
}
double width, center;
if (start > end) {

View File

@@ -138,7 +138,8 @@ class NavigationCoordinator {
// Enter search mode
try {
final center = mapController.mapController.camera.center;
appState.enterSearchMode(center);
final viewbox = mapController.mapController.camera.visibleBounds;
appState.enterSearchMode(center, viewbox: viewbox);
} catch (e) {
debugPrint('[NavigationCoordinator] Could not get map center for search: $e');
// Fallback to default location

View File

@@ -9,6 +9,7 @@ import '../../widgets/add_node_sheet.dart';
import '../../widgets/edit_node_sheet.dart';
import '../../widgets/navigation_sheet.dart';
import '../../widgets/measured_sheet.dart';
import '../../state/settings_state.dart' show FollowMeMode;
/// Coordinates all bottom sheet operations including opening, closing, height tracking,
/// and sheet-related validation logic.
@@ -25,6 +26,9 @@ class SheetCoordinator {
// Flag to prevent map bounce when transitioning from tag sheet to edit sheet
bool _transitioningToEdit = false;
// Follow-me state restoration
FollowMeMode? _followMeModeBeforeSheet;
// Getters for accessing heights
double get addSheetHeight => _addSheetHeight;
@@ -88,7 +92,8 @@ class SheetCoordinator {
return;
}
// Disable follow-me when adding a node so the map doesn't jump around
// Save current follow-me mode and disable it while sheet is open
_followMeModeBeforeSheet = appState.followMeMode;
appState.setFollowMeMode(FollowMeMode.off);
appState.startAddSession();
@@ -120,6 +125,9 @@ class SheetCoordinator {
debugPrint('[SheetCoordinator] AddNodeSheet dismissed - canceling session');
appState.cancelSession();
}
// Restore follow-me mode that was active before sheet opened
_restoreFollowMeMode(appState);
});
}
@@ -132,7 +140,8 @@ class SheetCoordinator {
}) {
final appState = context.read<AppState>();
// Disable follow-me when editing a node so the map doesn't jump around
// Save current follow-me mode and disable it while sheet is open
_followMeModeBeforeSheet = appState.followMeMode;
appState.setFollowMeMode(FollowMeMode.off);
final session = appState.editSession!; // should be non-null when this is called
@@ -185,6 +194,9 @@ class SheetCoordinator {
debugPrint('[SheetCoordinator] EditNodeSheet dismissed - canceling edit session');
appState.cancelEditSession();
}
// Restore follow-me mode that was active before sheet opened
_restoreFollowMeMode(appState);
});
}
@@ -250,4 +262,16 @@ class SheetCoordinator {
_tagSheetHeight = 0.0;
onStateChanged();
}
/// Restore the follow-me mode that was active before opening a node sheet
void _restoreFollowMeMode(AppState appState) {
if (_followMeModeBeforeSheet != null) {
debugPrint('[SheetCoordinator] Restoring follow-me mode: ${_followMeModeBeforeSheet}');
appState.setFollowMeMode(_followMeModeBeforeSheet!);
_followMeModeBeforeSheet = null; // Clear stored state
}
}
/// Check if any node editing/viewing sheet is currently open
bool get hasActiveNodeSheet => _addSheetHeight > 0 || _editSheetHeight > 0 || _tagSheetHeight > 0;
}

View File

@@ -433,16 +433,18 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
IconButton(
tooltip: _getFollowMeTooltip(appState.followMeMode),
icon: Icon(_getFollowMeIcon(appState.followMeMode)),
onPressed: () {
final oldMode = appState.followMeMode;
final newMode = _getNextFollowMeMode(oldMode);
debugPrint('[HomeScreen] Follow mode changed: $oldMode$newMode');
appState.setFollowMeMode(newMode);
// If enabling follow-me, retry location init in case permission was granted
if (newMode != FollowMeMode.off) {
_mapViewKey.currentState?.retryLocationInit();
}
},
onPressed: (_mapViewKey.currentState?.hasLocation == true && !_sheetCoordinator.hasActiveNodeSheet)
? () {
final oldMode = appState.followMeMode;
final newMode = _getNextFollowMeMode(oldMode);
debugPrint('[HomeScreen] Follow mode changed: $oldMode$newMode');
appState.setFollowMeMode(newMode);
// If enabling follow-me, retry location init in case permission was granted
if (newMode != FollowMeMode.off) {
_mapViewKey.currentState?.retryLocationInit();
}
}
: null, // Grey out when no location or when node sheet is open
),
AnimatedBuilder(
animation: LocalizationService.instance,
@@ -490,11 +492,24 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_isNodeLimitActive = isLimited;
});
},
onLocationStatusChanged: () {
// Re-render when location status changes (for follow-me button state)
setState(() {});
},
onUserGesture: () {
_mapInteractionHandler.handleUserGesture(
context: context,
onSelectedNodeChanged: (id) => setState(() => _selectedNodeId = id),
);
// Only clear selected node if tag sheet is not open
// This prevents nodes from losing their grey-out when map is moved while viewing tags
if (_sheetCoordinator.tagSheetHeight == 0) {
_mapInteractionHandler.handleUserGesture(
context: context,
onSelectedNodeChanged: (id) => setState(() => _selectedNodeId = id),
);
} else {
// Tag sheet is open - only handle suspected location clearing, not node selection
final appState = context.read<AppState>();
appState.clearSuspectedLocationSelection();
}
if (appState.followMeMode != FollowMeMode.off) {
appState.setFollowMeMode(FollowMeMode.off);
}

View File

@@ -55,6 +55,12 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
return Scaffold(
appBar: AppBar(
title: Text(widget.profile.name.isEmpty ? locService.t('operatorProfileEditor.newOperatorProfile') : locService.t('operatorProfileEditor.editOperatorProfile')),
actions: [
TextButton(
onPressed: _save,
child: Text(locService.t('profileEditor.saveProfile')),
),
],
),
body: ListView(
padding: EdgeInsets.fromLTRB(
@@ -87,10 +93,6 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
const SizedBox(height: 8),
..._buildTagRows(),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _save,
child: Text(locService.t('profileEditor.saveProfile')),
),
],
),
);

View File

@@ -69,6 +69,12 @@ class _ProfileEditorState extends State<ProfileEditor> {
title: Text(!widget.profile.editable
? locService.t('profileEditor.viewProfile')
: (widget.profile.name.isEmpty ? locService.t('profileEditor.newProfile') : locService.t('profileEditor.editProfile'))),
actions: widget.profile.editable ? [
TextButton(
onPressed: _save,
child: Text(locService.t('profileEditor.saveProfile')),
),
] : null,
),
body: ListView(
padding: EdgeInsets.fromLTRB(
@@ -126,7 +132,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
if (widget.profile.editable)
TextButton.icon(
onPressed: () => setState(() => _tags.add(const MapEntry('', ''))),
onPressed: () => setState(() => _tags.insert(0, const MapEntry('', ''))),
icon: const Icon(Icons.add),
label: Text(locService.t('profileEditor.addTag')),
),
@@ -135,11 +141,6 @@ class _ProfileEditorState extends State<ProfileEditor> {
const SizedBox(height: 8),
..._buildTagRows(),
const SizedBox(height: 24),
if (widget.profile.editable)
ElevatedButton(
onPressed: _save,
child: Text(locService.t('profileEditor.saveProfile')),
),
],
),
);

View File

@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../../../app_state.dart';
import '../../../models/node_profile.dart';
import '../../../services/localization_service.dart';
import '../../../widgets/profile_add_choice_dialog.dart';
import '../../profile_editor.dart';
class NodeProfilesSection extends StatelessWidget {
@@ -27,18 +28,7 @@ class NodeProfilesSection extends StatelessWidget {
style: Theme.of(context).textTheme.titleMedium,
),
TextButton.icon(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ProfileEditor(
profile: NodeProfile(
id: const Uuid().v4(),
name: '',
tags: const {},
),
),
),
),
onPressed: () => _showAddProfileDialog(context),
icon: const Icon(Icons.add),
label: Text(locService.t('profiles.newProfile')),
),
@@ -121,6 +111,34 @@ class NodeProfilesSection extends StatelessWidget {
);
}
void _showAddProfileDialog(BuildContext context) async {
final result = await showDialog<String?>(
context: context,
builder: (context) => const ProfileAddChoiceDialog(),
);
// If user chose to create custom profile, open the profile editor
if (result == 'create') {
_createNewProfile(context);
}
// If user chose import from website, ProfileAddChoiceDialog handles opening the URL
}
void _createNewProfile(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ProfileEditor(
profile: NodeProfile(
id: const Uuid().v4(),
name: '',
tags: const {},
),
),
),
);
}
void _showDeleteProfileDialog(BuildContext context, NodeProfile profile) {
final locService = LocalizationService.instance;
final appState = context.read<AppState>();

View File

@@ -16,6 +16,7 @@ class ChangelogService {
static const String _lastSeenVersionKey = 'last_seen_version';
static const String _hasSeenWelcomeKey = 'has_seen_welcome';
static const String _hasSeenSubmissionGuideKey = 'has_seen_submission_guide';
static const String _hasCompletedPositioningTutorialKey = 'has_completed_positioning_tutorial';
Map<String, dynamic>? _changelogData;
bool _initialized = false;
@@ -82,6 +83,18 @@ class ChangelogService {
await prefs.setBool(_hasSeenSubmissionGuideKey, true);
}
/// Check if user has completed the positioning tutorial
Future<bool> hasCompletedPositioningTutorial() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_hasCompletedPositioningTutorialKey) ?? false;
}
/// Mark that user has completed the positioning tutorial
Future<void> markPositioningTutorialCompleted() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_hasCompletedPositioningTutorialKey, true);
}
/// Check if app version has changed since last launch
Future<bool> hasVersionChanged() async {
final prefs = await SharedPreferences.getInstance();

View File

@@ -0,0 +1,157 @@
import 'dart:async';
import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import '../models/node_profile.dart';
import 'profile_import_service.dart';
import '../screens/profile_editor.dart';
class DeepLinkService {
static final DeepLinkService _instance = DeepLinkService._internal();
factory DeepLinkService() => _instance;
DeepLinkService._internal();
late AppLinks _appLinks;
StreamSubscription<Uri>? _linkSubscription;
/// Initialize deep link handling (sets up stream listener only)
Future<void> init() async {
_appLinks = AppLinks();
// Set up stream listener for links when app is already running
_linkSubscription = _appLinks.uriLinkStream.listen(
_processLink,
onError: (err) {
debugPrint('[DeepLinkService] Link stream error: $err');
},
);
}
/// Process a deep link
void _processLink(Uri uri) {
debugPrint('[DeepLinkService] Processing deep link: $uri');
// Only handle deflockapp scheme
if (uri.scheme != 'deflockapp') {
debugPrint('[DeepLinkService] Ignoring non-deflockapp scheme: ${uri.scheme}');
return;
}
// Route based on path
switch (uri.host) {
case 'profiles':
_handleProfilesLink(uri);
break;
case 'auth':
// OAuth links are handled by flutter_web_auth_2
debugPrint('[DeepLinkService] OAuth link handled by flutter_web_auth_2');
break;
default:
debugPrint('[DeepLinkService] Unknown deep link host: ${uri.host}');
}
}
/// Check for initial link after app is fully ready
Future<void> checkInitialLink() async {
debugPrint('[DeepLinkService] Checking for initial link...');
try {
final initialLink = await _appLinks.getInitialLink();
if (initialLink != null) {
debugPrint('[DeepLinkService] Found initial link: $initialLink');
_processLink(initialLink);
} else {
debugPrint('[DeepLinkService] No initial link found');
}
} catch (e) {
debugPrint('[DeepLinkService] Failed to get initial link: $e');
}
}
/// Handle profile-related deep links
void _handleProfilesLink(Uri uri) {
final segments = uri.pathSegments;
if (segments.isEmpty) {
debugPrint('[DeepLinkService] No path segments in profiles link');
return;
}
switch (segments[0]) {
case 'add':
_handleAddProfileLink(uri);
break;
default:
debugPrint('[DeepLinkService] Unknown profiles path: ${segments[0]}');
}
}
/// Handle profile add deep link: deflockapp://profiles/add?p=<base64>
void _handleAddProfileLink(Uri uri) {
final base64Data = uri.queryParameters['p'];
if (base64Data == null || base64Data.isEmpty) {
_showError('Invalid profile link: missing profile data');
return;
}
// Parse profile from base64
final profile = ProfileImportService.parseProfileFromBase64(base64Data);
if (profile == null) {
_showError('Invalid profile data');
return;
}
// Navigate to profile editor with the imported profile
_navigateToProfileEditor(profile);
}
/// Navigate to profile editor with pre-filled profile data
void _navigateToProfileEditor(NodeProfile profile) {
final context = _navigatorKey?.currentContext;
if (context == null) {
debugPrint('[DeepLinkService] No navigator context available');
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ProfileEditor(profile: profile),
),
);
}
/// Show error message to user
void _showError(String message) {
final context = _navigatorKey?.currentContext;
if (context == null) {
debugPrint('[DeepLinkService] Error (no context): $message');
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
}
/// Global navigator key for navigation
GlobalKey<NavigatorState>? _navigatorKey;
/// Set the global navigator key
void setNavigatorKey(GlobalKey<NavigatorState> navigatorKey) {
_navigatorKey = navigatorKey;
}
/// Clean up resources
void dispose() {
_linkSubscription?.cancel();
}
}

View File

@@ -5,13 +5,10 @@ import 'package:flutter/foundation.dart';
import '../models/node_profile.dart';
import '../models/osm_node.dart';
import '../app_state.dart';
import 'map_data_submodules/nodes_from_overpass.dart';
import 'map_data_submodules/nodes_from_osm_api.dart';
import 'map_data_submodules/tiles_from_remote.dart';
import 'map_data_submodules/nodes_from_local.dart';
import 'map_data_submodules/tiles_from_local.dart';
import 'network_status.dart';
import 'prefetch_area_service.dart';
import 'node_data_manager.dart';
import 'node_spatial_cache.dart';
enum MapSource { local, remote, auto } // For future use
@@ -27,103 +24,31 @@ class MapDataProvider {
factory MapDataProvider() => _instance;
MapDataProvider._();
// REMOVED: AppState get _appState => AppState();
final NodeDataManager _nodeDataManager = NodeDataManager();
bool get isOfflineMode => AppState.instance.offlineMode;
void setOfflineMode(bool enabled) {
AppState.instance.setOfflineMode(enabled);
}
/// Fetch surveillance nodes from OSM/Overpass or local storage.
/// Remote is default. If source is MapSource.auto, remote is tried first unless offline.
/// Fetch surveillance nodes using the new simplified system.
/// Returns cached data immediately if available, otherwise fetches from appropriate source.
Future<List<OsmNode>> getNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
MapSource source = MapSource.auto,
bool isUserInitiated = false,
}) async {
final offline = AppState.instance.offlineMode;
// Explicit remote request: error if offline, else always remote
if (source == MapSource.remote) {
if (offline) {
throw OfflineModeException("Cannot fetch remote nodes in offline mode.");
}
return _fetchRemoteNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: 0, // No limit - fetch all available data
);
}
// Explicit local request: always use local
if (source == MapSource.local) {
return fetchLocalNodes(
bounds: bounds,
profiles: profiles,
);
}
// AUTO: In offline mode, behavior depends on upload mode
if (offline) {
if (uploadMode == UploadMode.sandbox) {
// Offline + Sandbox = no nodes (local cache is production data)
debugPrint('[MapDataProvider] Offline + Sandbox mode: returning no nodes (local cache is production data)');
return <OsmNode>[];
} else {
// Offline + Production = use local cache
return fetchLocalNodes(
bounds: bounds,
profiles: profiles,
maxNodes: 0, // No limit - get all available data
);
}
} else if (uploadMode == UploadMode.sandbox) {
// Sandbox mode: Only fetch from sandbox API, ignore local production nodes
debugPrint('[MapDataProvider] Sandbox mode: fetching only from sandbox API, ignoring local cache');
return _fetchRemoteNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: 0, // No limit - fetch all available data
);
} else {
// Production mode: use pre-fetch service for efficient area loading
final preFetchService = PrefetchAreaService();
// Always get local nodes first (fast, from cache)
final localNodes = await fetchLocalNodes(
bounds: bounds,
profiles: profiles,
maxNodes: AppState.instance.maxNodes,
);
// Check if we need to trigger a new pre-fetch (spatial or temporal)
final needsFetch = !preFetchService.isWithinPreFetchedArea(bounds, profiles, uploadMode) ||
preFetchService.isDataStale();
if (needsFetch) {
// Outside area OR data stale - start pre-fetch with loading state
debugPrint('[MapDataProvider] Starting pre-fetch with loading state');
NetworkStatus.instance.setWaiting();
preFetchService.requestPreFetchIfNeeded(
viewBounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
);
} else {
debugPrint('[MapDataProvider] Using existing fresh pre-fetched area cache');
}
// Return all local nodes without any rendering limit
// Rendering limits are applied at the UI layer
return localNodes;
}
return _nodeDataManager.getNodesFor(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
isUserInitiated: isUserInitiated,
);
}
/// Bulk/paged node fetch for offline downloads (handling paging, dedup, and Overpass retries)
/// Only use for offline area download, not for map browsing! Ignores maxNodes config.
/// Bulk node fetch for offline downloads using new system
Future<List<OsmNode>> getAllNodesForDownload({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
@@ -131,16 +56,12 @@ class MapDataProvider {
int maxResults = 0, // 0 = no limit for offline downloads
int maxTries = 3,
}) async {
final offline = AppState.instance.offlineMode;
if (offline) {
if (AppState.instance.offlineMode) {
throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode.");
}
return _fetchRemoteNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults, // Pass 0 for unlimited
);
// For downloads, always fetch fresh data (don't use cache)
return _nodeDataManager.fetchWithSplitting(bounds, profiles);
}
/// Fetch tile image bytes. Default is to try local first, then remote if not offline. Honors explicit source.
@@ -202,57 +123,48 @@ class MapDataProvider {
clearRemoteTileQueueSelective(currentBounds);
}
/// Fetch remote nodes with Overpass first, OSM API fallback
Future<List<OsmNode>> _fetchRemoteNodes({
/// Add or update nodes in cache (for upload queue integration)
void addOrUpdateNodes(List<OsmNode> nodes) {
_nodeDataManager.addOrUpdateNodes(nodes);
}
/// NodeCache compatibility - alias for addOrUpdateNodes
void addOrUpdate(List<OsmNode> nodes) {
addOrUpdateNodes(nodes);
}
/// Remove node from cache (for deletions)
void removeNodeById(int nodeId) {
_nodeDataManager.removeNodeById(nodeId);
}
/// Clear cache (when profiles change)
void clearCache() {
_nodeDataManager.clearCache();
}
/// Force refresh current area (manual retry)
Future<void> refreshArea({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
required int maxResults,
}) async {
// For sandbox mode, skip Overpass and go directly to OSM API
// (Overpass doesn't have sandbox data)
if (uploadMode == UploadMode.sandbox) {
debugPrint('[MapDataProvider] Sandbox mode detected, using OSM API directly');
return fetchOsmApiNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
}
// For production mode, try Overpass first, then fallback to OSM API
try {
final nodes = await fetchOverpassNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
// If Overpass returns nodes, we're good
if (nodes.isNotEmpty) {
return nodes;
}
// If Overpass returns empty (could be no data or could be an issue),
// try OSM API as well to be thorough
debugPrint('[MapDataProvider] Overpass returned no nodes, trying OSM API fallback');
return fetchOsmApiNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
} catch (e) {
debugPrint('[MapDataProvider] Overpass failed ($e), trying OSM API fallback');
return fetchOsmApiNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
}
return _nodeDataManager.refreshArea(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
);
}
/// NodeCache compatibility methods for upload queue
/// These all delegate to the singleton cache to ensure consistency
OsmNode? getNodeById(int nodeId) => NodeSpatialCache().getNodeById(nodeId);
void removePendingEditMarker(int nodeId) => NodeSpatialCache().removePendingEditMarker(nodeId);
void removePendingDeletionMarker(int nodeId) => NodeSpatialCache().removePendingDeletionMarker(nodeId);
void removeTempNodeById(int tempNodeId) => NodeSpatialCache().removeTempNodeById(tempNodeId);
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) =>
NodeSpatialCache().findNodesWithinDistance(coord, distanceMeters, excludeNodeId: excludeNodeId);
/// Check if we have good cache coverage for the given area (prevents submission in uncovered areas)
bool hasGoodCoverageFor(LatLngBounds bounds) => NodeSpatialCache().hasDataFor(bounds);
}

View File

@@ -1,409 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../../models/node_profile.dart';
import '../../models/osm_node.dart';
import '../../models/pending_upload.dart';
import '../../app_state.dart';
import '../../dev_config.dart';
import '../network_status.dart';
import '../overpass_node_limit_exception.dart';
/// Fetches surveillance nodes from the Overpass OSM API for the given bounds and profiles.
/// If the query fails due to too many nodes, automatically splits the area and retries.
Future<List<OsmNode>> fetchOverpassNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
required int maxResults,
}) async {
// Check if this is a user-initiated fetch (indicated by loading state)
final wasUserInitiated = NetworkStatus.instance.currentStatus == NetworkStatusType.waiting;
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.
Future<List<OsmNode>> _fetchOverpassNodesWithSplitting({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
required int maxResults,
required int splitDepth,
required bool reportStatus, // Only true for top level
}) async {
if (profiles.isEmpty) return [];
const int maxSplitDepth = kMaxPreFetchSplitDepth; // Maximum times we'll split (4^3 = 64 max sub-areas)
try {
return await _fetchSingleOverpassQuery(
bounds: bounds,
profiles: profiles,
maxResults: maxResults,
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');
// Wait longer for rate limits before giving up entirely
await Future.delayed(const Duration(seconds: 30));
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');
return []; // Return empty - let caller handle error reporting
}
// Split the bounds into 4 quadrants and try each separately
debugPrint('[fetchOverpassNodes] Splitting area into quadrants (depth: $splitDepth)');
final quadrants = _splitBounds(bounds);
final List<OsmNode> allNodes = [];
for (final quadrant in quadrants) {
final nodes = await _fetchOverpassNodesWithSplitting(
bounds: quadrant,
profiles: profiles,
uploadMode: uploadMode,
maxResults: 0, // No limit on individual quadrants to avoid double-limiting
splitDepth: splitDepth + 1,
reportStatus: false, // Sub-requests don't report status
);
allNodes.addAll(nodes);
}
debugPrint('[fetchOverpassNodes] Collected ${allNodes.length} nodes from ${quadrants.length} quadrants');
return allNodes;
}
}
/// Perform a single Overpass query without splitting logic.
Future<List<OsmNode>> _fetchSingleOverpassQuery({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
required int maxResults,
required bool reportStatus,
}) async {
const String overpassEndpoint = 'https://overpass-api.de/api/interpreter';
// Build the Overpass query
final query = _buildOverpassQuery(bounds, profiles, maxResults);
try {
debugPrint('[fetchOverpassNodes] Querying Overpass for surveillance nodes...');
debugPrint('[fetchOverpassNodes] Query:\n$query');
final response = await http.post(
Uri.parse(overpassEndpoint),
body: {'data': query.trim()}
);
if (response.statusCode != 200) {
final errorBody = response.body;
debugPrint('[fetchOverpassNodes] Overpass API error: $errorBody');
// Check if it's specifically the 50k node limit error (HTTP 400)
// Exact message: "You requested too many nodes (limit is 50000)"
if (errorBody.contains('too many nodes') &&
errorBody.contains('50000')) {
debugPrint('[fetchOverpassNodes] Detected 50k node limit error, will attempt splitting');
throw OverpassNodeLimitException('Query exceeded node limit', serverResponse: errorBody);
}
// Check for timeout errors that indicate query complexity (should split)
// Common timeout messages from Overpass
if (errorBody.contains('timeout') ||
errorBody.contains('runtime limit exceeded') ||
errorBody.contains('Query timed out')) {
debugPrint('[fetchOverpassNodes] Detected timeout error, will attempt splitting to reduce complexity');
throw OverpassNodeLimitException('Query timed out', serverResponse: errorBody);
}
// Check for rate limiting (should NOT split - needs longer backoff)
if (errorBody.contains('rate limited') ||
errorBody.contains('too many requests') ||
response.statusCode == 429) {
debugPrint('[fetchOverpassNodes] Rate limited by Overpass API - needs extended backoff');
throw OverpassRateLimitException('Rate limited by server', serverResponse: errorBody);
}
// 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>;
final elements = data['elements'] as List<dynamic>;
if (elements.length > 20) {
debugPrint('[fetchOverpassNodes] Retrieved ${elements.length} elements (nodes + ways/relations)');
}
// Don't report success here - let the top level handle it
// Parse response to determine which nodes are constrained
final nodes = _parseOverpassResponseWithConstraints(elements);
// Clean up any pending uploads that now appear in Overpass results
_cleanupCompletedUploads(nodes);
return nodes;
} catch (e) {
// Re-throw OverpassNodeLimitException so splitting logic can catch it
if (e is OverpassNodeLimitException) rethrow;
debugPrint('[fetchOverpassNodes] Exception: $e');
// Don't report status here - let the top level handle it
throw e; // Re-throw to let caller handle
}
}
/// Builds an Overpass API query for surveillance nodes matching the given profiles within bounds.
/// Also fetches ways and relations that reference these nodes to determine constraint status.
String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int maxResults) {
// Deduplicate profiles to reduce query complexity - broader profiles subsume more specific ones
final deduplicatedProfiles = _deduplicateProfilesForQuery(profiles);
// Safety check: if deduplication removed all profiles (edge case), fall back to original list
final profilesToQuery = deduplicatedProfiles.isNotEmpty ? deduplicatedProfiles : profiles;
if (deduplicatedProfiles.length < profiles.length) {
debugPrint('[Overpass] Deduplicated ${profiles.length} profiles to ${deduplicatedProfiles.length} for query efficiency');
}
// Build node clauses for deduplicated profiles only
final nodeClauses = profilesToQuery.map((profile) {
// Convert profile tags to Overpass filter format, excluding empty values
final tagFilters = profile.tags.entries
.where((entry) => entry.value.trim().isNotEmpty) // Skip empty values
.map((entry) => '["${entry.key}"="${entry.value}"]')
.join();
// Build the node query with tag filters and bounding box
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
}).join('\n ');
return '''
[out:json][timeout:25];
(
$nodeClauses
);
out body ${maxResults > 0 ? maxResults : ''};
(
way(bn);
rel(bn);
);
out meta;
''';
}
/// Deduplicate profiles for Overpass queries by removing profiles that are subsumed by others.
/// A profile A subsumes profile B if all of A's non-empty tags exist in B with identical values.
/// This optimization reduces query complexity while returning the same nodes (since broader
/// profiles capture all nodes that more specific profiles would).
List<NodeProfile> _deduplicateProfilesForQuery(List<NodeProfile> profiles) {
if (profiles.length <= 1) return profiles;
final result = <NodeProfile>[];
for (final candidate in profiles) {
// Skip profiles that only have empty tags - they would match everything and break queries
final candidateNonEmptyTags = candidate.tags.entries
.where((entry) => entry.value.trim().isNotEmpty)
.toList();
if (candidateNonEmptyTags.isEmpty) continue;
// Check if any existing profile in our result subsumes this candidate
bool isSubsumed = false;
for (final existing in result) {
if (_profileSubsumes(existing, candidate)) {
isSubsumed = true;
break;
}
}
if (!isSubsumed) {
// This candidate is not subsumed, so add it
// But first, remove any existing profiles that this candidate subsumes
result.removeWhere((existing) => _profileSubsumes(candidate, existing));
result.add(candidate);
}
}
return result;
}
/// Check if broaderProfile subsumes specificProfile.
/// Returns true if all non-empty tags in broaderProfile exist in specificProfile with identical values.
bool _profileSubsumes(NodeProfile broaderProfile, NodeProfile specificProfile) {
// Get non-empty tags from both profiles
final broaderTags = Map.fromEntries(
broaderProfile.tags.entries.where((entry) => entry.value.trim().isNotEmpty)
);
final specificTags = Map.fromEntries(
specificProfile.tags.entries.where((entry) => entry.value.trim().isNotEmpty)
);
// If broader has no non-empty tags, it doesn't subsume anything (would match everything)
if (broaderTags.isEmpty) return false;
// If broader has more non-empty tags than specific, it can't subsume
if (broaderTags.length > specificTags.length) return false;
// Check if all broader tags exist in specific with same values
for (final entry in broaderTags.entries) {
if (specificTags[entry.key] != entry.value) return false;
}
return true;
}
/// Split a LatLngBounds into 4 quadrants (NW, NE, SW, SE).
List<LatLngBounds> _splitBounds(LatLngBounds bounds) {
final centerLat = (bounds.north + bounds.south) / 2;
final centerLng = (bounds.east + bounds.west) / 2;
return [
// Southwest quadrant (bottom-left)
LatLngBounds(
LatLng(bounds.south, bounds.west),
LatLng(centerLat, centerLng),
),
// Southeast quadrant (bottom-right)
LatLngBounds(
LatLng(bounds.south, centerLng),
LatLng(centerLat, bounds.east),
),
// Northwest quadrant (top-left)
LatLngBounds(
LatLng(centerLat, bounds.west),
LatLng(bounds.north, centerLng),
),
// Northeast quadrant (top-right)
LatLngBounds(
LatLng(centerLat, centerLng),
LatLng(bounds.north, bounds.east),
),
];
}
/// Parse Overpass response elements to create OsmNode objects with constraint information.
List<OsmNode> _parseOverpassResponseWithConstraints(List<dynamic> elements) {
final nodeElements = <Map<String, dynamic>>[];
final constrainedNodeIds = <int>{};
// First pass: collect surveillance nodes and identify constrained nodes
for (final element in elements.whereType<Map<String, dynamic>>()) {
final type = element['type'] as String?;
if (type == 'node') {
// This is a surveillance node - collect it
nodeElements.add(element);
} else if (type == 'way' || type == 'relation') {
// This is a way/relation that references some of our nodes
final refs = element['nodes'] as List<dynamic>? ??
element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? [];
// Mark all referenced nodes as constrained
for (final ref in refs) {
if (ref is int) {
constrainedNodeIds.add(ref);
} else if (ref is String) {
final nodeId = int.tryParse(ref);
if (nodeId != null) constrainedNodeIds.add(nodeId);
}
}
}
}
// Second pass: create OsmNode objects with constraint info
final nodes = nodeElements.map((element) {
final nodeId = element['id'] as int;
final isConstrained = constrainedNodeIds.contains(nodeId);
return OsmNode(
id: nodeId,
coord: LatLng(element['lat'], element['lon']),
tags: Map<String, String>.from(element['tags'] ?? {}),
isConstrained: isConstrained,
);
}).toList();
final constrainedCount = nodes.where((n) => n.isConstrained).length;
if (constrainedCount > 0) {
debugPrint('[fetchOverpassNodes] Found $constrainedCount constrained nodes out of ${nodes.length} total');
}
return nodes;
}
/// Clean up pending uploads that now appear in Overpass results
void _cleanupCompletedUploads(List<OsmNode> overpassNodes) {
try {
final appState = AppState.instance;
final pendingUploads = appState.pendingUploads;
if (pendingUploads.isEmpty) return;
final overpassNodeIds = overpassNodes.map((n) => n.id).toSet();
// Find pending uploads whose submitted node IDs now appear in Overpass results
final uploadsToRemove = <PendingUpload>[];
for (final upload in pendingUploads) {
if (upload.submittedNodeId != null &&
overpassNodeIds.contains(upload.submittedNodeId!)) {
uploadsToRemove.add(upload);
debugPrint('[OverpassCleanup] Found submitted node ${upload.submittedNodeId} in Overpass results, removing from pending queue');
}
}
// Remove the completed uploads from the queue
for (final upload in uploadsToRemove) {
appState.removeFromQueue(upload);
}
if (uploadsToRemove.isNotEmpty) {
debugPrint('[OverpassCleanup] Cleaned up ${uploadsToRemove.length} completed uploads');
}
} catch (e) {
debugPrint('[OverpassCleanup] Error during cleanup: $e');
// Don't let cleanup errors break the main functionality
}
}

View File

@@ -19,7 +19,6 @@ class NetworkStatus extends ChangeNotifier {
bool _hasSuccess = false;
int _recentOfflineMisses = 0;
Timer? _overpassRecoveryTimer;
Timer? _waitingTimer;
Timer? _noDataResetTimer;
Timer? _successResetTimer;
// Getters
@@ -72,7 +71,25 @@ class NetworkStatus extends ChangeNotifier {
}
}
/// Set waiting status (show when loading tiles/cameras)
/// Report that requests are taking longer than usual (splitting, backoffs, etc.)
void reportSlowProgress() {
if (!_overpassHasIssues) {
_overpassHasIssues = true;
_isWaitingForData = false; // Transition from waiting to slow progress
notifyListeners();
debugPrint('[NetworkStatus] Surveillance data requests taking longer than usual');
}
// Reset recovery timer - we'll clear this when the operation actually completes
_overpassRecoveryTimer?.cancel();
_overpassRecoveryTimer = Timer(const Duration(minutes: 2), () {
_overpassHasIssues = false;
notifyListeners();
debugPrint('[NetworkStatus] Slow progress status cleared');
});
}
/// Set waiting status (show when loading surveillance data)
void setWaiting() {
// Clear any previous timeout/no-data state when starting new wait
_isTimedOut = false;
@@ -83,17 +100,7 @@ class NetworkStatus extends ChangeNotifier {
if (!_isWaitingForData) {
_isWaitingForData = true;
notifyListeners();
// Don't log routine waiting - only log if we stay waiting too long
}
// Set timeout for genuine network issues (not 404s)
_waitingTimer?.cancel();
_waitingTimer = Timer(const Duration(seconds: 8), () {
_isWaitingForData = false;
_isTimedOut = true;
debugPrint('[NetworkStatus] Request timed out - likely network issues');
notifyListeners();
});
}
/// Show success status briefly when data loads
@@ -103,7 +110,6 @@ class NetworkStatus extends ChangeNotifier {
_hasNoData = false;
_hasSuccess = true;
_recentOfflineMisses = 0;
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
notifyListeners();
@@ -123,7 +129,6 @@ class NetworkStatus extends ChangeNotifier {
_isTimedOut = false;
_hasSuccess = false;
_hasNoData = true;
_waitingTimer?.cancel();
_successResetTimer?.cancel();
notifyListeners();
@@ -145,7 +150,6 @@ class NetworkStatus extends ChangeNotifier {
_hasNoData = false;
_hasSuccess = false;
_recentOfflineMisses = 0;
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
notifyListeners();
@@ -158,7 +162,6 @@ class NetworkStatus extends ChangeNotifier {
_isTimedOut = true;
_hasNoData = false;
_hasSuccess = false;
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
notifyListeners();
@@ -179,7 +182,6 @@ class NetworkStatus extends ChangeNotifier {
_isTimedOut = false;
_hasNoData = false;
_hasSuccess = false;
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
@@ -200,7 +202,6 @@ class NetworkStatus extends ChangeNotifier {
_isWaitingForData = false;
_isTimedOut = false;
_hasNoData = true;
_waitingTimer?.cancel();
notifyListeners();
debugPrint('[NetworkStatus] No offline data available for this area');
}
@@ -217,7 +218,6 @@ class NetworkStatus extends ChangeNotifier {
@override
void dispose() {
_overpassRecoveryTimer?.cancel();
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
super.dispose();

View File

@@ -0,0 +1,300 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../models/node_profile.dart';
import '../models/osm_node.dart';
import '../app_state.dart';
import 'overpass_service.dart';
import 'node_spatial_cache.dart';
import 'network_status.dart';
import 'map_data_submodules/nodes_from_osm_api.dart';
import 'map_data_submodules/nodes_from_local.dart';
import 'offline_area_service.dart';
import 'offline_areas/offline_area_models.dart';
/// Coordinates node data fetching between cache, Overpass, and OSM API.
/// Simple interface: give me nodes for this view with proper caching and error handling.
class NodeDataManager extends ChangeNotifier {
static final NodeDataManager _instance = NodeDataManager._();
factory NodeDataManager() => _instance;
NodeDataManager._();
final OverpassService _overpassService = OverpassService();
final NodeSpatialCache _cache = NodeSpatialCache();
/// Get nodes for the given bounds and profiles.
/// Returns cached data immediately if available, otherwise fetches from appropriate source.
Future<List<OsmNode>> getNodesFor({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
bool isUserInitiated = false,
}) async {
if (profiles.isEmpty) return [];
// Handle offline mode - no loading states needed, data is instant
if (AppState.instance.offlineMode) {
// Clear any existing loading states since offline data is instant
if (isUserInitiated) {
NetworkStatus.instance.clearWaiting();
}
if (uploadMode == UploadMode.sandbox) {
// Offline + Sandbox = no nodes (local cache is production data)
debugPrint('[NodeDataManager] Offline + Sandbox mode: returning no nodes');
return [];
} else {
// Offline + Production = use local offline areas (instant)
final offlineNodes = await fetchLocalNodes(bounds: bounds, profiles: profiles);
// Add offline nodes to cache so they integrate with the rest of the system
if (offlineNodes.isNotEmpty) {
_cache.addOrUpdateNodes(offlineNodes);
// Mark this area as having coverage for submit button logic
_cache.markAreaAsFetched(bounds, offlineNodes);
notifyListeners();
}
// Show brief success for user-initiated offline loads
if (isUserInitiated && offlineNodes.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
NetworkStatus.instance.setSuccess();
});
}
return offlineNodes;
}
}
// Handle sandbox mode (always fetch from OSM API, no caching)
if (uploadMode == UploadMode.sandbox) {
debugPrint('[NodeDataManager] Sandbox mode: fetching from OSM API');
return fetchOsmApiNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: 0,
);
}
// Production mode: check cache first
if (_cache.hasDataFor(bounds)) {
debugPrint('[NodeDataManager] Using cached data for bounds');
return _cache.getNodesFor(bounds);
}
// Not cached - need to fetch
if (isUserInitiated) {
NetworkStatus.instance.setWaiting();
}
try {
final nodes = await fetchWithSplitting(bounds, profiles);
// Don't set success immediately - wait for UI to render the nodes
notifyListeners();
// Set success after the next frame renders (when nodes are actually visible)
if (isUserInitiated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
NetworkStatus.instance.setSuccess();
});
}
return nodes;
} catch (e) {
debugPrint('[NodeDataManager] Fetch failed: $e');
if (isUserInitiated) {
if (e is RateLimitError) {
NetworkStatus.instance.reportOverpassIssue();
} else {
NetworkStatus.instance.setNetworkError();
}
}
// Return whatever we have in cache for this area
return _cache.getNodesFor(bounds);
}
}
/// Fetch nodes with automatic area splitting if needed
Future<List<OsmNode>> fetchWithSplitting(
LatLngBounds bounds,
List<NodeProfile> profiles, {
int splitDepth = 0,
}) async {
const maxSplitDepth = 3; // 4^3 = 64 max sub-areas
try {
// Expand bounds slightly to reduce edge effects
final expandedBounds = _expandBounds(bounds, 1.2);
final nodes = await _overpassService.fetchNodes(
bounds: expandedBounds,
profiles: profiles,
);
// Success - cache the data for the expanded area
_cache.markAreaAsFetched(expandedBounds, nodes);
return nodes;
} on NodeLimitError {
// Hit node limit or timeout - split area if not too deep
if (splitDepth >= maxSplitDepth) {
debugPrint('[NodeDataManager] Max split depth reached, giving up');
return [];
}
debugPrint('[NodeDataManager] Splitting area (depth: $splitDepth)');
NetworkStatus.instance.reportSlowProgress();
return _fetchSplitAreas(bounds, profiles, splitDepth + 1);
} on RateLimitError {
// Rate limited - wait and return empty
debugPrint('[NodeDataManager] Rate limited, backing off');
await Future.delayed(const Duration(seconds: 30));
return [];
}
}
/// Fetch data by splitting area into quadrants
Future<List<OsmNode>> _fetchSplitAreas(
LatLngBounds bounds,
List<NodeProfile> profiles,
int splitDepth,
) async {
final quadrants = _splitBounds(bounds);
final allNodes = <OsmNode>[];
for (final quadrant in quadrants) {
try {
final nodes = await fetchWithSplitting(quadrant, profiles, splitDepth: splitDepth);
allNodes.addAll(nodes);
} catch (e) {
debugPrint('[NodeDataManager] Quadrant fetch failed: $e');
// Continue with other quadrants
}
}
debugPrint('[NodeDataManager] Split fetch complete: ${allNodes.length} total nodes');
return allNodes;
}
/// Split bounds into 4 quadrants
List<LatLngBounds> _splitBounds(LatLngBounds bounds) {
final centerLat = (bounds.north + bounds.south) / 2;
final centerLng = (bounds.east + bounds.west) / 2;
return [
// Southwest
LatLngBounds(LatLng(bounds.south, bounds.west), LatLng(centerLat, centerLng)),
// Southeast
LatLngBounds(LatLng(bounds.south, centerLng), LatLng(centerLat, bounds.east)),
// Northwest
LatLngBounds(LatLng(centerLat, bounds.west), LatLng(bounds.north, centerLng)),
// Northeast
LatLngBounds(LatLng(centerLat, centerLng), LatLng(bounds.north, bounds.east)),
];
}
/// Expand bounds by given factor around center point
LatLngBounds _expandBounds(LatLngBounds bounds, double factor) {
final centerLat = (bounds.north + bounds.south) / 2;
final centerLng = (bounds.east + bounds.west) / 2;
final latSpan = (bounds.north - bounds.south) * factor / 2;
final lngSpan = (bounds.east - bounds.west) * factor / 2;
return LatLngBounds(
LatLng(centerLat - latSpan, centerLng - lngSpan),
LatLng(centerLat + latSpan, centerLng + lngSpan),
);
}
/// Add or update nodes in cache (for upload queue integration)
void addOrUpdateNodes(List<OsmNode> nodes) {
_cache.addOrUpdateNodes(nodes);
notifyListeners();
}
/// Remove node from cache (for deletions)
void removeNodeById(int nodeId) {
_cache.removeNodeById(nodeId);
notifyListeners();
}
/// Clear cache (when profiles change significantly)
void clearCache() {
_cache.clear();
notifyListeners();
}
/// Force refresh for current view (manual retry)
Future<void> refreshArea({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
}) async {
// Clear any cached data for this area
_cache.clear(); // Simple: clear everything for now
// Re-fetch
await getNodesFor(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
isUserInitiated: true,
);
}
/// NodeCache compatibility methods
OsmNode? getNodeById(int nodeId) => _cache.getNodeById(nodeId);
void removePendingEditMarker(int nodeId) => _cache.removePendingEditMarker(nodeId);
void removePendingDeletionMarker(int nodeId) => _cache.removePendingDeletionMarker(nodeId);
void removeTempNodeById(int tempNodeId) => _cache.removeTempNodeById(tempNodeId);
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) =>
_cache.findNodesWithinDistance(coord, distanceMeters, excludeNodeId: excludeNodeId);
/// Check if we have good cache coverage for the given area
bool hasGoodCoverageFor(LatLngBounds bounds) {
return _cache.hasDataFor(bounds);
}
/// Load all offline nodes into cache (call at app startup)
Future<void> preloadOfflineNodes() async {
try {
final offlineAreaService = OfflineAreaService();
for (final area in offlineAreaService.offlineAreas) {
if (area.status != OfflineAreaStatus.complete) continue;
// Load nodes from this offline area
final nodes = await fetchLocalNodes(
bounds: area.bounds,
profiles: [], // Empty profiles = load all nodes
);
if (nodes.isNotEmpty) {
_cache.addOrUpdateNodes(nodes);
// Mark the offline area as having coverage so submit buttons work
_cache.markAreaAsFetched(area.bounds, nodes);
debugPrint('[NodeDataManager] Preloaded ${nodes.length} offline nodes from area ${area.name}');
}
}
notifyListeners();
} catch (e) {
debugPrint('[NodeDataManager] Error preloading offline nodes: $e');
}
}
/// Get cache statistics
String get cacheStats => _cache.stats.toString();
}

View File

@@ -0,0 +1,190 @@
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../models/osm_node.dart';
const Distance _distance = Distance();
/// Simple spatial cache that tracks which areas have been successfully fetched.
/// No temporal expiration - data stays cached until app restart or explicit clear.
class NodeSpatialCache {
static final NodeSpatialCache _instance = NodeSpatialCache._();
factory NodeSpatialCache() => _instance;
NodeSpatialCache._();
final List<CachedArea> _fetchedAreas = [];
final Map<int, OsmNode> _nodes = {}; // nodeId -> node
/// Check if we have cached data covering the given bounds
bool hasDataFor(LatLngBounds bounds) {
return _fetchedAreas.any((area) => area.bounds.containsBounds(bounds));
}
/// Record that we successfully fetched data for this area
void markAreaAsFetched(LatLngBounds bounds, List<OsmNode> nodes) {
// Add the fetched area
_fetchedAreas.add(CachedArea(bounds, DateTime.now()));
// Update nodes in cache
for (final node in nodes) {
_nodes[node.id] = node;
}
debugPrint('[NodeSpatialCache] Cached ${nodes.length} nodes for area ${bounds.south.toStringAsFixed(3)},${bounds.west.toStringAsFixed(3)} to ${bounds.north.toStringAsFixed(3)},${bounds.east.toStringAsFixed(3)}');
debugPrint('[NodeSpatialCache] Total areas cached: ${_fetchedAreas.length}, total nodes: ${_nodes.length}');
}
/// Get all cached nodes within the given bounds
List<OsmNode> getNodesFor(LatLngBounds bounds) {
return _nodes.values
.where((node) => bounds.contains(node.coord))
.toList();
}
/// Add or update individual nodes (for upload queue integration)
void addOrUpdateNodes(List<OsmNode> nodes) {
for (final node in nodes) {
final existing = _nodes[node.id];
if (existing != null) {
// Preserve any tags starting with underscore when updating existing nodes
final mergedTags = Map<String, String>.from(node.tags);
for (final entry in existing.tags.entries) {
if (entry.key.startsWith('_')) {
mergedTags[entry.key] = entry.value;
}
}
_nodes[node.id] = OsmNode(
id: node.id,
coord: node.coord,
tags: mergedTags,
isConstrained: node.isConstrained,
);
} else {
_nodes[node.id] = node;
}
}
}
/// Remove a node by ID (for deletions)
void removeNodeById(int nodeId) {
if (_nodes.remove(nodeId) != null) {
debugPrint('[NodeSpatialCache] Removed node $nodeId from cache');
}
}
/// Get a specific node by ID (returns null if not found)
OsmNode? getNodeById(int nodeId) {
return _nodes[nodeId];
}
/// Remove the _pending_edit marker from a specific node
void removePendingEditMarker(int nodeId) {
final node = _nodes[nodeId];
if (node != null && node.tags.containsKey('_pending_edit')) {
final cleanTags = Map<String, String>.from(node.tags);
cleanTags.remove('_pending_edit');
_nodes[nodeId] = OsmNode(
id: node.id,
coord: node.coord,
tags: cleanTags,
isConstrained: node.isConstrained,
);
}
}
/// Remove the _pending_deletion marker from a specific node
void removePendingDeletionMarker(int nodeId) {
final node = _nodes[nodeId];
if (node != null && node.tags.containsKey('_pending_deletion')) {
final cleanTags = Map<String, String>.from(node.tags);
cleanTags.remove('_pending_deletion');
_nodes[nodeId] = OsmNode(
id: node.id,
coord: node.coord,
tags: cleanTags,
isConstrained: node.isConstrained,
);
}
}
/// Remove a specific temporary node by its ID
void removeTempNodeById(int tempNodeId) {
if (tempNodeId >= 0) {
debugPrint('[NodeSpatialCache] Warning: Attempted to remove non-temp node ID $tempNodeId');
return;
}
if (_nodes.remove(tempNodeId) != null) {
debugPrint('[NodeSpatialCache] Removed temp node $tempNodeId from cache');
}
}
/// Find nodes within distance of a coordinate (for proximity warnings)
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) {
final nearbyNodes = <OsmNode>[];
for (final node in _nodes.values) {
// Skip the excluded node
if (excludeNodeId != null && node.id == excludeNodeId) {
continue;
}
// Skip nodes marked for deletion
if (node.tags.containsKey('_pending_deletion')) {
continue;
}
final distanceInMeters = _distance.as(LengthUnit.Meter, coord, node.coord);
if (distanceInMeters <= distanceMeters) {
nearbyNodes.add(node);
}
}
return nearbyNodes;
}
/// Clear all cached data
void clear() {
_fetchedAreas.clear();
_nodes.clear();
debugPrint('[NodeSpatialCache] Cache cleared');
}
/// Get cache statistics for debugging
CacheStats get stats => CacheStats(
areasCount: _fetchedAreas.length,
nodesCount: _nodes.length,
);
}
/// Represents an area that has been successfully fetched
class CachedArea {
final LatLngBounds bounds;
final DateTime fetchedAt;
CachedArea(this.bounds, this.fetchedAt);
}
/// Cache statistics for debugging
class CacheStats {
final int areasCount;
final int nodesCount;
CacheStats({required this.areasCount, required this.nodesCount});
@override
String toString() => 'CacheStats(areas: $areasCount, nodes: $nodesCount)';
}
/// Extension to check if one bounds completely contains another
extension LatLngBoundsExtension on LatLngBounds {
bool containsBounds(LatLngBounds other) {
return north >= other.north &&
south <= other.south &&
east >= other.east &&
west <= other.west;
}
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../app_state.dart';
import '../dev_config.dart';
/// Service for fetching tag value suggestions from OpenStreetMap Name Suggestion Index
class NSIService {
@@ -66,13 +67,19 @@ class NSIService {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final values = data['data'] as List<dynamic>? ?? [];
// Extract the most commonly used values
// Extract the most commonly used values that meet our minimum hit threshold
final suggestions = <String>[];
for (final item in values) {
if (item is Map<String, dynamic>) {
final value = item['value'] as String?;
if (value != null && value.trim().isNotEmpty && _isValidSuggestion(value)) {
final count = item['count'] as int? ?? 0;
// Only include suggestions that meet our minimum hit count threshold
if (value != null &&
value.trim().isNotEmpty &&
count >= kNSIMinimumHitCount &&
_isValidSuggestion(value)) {
suggestions.add(value.trim());
}
}

View File

@@ -0,0 +1,187 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../models/node_profile.dart';
import '../models/osm_node.dart';
import '../dev_config.dart';
/// Simple Overpass API client with proper HTTP retry logic.
/// Single responsibility: Make requests, handle network errors, return data.
class OverpassService {
static const String _endpoint = 'https://overpass-api.de/api/interpreter';
/// Fetch surveillance nodes from Overpass API with proper retry logic.
/// Throws NetworkError for retryable failures, NodeLimitError for area splitting.
Future<List<OsmNode>> fetchNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
int maxRetries = 3,
}) async {
if (profiles.isEmpty) return [];
final query = _buildQuery(bounds, profiles);
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
debugPrint('[OverpassService] Attempt ${attempt + 1}/${maxRetries + 1} for ${profiles.length} profiles');
final response = await http.post(
Uri.parse(_endpoint),
body: {'data': query},
).timeout(kOverpassQueryTimeout);
if (response.statusCode == 200) {
return _parseResponse(response.body);
}
// Check for specific error types
final errorBody = response.body;
// Node limit error - caller should split area
if (response.statusCode == 400 &&
(errorBody.contains('too many nodes') && errorBody.contains('50000'))) {
debugPrint('[OverpassService] Node limit exceeded, area should be split');
throw NodeLimitError('Query exceeded 50k node limit');
}
// Timeout error - also try splitting (complex query)
if (errorBody.contains('timeout') ||
errorBody.contains('runtime limit exceeded') ||
errorBody.contains('Query timed out')) {
debugPrint('[OverpassService] Query timeout, area should be split');
throw NodeLimitError('Query timed out - area too complex');
}
// Rate limit - throw immediately, don't retry
if (response.statusCode == 429 ||
errorBody.contains('rate limited') ||
errorBody.contains('too many requests')) {
debugPrint('[OverpassService] Rate limited by Overpass');
throw RateLimitError('Rate limited by Overpass API');
}
// Other HTTP errors - retry with backoff
if (attempt < maxRetries) {
final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000));
debugPrint('[OverpassService] HTTP ${response.statusCode} error, retrying in ${delay.inMilliseconds}ms');
await Future.delayed(delay);
continue;
}
throw NetworkError('HTTP ${response.statusCode}: $errorBody');
} catch (e) {
// Handle specific error types without retry
if (e is NodeLimitError || e is RateLimitError) {
rethrow;
}
// Network/timeout errors - retry with backoff
if (attempt < maxRetries) {
final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000));
debugPrint('[OverpassService] Network error ($e), retrying in ${delay.inMilliseconds}ms');
await Future.delayed(delay);
continue;
}
throw NetworkError('Network error after $maxRetries retries: $e');
}
}
throw NetworkError('Max retries exceeded');
}
/// Build Overpass QL query for given bounds and profiles
String _buildQuery(LatLngBounds bounds, List<NodeProfile> profiles) {
final nodeClauses = profiles.map((profile) {
// Convert profile tags to Overpass filter format, excluding empty values
final tagFilters = profile.tags.entries
.where((entry) => entry.value.trim().isNotEmpty)
.map((entry) => '["${entry.key}"="${entry.value}"]')
.join();
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
}).join('\n ');
return '''
[out:json][timeout:${kOverpassQueryTimeout.inSeconds}];
(
$nodeClauses
);
out body;
(
way(bn);
rel(bn);
);
out meta;
''';
}
/// Parse Overpass JSON response into OsmNode objects
List<OsmNode> _parseResponse(String responseBody) {
final data = jsonDecode(responseBody) as Map<String, dynamic>;
final elements = data['elements'] as List<dynamic>;
final nodeElements = <Map<String, dynamic>>[];
final constrainedNodeIds = <int>{};
// First pass: collect surveillance nodes and identify constrained nodes
for (final element in elements.whereType<Map<String, dynamic>>()) {
final type = element['type'] as String?;
if (type == 'node') {
nodeElements.add(element);
} else if (type == 'way' || type == 'relation') {
// Mark referenced nodes as constrained
final refs = element['nodes'] as List<dynamic>? ??
element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? [];
for (final ref in refs) {
final nodeId = ref is int ? ref : int.tryParse(ref.toString());
if (nodeId != null) constrainedNodeIds.add(nodeId);
}
}
}
// Second pass: create OsmNode objects
final nodes = nodeElements.map((element) {
final nodeId = element['id'] as int;
return OsmNode(
id: nodeId,
coord: LatLng(element['lat'], element['lon']),
tags: Map<String, String>.from(element['tags'] ?? {}),
isConstrained: constrainedNodeIds.contains(nodeId),
);
}).toList();
debugPrint('[OverpassService] Parsed ${nodes.length} nodes, ${constrainedNodeIds.length} constrained');
return nodes;
}
}
/// Error thrown when query exceeds node limits or is too complex - area should be split
class NodeLimitError extends Error {
final String message;
NodeLimitError(this.message);
@override
String toString() => 'NodeLimitError: $message';
}
/// Error thrown when rate limited - should not retry immediately
class RateLimitError extends Error {
final String message;
RateLimitError(this.message);
@override
String toString() => 'RateLimitError: $message';
}
/// Error thrown for network/HTTP issues - retryable
class NetworkError extends Error {
final String message;
NetworkError(this.message);
@override
String toString() => 'NetworkError: $message';
}

View File

@@ -1,192 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../models/node_profile.dart';
import '../models/osm_node.dart';
import '../app_state.dart';
import '../dev_config.dart';
import 'map_data_submodules/nodes_from_overpass.dart';
import 'node_cache.dart';
import 'network_status.dart';
import '../widgets/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.
class PrefetchAreaService {
static final PrefetchAreaService _instance = PrefetchAreaService._();
factory PrefetchAreaService() => _instance;
PrefetchAreaService._();
// Current pre-fetched area and associated data
LatLngBounds? _preFetchedArea;
List<NodeProfile>? _preFetchedProfiles;
UploadMode? _preFetchedUploadMode;
DateTime? _lastFetchTime;
bool _preFetchInProgress = false;
// Debounce timer to avoid rapid requests while user is panning
Timer? _debounceTimer;
// Configuration from dev_config
static const double _areaExpansionMultiplier = kPreFetchAreaExpansionMultiplier;
static const int _preFetchZoomLevel = kPreFetchZoomLevel;
/// Check if the given bounds are fully within the current pre-fetched area.
bool isWithinPreFetchedArea(LatLngBounds bounds, List<NodeProfile> profiles, UploadMode uploadMode) {
if (_preFetchedArea == null || _preFetchedProfiles == null || _preFetchedUploadMode == null) {
return false;
}
// Check if profiles and upload mode match
if (_preFetchedUploadMode != uploadMode) {
return false;
}
if (!_profileListsEqual(_preFetchedProfiles!, profiles)) {
return false;
}
// Check if bounds are fully contained within pre-fetched area
return bounds.north <= _preFetchedArea!.north &&
bounds.south >= _preFetchedArea!.south &&
bounds.east <= _preFetchedArea!.east &&
bounds.west >= _preFetchedArea!.west;
}
/// Check if cached data is stale (older than configured refresh interval).
bool isDataStale() {
if (_lastFetchTime == null) return true;
return DateTime.now().difference(_lastFetchTime!).inSeconds > kDataRefreshIntervalSeconds;
}
/// Request pre-fetch for the given view bounds if not already covered or if data is stale.
/// Uses debouncing to avoid rapid requests while user is panning.
void requestPreFetchIfNeeded({
required LatLngBounds viewBounds,
required List<NodeProfile> profiles,
required UploadMode uploadMode,
}) {
// Skip if already in progress
if (_preFetchInProgress) {
debugPrint('[PrefetchAreaService] Pre-fetch already in progress, skipping');
return;
}
// Check both spatial and temporal conditions
final isWithinArea = isWithinPreFetchedArea(viewBounds, profiles, uploadMode);
final isStale = isDataStale();
if (isWithinArea && !isStale) {
debugPrint('[PrefetchAreaService] Current view within fresh pre-fetched area, no fetch needed');
return;
}
if (isStale) {
debugPrint('[PrefetchAreaService] Data is stale (>${kDataRefreshIntervalSeconds}s), refreshing');
} else {
debugPrint('[PrefetchAreaService] Current view outside pre-fetched area, fetching larger area');
}
// Cancel any pending debounced request
_debounceTimer?.cancel();
// Debounce to avoid rapid requests while user is still moving
_debounceTimer = Timer(const Duration(milliseconds: 800), () {
_startPreFetch(
viewBounds: viewBounds,
profiles: profiles,
uploadMode: uploadMode,
);
});
}
/// Start the actual pre-fetch operation.
Future<void> _startPreFetch({
required LatLngBounds viewBounds,
required List<NodeProfile> profiles,
required UploadMode uploadMode,
}) async {
if (_preFetchInProgress) return;
_preFetchInProgress = true;
try {
// Calculate expanded area for pre-fetching
final preFetchArea = _expandBounds(viewBounds, _areaExpansionMultiplier);
debugPrint('[PrefetchAreaService] Starting pre-fetch for area: ${preFetchArea.south},${preFetchArea.west} to ${preFetchArea.north},${preFetchArea.east}');
// Fetch nodes for the expanded area (unlimited - let splitting handle 50k limit)
final nodes = await fetchOverpassNodes(
bounds: preFetchArea,
profiles: profiles,
uploadMode: uploadMode,
maxResults: 0, // Unlimited - our splitting system handles the 50k limit gracefully
);
debugPrint('[PrefetchAreaService] Pre-fetch completed: ${nodes.length} nodes retrieved');
// Update cache with new nodes (fresh data overwrites stale, but preserves underscore tags)
if (nodes.isNotEmpty) {
NodeCache.instance.addOrUpdate(nodes);
}
// Store the pre-fetched area info and timestamp
_preFetchedArea = preFetchArea;
_preFetchedProfiles = List.from(profiles);
_preFetchedUploadMode = uploadMode;
_lastFetchTime = DateTime.now();
// 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
NodeProviderWithCache.instance.refreshDisplay();
} catch (e) {
debugPrint('[PrefetchAreaService] Pre-fetch failed: $e');
// The overpass module already reported the error status
// Don't update pre-fetched area info on failure
} finally {
_preFetchInProgress = false;
}
}
/// Expand bounds by the given multiplier, maintaining center point.
LatLngBounds _expandBounds(LatLngBounds bounds, double multiplier) {
final centerLat = (bounds.north + bounds.south) / 2;
final centerLng = (bounds.east + bounds.west) / 2;
final latSpan = (bounds.north - bounds.south) * multiplier / 2;
final lngSpan = (bounds.east - bounds.west) * multiplier / 2;
return LatLngBounds(
LatLng(centerLat - latSpan, centerLng - lngSpan), // Southwest
LatLng(centerLat + latSpan, centerLng + lngSpan), // Northeast
);
}
/// Check if two profile lists are equal by comparing IDs.
bool _profileListsEqual(List<NodeProfile> list1, List<NodeProfile> list2) {
if (list1.length != list2.length) return false;
final ids1 = list1.map((p) => p.id).toSet();
final ids2 = list2.map((p) => p.id).toSet();
return ids1.length == ids2.length && ids1.containsAll(ids2);
}
/// Clear the pre-fetched area (e.g., when profiles change significantly).
void clearPreFetchedArea() {
_preFetchedArea = null;
_preFetchedProfiles = null;
_preFetchedUploadMode = null;
_lastFetchTime = null;
debugPrint('[PrefetchAreaService] Pre-fetched area cleared');
}
/// Dispose of resources.
void dispose() {
_debounceTimer?.cancel();
}
}

View File

@@ -0,0 +1,120 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import '../models/node_profile.dart';
class ProfileImportService {
// Maximum size for base64 encoded profile data (approx 50KB decoded)
static const int maxBase64Length = 70000;
/// Parse and validate a profile from a base64-encoded JSON string
/// Returns null if parsing/validation fails
static NodeProfile? parseProfileFromBase64(String base64Data) {
try {
// Basic size validation before expensive decode
if (base64Data.length > maxBase64Length) {
debugPrint('[ProfileImportService] Base64 data too large: ${base64Data.length} characters');
return null;
}
// Decode base64
final jsonBytes = base64Decode(base64Data);
final jsonString = utf8.decode(jsonBytes);
// Parse JSON
final jsonData = jsonDecode(jsonString) as Map<String, dynamic>;
// Validate and sanitize the profile data
final sanitizedProfile = _validateAndSanitizeProfile(jsonData);
return sanitizedProfile;
} catch (e) {
debugPrint('[ProfileImportService] Failed to parse profile from base64: $e');
return null;
}
}
/// Validate profile structure and sanitize all string values
static NodeProfile? _validateAndSanitizeProfile(Map<String, dynamic> data) {
try {
// Extract and sanitize required fields
final name = _sanitizeString(data['name']);
if (name == null || name.isEmpty) {
debugPrint('[ProfileImportService] Profile name is required');
return null;
}
// Extract and sanitize tags
final tagsData = data['tags'];
if (tagsData is! Map<String, dynamic>) {
debugPrint('[ProfileImportService] Profile tags must be a map');
return null;
}
final sanitizedTags = <String, String>{};
for (final entry in tagsData.entries) {
final key = _sanitizeString(entry.key);
final value = _sanitizeString(entry.value);
if (key != null && key.isNotEmpty) {
// Allow empty values for refinement purposes
sanitizedTags[key] = value ?? '';
}
}
if (sanitizedTags.isEmpty) {
debugPrint('[ProfileImportService] Profile must have at least one valid tag');
return null;
}
// Extract optional fields with defaults
final requiresDirection = data['requiresDirection'] ?? true;
final submittable = data['submittable'] ?? true;
// Parse FOV if provided
double? fov;
if (data['fov'] != null) {
if (data['fov'] is num) {
final fovValue = (data['fov'] as num).toDouble();
if (fovValue > 0 && fovValue <= 360) {
fov = fovValue;
}
}
}
return NodeProfile(
id: const Uuid().v4(), // Always generate new ID for imported profiles
name: name,
tags: sanitizedTags,
builtin: false, // Imported profiles are always custom
requiresDirection: requiresDirection is bool ? requiresDirection : true,
submittable: submittable is bool ? submittable : true,
editable: true, // Imported profiles are always editable
fov: fov,
);
} catch (e) {
debugPrint('[ProfileImportService] Failed to validate profile: $e');
return null;
}
}
/// Sanitize a string value by trimming and removing potentially harmful characters
static String? _sanitizeString(dynamic value) {
if (value == null) return null;
final str = value.toString().trim();
// Remove control characters and limit length
final sanitized = str.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '');
// Limit length to prevent abuse
const maxLength = 500;
if (sanitized.length > maxLength) {
return sanitized.substring(0, maxLength);
}
return sanitized;
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
@@ -12,19 +13,19 @@ class SearchService {
static const Duration _timeout = Duration(seconds: 10);
/// Search for places using Nominatim geocoding service
Future<List<SearchResult>> search(String query) async {
Future<List<SearchResult>> search(String query, {LatLngBounds? viewbox}) async {
if (query.trim().isEmpty) {
return [];
}
// Check if query looks like coordinates first
final coordResult = _tryParseCoordinates(query.trim());
if (coordResult != null) {
return [coordResult];
}
// Otherwise, use Nominatim API
return await _searchNominatim(query.trim());
return await _searchNominatim(query.trim(), viewbox: viewbox);
}
/// Try to parse various coordinate formats
@@ -52,14 +53,37 @@ class SearchService {
}
/// Search using Nominatim API
Future<List<SearchResult>> _searchNominatim(String query) async {
final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: {
Future<List<SearchResult>> _searchNominatim(String query, {LatLngBounds? viewbox}) async {
final params = {
'q': query,
'format': 'json',
'limit': _maxResults.toString(),
'addressdetails': '1',
'extratags': '1',
});
};
if (viewbox != null) {
double round1(double v) => (v * 10).round() / 10;
var west = round1(viewbox.west);
var east = round1(viewbox.east);
var south = round1(viewbox.south);
var north = round1(viewbox.north);
if (east - west < 0.5) {
final mid = (east + west) / 2;
west = mid - 0.25;
east = mid + 0.25;
}
if (north - south < 0.5) {
final mid = (north + south) / 2;
south = mid - 0.25;
north = mid + 0.25;
}
params['viewbox'] = '$west,$north,$east,$south';
}
final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: params);
debugPrint('[SearchService] Searching Nominatim: $uri');

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:latlong2/latlong.dart';
import '../models/search_result.dart';
@@ -31,7 +32,8 @@ class NavigationState extends ChangeNotifier {
bool _isSearchLoading = false;
List<SearchResult> _searchResults = [];
String _lastQuery = '';
LatLngBounds? _searchViewbox;
// Location state
LatLng? _provisionalPinLocation;
String? _provisionalPinAddress;
@@ -106,19 +108,20 @@ class NavigationState extends ChangeNotifier {
}
/// BRUTALIST: Single entry point to search mode
void enterSearchMode(LatLng mapCenter) {
void enterSearchMode(LatLng mapCenter, {LatLngBounds? viewbox}) {
debugPrint('[NavigationState] enterSearchMode - current mode: $_mode');
if (_mode != AppNavigationMode.normal) {
debugPrint('[NavigationState] Cannot enter search mode - not in normal mode');
return;
}
_mode = AppNavigationMode.search;
_provisionalPinLocation = mapCenter;
_provisionalPinAddress = null;
_searchViewbox = viewbox;
_clearSearchResults();
debugPrint('[NavigationState] Entered search mode');
notifyListeners();
}
@@ -149,7 +152,8 @@ class NavigationState extends ChangeNotifier {
_showingOverview = false;
_nextPointIsStart = false;
_routingError = null;
_searchViewbox = null;
// Clear search
_clearSearchResults();
@@ -336,21 +340,21 @@ class NavigationState extends ChangeNotifier {
_clearSearchResults();
return;
}
if (query.trim() == _lastQuery.trim()) return;
_setSearchLoading(true);
_lastQuery = query.trim();
try {
final results = await _searchService.search(query.trim());
final results = await _searchService.search(query.trim(), viewbox: _searchViewbox);
_searchResults = results;
debugPrint('[NavigationState] Found ${results.length} results');
} catch (e) {
debugPrint('[NavigationState] Search failed: $e');
_searchResults = [];
}
_setSearchLoading(false);
}

View File

@@ -7,19 +7,24 @@ import 'package:latlong2/latlong.dart';
import '../models/pending_upload.dart';
import '../models/osm_node.dart';
import '../models/node_profile.dart';
import '../services/node_cache.dart';
import '../services/map_data_provider.dart';
import '../services/uploader.dart';
import '../widgets/node_provider_with_cache.dart';
import '../dev_config.dart';
import 'settings_state.dart';
import 'session_state.dart';
class UploadQueueState extends ChangeNotifier {
/// Helper to access the map data provider instance
MapDataProvider get _nodeCache => MapDataProvider();
final List<PendingUpload> _queue = [];
Timer? _uploadTimer;
int _activeUploadCount = 0;
// Getters
int get pendingCount => _queue.length;
List<PendingUpload> get pendingUploads => List.unmodifiable(_queue);
int get activeUploadCount => _activeUploadCount;
// Initialize by loading queue from storage and repopulate cache with pending nodes
Future<void> init() async {
@@ -45,7 +50,7 @@ class UploadQueueState extends ChangeNotifier {
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!);
final existingNode = _nodeCache.getNodeById(upload.originalNodeId!);
if (existingNode != null) {
final deletionTags = Map<String, String>.from(existingNode.tags);
deletionTags['_pending_deletion'] = 'true';
@@ -75,7 +80,7 @@ class UploadQueueState extends ChangeNotifier {
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!);
final existingOriginal = _nodeCache.getNodeById(upload.originalNodeId!);
if (existingOriginal != null) {
final originalTags = Map<String, String>.from(existingOriginal.tags);
originalTags['_pending_edit'] = 'true';
@@ -106,7 +111,7 @@ class UploadQueueState extends ChangeNotifier {
}
if (nodesToAdd.isNotEmpty) {
NodeCache.instance.addOrUpdate(nodesToAdd);
_nodeCache.addOrUpdate(nodesToAdd);
print('[UploadQueue] Repopulated cache with ${nodesToAdd.length} pending nodes from queue');
// Save queue if we updated any temp IDs for backward compatibility
@@ -149,7 +154,7 @@ class UploadQueueState extends ChangeNotifier {
tags: tags,
);
NodeCache.instance.addOrUpdate([tempNode]);
_nodeCache.addOrUpdate([tempNode]);
// Notify node provider to update the map
NodeProviderWithCache.instance.notifyListeners();
@@ -208,7 +213,7 @@ class UploadQueueState extends ChangeNotifier {
tags: extractedTags,
);
NodeCache.instance.addOrUpdate([extractedNode]);
_nodeCache.addOrUpdate([extractedNode]);
} else {
// For modify: mark original with grey ring and create new temp node
// 1. Mark the original node with _pending_edit (grey ring) at original location
@@ -237,7 +242,7 @@ class UploadQueueState extends ChangeNotifier {
tags: editedTags,
);
NodeCache.instance.addOrUpdate([originalNode, editedNode]);
_nodeCache.addOrUpdate([originalNode, editedNode]);
}
// Notify node provider to update the map
NodeProviderWithCache.instance.notifyListeners();
@@ -269,7 +274,7 @@ class UploadQueueState extends ChangeNotifier {
tags: deletionTags,
);
NodeCache.instance.addOrUpdate([nodeWithDeletionTag]);
_nodeCache.addOrUpdate([nodeWithDeletionTag]);
// Notify node provider to update the map
NodeProviderWithCache.instance.notifyListeners();
@@ -321,19 +326,22 @@ class UploadQueueState extends ChangeNotifier {
// No uploads if queue is empty, offline mode is enabled, or queue processing is paused
if (_queue.isEmpty || offlineMode || pauseQueueProcessing) return;
_uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async {
_uploadTimer = Timer.periodic(kUploadQueueProcessingInterval, (t) async {
if (_queue.isEmpty || offlineMode || pauseQueueProcessing) {
_uploadTimer?.cancel();
return;
}
// Find 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();
// Check if we can start more uploads (concurrency limit check)
if (_activeUploadCount >= kMaxConcurrentUploads) {
debugPrint('[UploadQueue] At concurrency limit ($_activeUploadCount/$kMaxConcurrentUploads), waiting for uploads to complete');
return;
}
// Process any expired items
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');
@@ -347,73 +355,109 @@ class UploadQueueState extends ChangeNotifier {
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 pending item to start
final pendingItems = _queue.where((pu) => pu.uploadState == UploadState.pending).toList();
// 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
if (pendingItems.isEmpty) {
// 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)
pu.uploadState == UploadState.uploading ||
pu.uploadState == UploadState.closingChangeset
);
if (!hasActiveItems) {
debugPrint('[UploadQueue] No active items remaining, stopping uploader');
_uploadTimer?.cancel();
}
return; // Nothing to process right now
return;
}
// Retrieve access after every tick (accounts for re-login)
// Retrieve access token
final access = await getAccessToken();
if (access == null) return; // not logged in
debugPrint('[UploadQueue] Processing item in state: ${item.uploadState} with uploadMode: ${item.uploadMode}');
// Start processing the next pending upload
final item = pendingItems.first;
debugPrint('[UploadQueue] Starting new upload processing for item at ${item.coord} ($_activeUploadCount/$kMaxConcurrentUploads active)');
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);
}
_activeUploadCount++;
_processIndividualUpload(item, access);
});
}
// Process an individual upload through all three stages
Future<void> _processIndividualUpload(PendingUpload item, String accessToken) async {
try {
debugPrint('[UploadQueue] Starting individual upload processing for ${item.operation.name} at ${item.coord}');
// Stage 1: Create changeset
await _processCreateChangeset(item, accessToken);
if (item.uploadState == UploadState.error) return;
// Stage 2: Node operation with retry logic
bool nodeOperationCompleted = false;
while (!nodeOperationCompleted && !item.hasChangesetExpired && item.uploadState != UploadState.error) {
await _processNodeOperation(item, accessToken);
if (item.uploadState == UploadState.closingChangeset) {
// Node operation succeeded
nodeOperationCompleted = true;
} else if (item.uploadState == UploadState.uploading && !item.isReadyForNodeSubmissionRetry) {
// Need to wait before retry
final delay = item.nextNodeSubmissionRetryDelay;
debugPrint('[UploadQueue] Waiting ${delay.inSeconds}s before node submission retry');
await Future.delayed(delay);
} else if (item.uploadState == UploadState.error) {
// Failed permanently
return;
}
}
if (!nodeOperationCompleted) return; // Failed or expired
// Stage 3: Close changeset with retry logic
bool changesetClosed = false;
while (!changesetClosed && !item.hasChangesetExpired && item.uploadState != UploadState.error) {
await _processChangesetClose(item, accessToken);
if (item.uploadState == UploadState.complete) {
// Changeset close succeeded
changesetClosed = true;
} else if (item.uploadState == UploadState.closingChangeset && !item.isReadyForChangesetCloseRetry) {
// Need to wait before retry
final delay = item.nextChangesetCloseRetryDelay;
debugPrint('[UploadQueue] Waiting ${delay.inSeconds}s before changeset close retry');
await Future.delayed(delay);
} else if (item.uploadState == UploadState.error) {
// Failed permanently
return;
}
}
if (!changesetClosed && item.hasChangesetExpired) {
// Trust OSM auto-close if we ran out of time
debugPrint('[UploadQueue] Upload completed but changeset close timed out - trusting OSM auto-close');
if (item.submittedNodeId != null) {
_markAsCompleting(item, submittedNodeId: item.submittedNodeId!);
}
}
} catch (e) {
debugPrint('[UploadQueue] Unexpected error in individual upload processing: $e');
item.setError('Unexpected error: $e');
_saveQueue();
notifyListeners();
} finally {
// Always decrement the active upload count
_activeUploadCount--;
debugPrint('[UploadQueue] Individual upload processing finished ($_activeUploadCount/$kMaxConcurrentUploads active)');
}
}
// Process changeset creation (step 1 of 3)
Future<void> _processCreateChangeset(PendingUpload item, String access) async {
item.markAsCreatingChangeset();
@@ -651,11 +695,11 @@ class UploadQueueState extends ChangeNotifier {
);
// Add/update the cache with the real node
NodeCache.instance.addOrUpdate([realNode]);
_nodeCache.addOrUpdate([realNode]);
// Clean up the specific temp node for this upload
if (item.tempNodeId != null) {
NodeCache.instance.removeTempNodeById(item.tempNodeId!);
_nodeCache.removeTempNodeById(item.tempNodeId!);
}
// For modify operations, clean up the original node's _pending_edit marker
@@ -663,7 +707,7 @@ class UploadQueueState extends ChangeNotifier {
if (item.isEdit && item.originalNodeId != null) {
// Remove the _pending_edit marker from the original node in cache
// The next Overpass fetch will provide the authoritative data anyway
NodeCache.instance.removePendingEditMarker(item.originalNodeId!);
_nodeCache.removePendingEditMarker(item.originalNodeId!);
}
// Notify node provider to update the map
@@ -674,7 +718,7 @@ class UploadQueueState extends ChangeNotifier {
void _handleSuccessfulDeletion(PendingUpload item) {
if (item.originalNodeId != null) {
// Remove the node from cache entirely
NodeCache.instance.removeNodeById(item.originalNodeId!);
_nodeCache.removeNodeById(item.originalNodeId!);
// Notify node provider to update the map
NodeProviderWithCache.instance.notifyListeners();
@@ -701,6 +745,11 @@ class UploadQueueState extends ChangeNotifier {
// Convert a center direction and FOV to range notation (e.g., 180° center with 90° FOV -> "135-225")
String _formatDirectionWithFov(double center, double fov) {
// Handle 360-degree FOV as special case
if (fov >= 360) {
return '0-360';
}
final halfFov = fov / 2;
final start = (center - halfFov + 360) % 360;
final end = (center + halfFov) % 360;
@@ -713,25 +762,25 @@ class UploadQueueState extends ChangeNotifier {
if (upload.isDeletion) {
// For deletions: remove the _pending_deletion marker from the original node
if (upload.originalNodeId != null) {
NodeCache.instance.removePendingDeletionMarker(upload.originalNodeId!);
_nodeCache.removePendingDeletionMarker(upload.originalNodeId!);
}
} else if (upload.isEdit) {
// For edits: remove the specific temp node and the _pending_edit marker from original
if (upload.tempNodeId != null) {
NodeCache.instance.removeTempNodeById(upload.tempNodeId!);
_nodeCache.removeTempNodeById(upload.tempNodeId!);
}
if (upload.originalNodeId != null) {
NodeCache.instance.removePendingEditMarker(upload.originalNodeId!);
_nodeCache.removePendingEditMarker(upload.originalNodeId!);
}
} else if (upload.operation == UploadOperation.extract) {
// For extracts: remove the specific temp node (leave original unchanged)
if (upload.tempNodeId != null) {
NodeCache.instance.removeTempNodeById(upload.tempNodeId!);
_nodeCache.removeTempNodeById(upload.tempNodeId!);
}
} else {
// For creates: remove the specific temp node
if (upload.tempNodeId != null) {
NodeCache.instance.removeTempNodeById(upload.tempNodeId!);
_nodeCache.removeTempNodeById(upload.tempNodeId!);
}
}
}

View File

@@ -1,22 +1,105 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../app_state.dart';
import '../dev_config.dart';
import '../models/node_profile.dart';
import '../models/operator_profile.dart';
import '../services/localization_service.dart';
import '../services/node_cache.dart';
import '../services/map_data_provider.dart';
import '../services/node_data_manager.dart';
import '../services/changelog_service.dart';
import 'refine_tags_sheet.dart';
import 'proximity_warning_dialog.dart';
import 'submission_guide_dialog.dart';
import 'positioning_tutorial_overlay.dart';
class AddNodeSheet extends StatelessWidget {
class AddNodeSheet extends StatefulWidget {
const AddNodeSheet({super.key, required this.session});
final AddNodeSession session;
@override
State<AddNodeSheet> createState() => _AddNodeSheetState();
}
class _AddNodeSheetState extends State<AddNodeSheet> {
bool _showTutorial = false;
bool _isCheckingTutorial = true;
@override
void initState() {
super.initState();
_checkTutorialStatus();
// Listen to node data manager for cache updates
NodeDataManager().addListener(_onCacheUpdated);
}
void _onCacheUpdated() {
// Rebuild when cache updates (e.g., when new data loads)
if (mounted) setState(() {});
}
Future<void> _checkTutorialStatus() async {
final hasCompleted = await ChangelogService().hasCompletedPositioningTutorial();
if (mounted) {
setState(() {
_showTutorial = !hasCompleted;
_isCheckingTutorial = false;
});
// If tutorial should be shown, register callback with AppState
if (_showTutorial) {
final appState = context.read<AppState>();
appState.registerTutorialCallback(_hideTutorial);
}
}
}
/// Listen for tutorial completion from AppState
void _onTutorialCompleted() {
_hideTutorial();
}
/// Also check periodically if tutorial was completed by another sheet
void _recheckTutorialStatus() async {
if (_showTutorial) {
final hasCompleted = await ChangelogService().hasCompletedPositioningTutorial();
if (hasCompleted && mounted) {
setState(() {
_showTutorial = false;
});
}
}
}
void _hideTutorial() {
if (mounted && _showTutorial) {
setState(() {
_showTutorial = false;
});
}
}
@override
void dispose() {
// Remove listener
NodeDataManager().removeListener(_onCacheUpdated);
// Clear tutorial callback when widget is disposed
if (_showTutorial) {
try {
context.read<AppState>().clearTutorialCallback();
} catch (e) {
// Context might be unavailable during disposal, ignore
}
}
super.dispose();
}
void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) {
_checkSubmissionGuideAndProceed(context, appState, locService);
}
@@ -27,11 +110,16 @@ class AddNodeSheet extends StatelessWidget {
if (!hasSeenGuide) {
// Show submission guide dialog first
await showDialog<void>(
final shouldProceed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => const SubmissionGuideDialog(),
);
// If user canceled the submission guide, don't proceed with submission
if (shouldProceed != true) {
return;
}
}
// Now proceed with proximity check
@@ -40,14 +128,14 @@ class AddNodeSheet extends StatelessWidget {
void _checkProximityOnly(BuildContext context, AppState appState, LocalizationService locService) {
// Only check proximity if we have a target location
if (session.target == null) {
if (widget.session.target == null) {
_commitWithoutCheck(context, appState, locService);
return;
}
// Check for nearby nodes within the configured distance
final nearbyNodes = NodeCache.instance.findNodesWithinDistance(
session.target!,
final nearbyNodes = MapDataProvider().findNodesWithinDistance(
widget.session.target!,
kNodeProximityWarningDistance,
);
@@ -81,8 +169,111 @@ class AddNodeSheet extends StatelessWidget {
);
}
Widget _buildProfileDropdown(BuildContext context, AppState appState, AddNodeSession session, List<NodeProfile> submittableProfiles, LocalizationService locService) {
return PopupMenuButton<String>(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
session.profile?.name ?? locService.t('addNode.selectProfile'),
style: TextStyle(
fontSize: 16,
color: session.profile != null ? null : Colors.grey.shade600,
),
),
const SizedBox(width: 4),
const Icon(Icons.arrow_drop_down, size: 20),
],
),
),
itemBuilder: (context) => [
// Regular profiles
...submittableProfiles.map(
(profile) => PopupMenuItem<String>(
value: 'profile_${profile.id}',
child: Text(profile.name),
),
),
// Divider
if (submittableProfiles.isNotEmpty) const PopupMenuDivider(),
// Get more... option
PopupMenuItem<String>(
value: 'get_more',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.language, size: 16),
const SizedBox(width: 8),
Text(
locService.t('profiles.getMore'),
style: const TextStyle(
fontStyle: FontStyle.italic,
),
),
],
),
),
],
onSelected: (value) {
if (value == 'get_more') {
_openIdentifyWebsite(context);
} else if (value.startsWith('profile_')) {
final profileId = value.substring(8); // Remove 'profile_' prefix
final profile = submittableProfiles.firstWhere((p) => p.id == profileId);
appState.updateSession(profile: profile);
}
},
);
}
void _openIdentifyWebsite(BuildContext context) async {
const url = 'https://deflock.me/identify';
try {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(
uri,
mode: LaunchMode.externalApplication, // Force external browser
);
} else {
if (context.mounted) {
_showErrorSnackBar(context, 'Unable to open website');
}
}
} catch (e) {
if (context.mounted) {
_showErrorSnackBar(context, 'Error opening website: $e');
}
}
}
void _showErrorSnackBar(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
}
Widget _buildDirectionControls(BuildContext context, AppState appState, AddNodeSession session, LocalizationService locService) {
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
final is360Fov = session.profile?.fov == 360;
final enableDirectionControls = requiresDirection && !is360Fov;
// Force direction to 0 when FOV is 360 (omnidirectional)
if (is360Fov && session.directionDegrees != 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
appState.updateSession(directionDeg: 0);
});
}
// Format direction display text with bold for current direction
String directionsText = '';
@@ -132,7 +323,7 @@ class AddNodeSheet extends StatelessWidget {
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: requiresDirection ? (v) => appState.updateSession(directionDeg: v) : null,
onChanged: enableDirectionControls ? (v) => appState.updateSession(directionDeg: v) : null,
),
),
// Direction control buttons - always show but grey out when direction not required
@@ -142,9 +333,9 @@ class AddNodeSheet extends StatelessWidget {
icon: Icon(
Icons.remove,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
color: enableDirectionControls ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection && session.directions.length > 1
onPressed: enableDirectionControls && session.directions.length > 1
? () => appState.removeDirection()
: null,
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
@@ -156,9 +347,9 @@ class AddNodeSheet extends StatelessWidget {
icon: Icon(
Icons.add,
size: 20,
color: requiresDirection && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
color: enableDirectionControls && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection && session.directions.length < 8 ? () => appState.addDirection() : null,
onPressed: enableDirectionControls && session.directions.length < 8 ? () => appState.addDirection() : null,
tooltip: requiresDirection
? (session.directions.length >= 8 ? 'Maximum 8 directions allowed' : 'Add new direction')
: 'Direction not required for this profile',
@@ -170,9 +361,9 @@ class AddNodeSheet extends StatelessWidget {
icon: Icon(
Icons.repeat,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
color: enableDirectionControls ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection && session.directions.length > 1
onPressed: enableDirectionControls && session.directions.length > 1
? () => appState.cycleDirection()
: null,
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',
@@ -220,11 +411,40 @@ class AddNodeSheet extends StatelessWidget {
Navigator.pop(context);
}
final session = widget.session;
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
// Check if we have good cache coverage around the node position
bool hasGoodCoverage = true;
if (session.target != null) {
// Create a small bounds around the target position to check coverage
const double bufferDegrees = 0.001; // ~100m buffer
final targetBounds = LatLngBounds(
LatLng(session.target!.latitude - bufferDegrees, session.target!.longitude - bufferDegrees),
LatLng(session.target!.latitude + bufferDegrees, session.target!.longitude + bufferDegrees),
);
hasGoodCoverage = MapDataProvider().hasGoodCoverageFor(targetBounds);
// If strict coverage check fails, fall back to checking if we have any nodes nearby
// This handles the timing issue where cache might not be marked as "covered" yet
if (!hasGoodCoverage) {
final nearbyNodes = MapDataProvider().findNodesWithinDistance(
session.target!,
200.0, // 200m radius - if we have nodes nearby, we likely have good data
);
hasGoodCoverage = nearbyNodes.isNotEmpty;
}
}
final allowSubmit = appState.isLoggedIn &&
submittableProfiles.isNotEmpty &&
session.profile != null &&
session.profile!.isSubmittable;
session.profile!.isSubmittable &&
hasGoodCoverage;
void _navigateToLogin() {
Navigator.pushNamed(context, '/settings/osm-account');
}
void _openRefineTags() async {
final result = await Navigator.push<RefineTagsResult?>(
@@ -246,7 +466,11 @@ class AddNodeSheet extends StatelessWidget {
}
}
return Column(
return Stack(
clipBehavior: Clip.none,
fit: StackFit.loose,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
@@ -261,14 +485,7 @@ class AddNodeSheet extends StatelessWidget {
const SizedBox(height: 16),
ListTile(
title: Text(locService.t('addNode.profile')),
trailing: DropdownButton<NodeProfile?>(
value: session.profile,
hint: Text(locService.t('addNode.selectProfile')),
items: submittableProfiles
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
.toList(),
onChanged: (p) => appState.updateSession(profile: p),
),
trailing: _buildProfileDropdown(context, appState, session, submittableProfiles, locService),
),
// Direction controls
_buildDirectionControls(context, appState, session, locService),
@@ -336,6 +553,22 @@ class AddNodeSheet extends StatelessWidget {
),
],
),
)
else if (!hasGoodCoverage)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.cloud_download, color: Colors.blue, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('addNode.loadingAreaData'),
style: const TextStyle(color: Colors.blue, fontSize: 13),
),
),
],
),
),
const SizedBox(height: 8),
Padding(
@@ -365,8 +598,8 @@ class AddNodeSheet extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: allowSubmit ? _commit : null,
child: Text(locService.t('actions.submit')),
onPressed: !appState.isLoggedIn ? _navigateToLogin : (allowSubmit ? _commit : null),
child: Text(!appState.isLoggedIn ? locService.t('actions.logIn') : locService.t('actions.submit')),
),
),
],
@@ -374,6 +607,14 @@ class AddNodeSheet extends StatelessWidget {
),
const SizedBox(height: 20),
],
),
// Tutorial overlay - show only if tutorial should be shown and we're done checking
if (!_isCheckingTutorial && _showTutorial)
Positioned.fill(
child: PositioningTutorialOverlay(),
),
],
);
},
);

View File

@@ -1,24 +1,91 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../app_state.dart';
import '../dev_config.dart';
import '../models/node_profile.dart';
import '../models/operator_profile.dart';
import '../services/localization_service.dart';
import '../services/node_cache.dart';
import '../services/map_data_provider.dart';
import '../services/node_data_manager.dart';
import '../services/changelog_service.dart';
import '../state/settings_state.dart';
import 'refine_tags_sheet.dart';
import 'advanced_edit_options_sheet.dart';
import 'proximity_warning_dialog.dart';
import 'submission_guide_dialog.dart';
import 'positioning_tutorial_overlay.dart';
class EditNodeSheet extends StatelessWidget {
class EditNodeSheet extends StatefulWidget {
const EditNodeSheet({super.key, required this.session});
final EditNodeSession session;
@override
State<EditNodeSheet> createState() => _EditNodeSheetState();
}
class _EditNodeSheetState extends State<EditNodeSheet> {
bool _showTutorial = false;
bool _isCheckingTutorial = true;
@override
void initState() {
super.initState();
_checkTutorialStatus();
// Listen to node data manager for cache updates
NodeDataManager().addListener(_onCacheUpdated);
}
void _onCacheUpdated() {
// Rebuild when cache updates (e.g., when new data loads)
if (mounted) setState(() {});
}
Future<void> _checkTutorialStatus() async {
final hasCompleted = await ChangelogService().hasCompletedPositioningTutorial();
if (mounted) {
setState(() {
_showTutorial = !hasCompleted;
_isCheckingTutorial = false;
});
// If tutorial should be shown, register callback with AppState
if (_showTutorial) {
final appState = context.read<AppState>();
appState.registerTutorialCallback(_hideTutorial);
}
}
}
void _hideTutorial() {
if (mounted && _showTutorial) {
setState(() {
_showTutorial = false;
});
}
}
@override
@override
void dispose() {
// Remove listener
NodeDataManager().removeListener(_onCacheUpdated);
// Clear tutorial callback when widget is disposed
if (_showTutorial) {
try {
context.read<AppState>().clearTutorialCallback();
} catch (e) {
// Context might be unavailable during disposal, ignore
}
}
super.dispose();
}
void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) {
_checkSubmissionGuideAndProceed(context, appState, locService);
}
@@ -29,11 +96,16 @@ class EditNodeSheet extends StatelessWidget {
if (!hasSeenGuide) {
// Show submission guide dialog first
await showDialog<void>(
final shouldProceed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => const SubmissionGuideDialog(),
);
// If user canceled the submission guide, don't proceed with submission
if (shouldProceed != true) {
return;
}
}
// Now proceed with proximity check
@@ -42,10 +114,10 @@ class EditNodeSheet extends StatelessWidget {
void _checkProximityOnly(BuildContext context, AppState appState, LocalizationService locService) {
// Check for nearby nodes within the configured distance, excluding the node being edited
final nearbyNodes = NodeCache.instance.findNodesWithinDistance(
session.target,
final nearbyNodes = MapDataProvider().findNodesWithinDistance(
widget.session.target,
kNodeProximityWarningDistance,
excludeNodeId: session.originalNode.id,
excludeNodeId: widget.session.originalNode.id,
);
if (nearbyNodes.isNotEmpty) {
@@ -80,6 +152,15 @@ class EditNodeSheet extends StatelessWidget {
Widget _buildDirectionControls(BuildContext context, AppState appState, EditNodeSession session, LocalizationService locService) {
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
final is360Fov = session.profile?.fov == 360;
final enableDirectionControls = requiresDirection && !is360Fov;
// Force direction to 0 when FOV is 360 (omnidirectional)
if (is360Fov && session.directionDegrees != 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
appState.updateEditSession(directionDeg: 0);
});
}
// Format direction display text with bold for current direction
String directionsText = '';
@@ -129,7 +210,7 @@ class EditNodeSheet extends StatelessWidget {
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: requiresDirection ? (v) => appState.updateEditSession(directionDeg: v) : null,
onChanged: enableDirectionControls ? (v) => appState.updateEditSession(directionDeg: v) : null,
),
),
// Direction control buttons - always show but grey out when direction not required
@@ -139,9 +220,9 @@ class EditNodeSheet extends StatelessWidget {
icon: Icon(
Icons.remove,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
color: enableDirectionControls ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection && session.directions.length > 1
onPressed: enableDirectionControls && session.directions.length > 1
? () => appState.removeDirection()
: null,
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
@@ -153,9 +234,9 @@ class EditNodeSheet extends StatelessWidget {
icon: Icon(
Icons.add,
size: 20,
color: requiresDirection && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
color: enableDirectionControls && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection && session.directions.length < 8 ? () => appState.addDirection() : null,
onPressed: enableDirectionControls && session.directions.length < 8 ? () => appState.addDirection() : null,
tooltip: requiresDirection
? (session.directions.length >= 8 ? 'Maximum 8 directions allowed' : 'Add new direction')
: 'Direction not required for this profile',
@@ -167,9 +248,9 @@ class EditNodeSheet extends StatelessWidget {
icon: Icon(
Icons.repeat,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
color: enableDirectionControls ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection && session.directions.length > 1
onPressed: enableDirectionControls && session.directions.length > 1
? () => appState.cycleDirection()
: null,
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',
@@ -217,13 +298,40 @@ class EditNodeSheet extends StatelessWidget {
Navigator.pop(context);
}
final session = widget.session;
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final isSandboxMode = appState.uploadMode == UploadMode.sandbox;
// Check if we have good cache coverage around the node position
bool hasGoodCoverage = true;
final nodeCoord = session.originalNode.coord;
const double bufferDegrees = 0.001; // ~100m buffer
final targetBounds = LatLngBounds(
LatLng(nodeCoord.latitude - bufferDegrees, nodeCoord.longitude - bufferDegrees),
LatLng(nodeCoord.latitude + bufferDegrees, nodeCoord.longitude + bufferDegrees),
);
hasGoodCoverage = MapDataProvider().hasGoodCoverageFor(targetBounds);
// If strict coverage check fails, fall back to checking if we have any nodes nearby
// This handles the timing issue where cache might not be marked as "covered" yet
if (!hasGoodCoverage) {
final nearbyNodes = MapDataProvider().findNodesWithinDistance(
nodeCoord,
200.0, // 200m radius - if we have nodes nearby, we likely have good data
);
hasGoodCoverage = nearbyNodes.isNotEmpty;
}
final allowSubmit = kEnableNodeEdits &&
appState.isLoggedIn &&
submittableProfiles.isNotEmpty &&
session.profile != null &&
session.profile!.isSubmittable;
session.profile!.isSubmittable &&
hasGoodCoverage;
void _navigateToLogin() {
Navigator.pushNamed(context, '/settings/osm-account');
}
void _openRefineTags() async {
final result = await Navigator.push<RefineTagsResult?>(
@@ -245,7 +353,11 @@ class EditNodeSheet extends StatelessWidget {
}
}
return Column(
return Stack(
clipBehavior: Clip.none,
fit: StackFit.loose,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
@@ -265,14 +377,7 @@ class EditNodeSheet extends StatelessWidget {
const SizedBox(height: 16),
ListTile(
title: Text(locService.t('editNode.profile')),
trailing: DropdownButton<NodeProfile?>(
value: session.profile,
hint: Text(locService.t('editNode.selectProfile')),
items: submittableProfiles
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
.toList(),
onChanged: (p) => appState.updateEditSession(profile: p),
),
trailing: _buildProfileDropdown(context, appState, session, submittableProfiles, locService),
),
// Direction controls
_buildDirectionControls(context, appState, session, locService),
@@ -409,6 +514,22 @@ class EditNodeSheet extends StatelessWidget {
),
],
),
)
else if (!hasGoodCoverage)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.cloud_download, color: Colors.blue, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('editNode.loadingAreaData'),
style: const TextStyle(color: Colors.blue, fontSize: 13),
),
),
],
),
),
const SizedBox(height: 8),
Padding(
@@ -438,8 +559,8 @@ class EditNodeSheet extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: allowSubmit ? _commit : null,
child: Text(locService.t('actions.saveEdit')),
onPressed: !appState.isLoggedIn ? _navigateToLogin : (allowSubmit ? _commit : null),
child: Text(!appState.isLoggedIn ? locService.t('actions.logIn') : locService.t('actions.saveEdit')),
),
),
],
@@ -447,16 +568,118 @@ class EditNodeSheet extends StatelessWidget {
),
const SizedBox(height: 20),
],
),
// Tutorial overlay - show only if tutorial should be shown and we're done checking
if (!_isCheckingTutorial && _showTutorial)
Positioned.fill(
child: PositioningTutorialOverlay(),
),
],
);
},
);
}
Widget _buildProfileDropdown(BuildContext context, AppState appState, EditNodeSession session, List<NodeProfile> submittableProfiles, LocalizationService locService) {
return PopupMenuButton<String>(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
session.profile?.name ?? locService.t('editNode.selectProfile'),
style: TextStyle(
fontSize: 16,
color: session.profile != null ? null : Colors.grey.shade600,
),
),
const SizedBox(width: 4),
const Icon(Icons.arrow_drop_down, size: 20),
],
),
),
itemBuilder: (context) => [
// Regular profiles
...submittableProfiles.map(
(profile) => PopupMenuItem<String>(
value: 'profile_${profile.id}',
child: Text(profile.name),
),
),
// Divider
if (submittableProfiles.isNotEmpty) const PopupMenuDivider(),
// Get more... option
PopupMenuItem<String>(
value: 'get_more',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.language, size: 16),
const SizedBox(width: 8),
Text(
locService.t('profiles.getMore'),
style: const TextStyle(
fontStyle: FontStyle.italic,
),
),
],
),
),
],
onSelected: (value) {
if (value == 'get_more') {
_openIdentifyWebsite(context);
} else if (value.startsWith('profile_')) {
final profileId = value.substring(8); // Remove 'profile_' prefix
final profile = submittableProfiles.firstWhere((p) => p.id == profileId);
appState.updateEditSession(profile: profile);
}
},
);
}
void _openIdentifyWebsite(BuildContext context) async {
const url = 'https://deflock.me/identify';
try {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(
uri,
mode: LaunchMode.externalApplication, // Force external browser
);
} else {
if (context.mounted) {
_showErrorSnackBar(context, 'Unable to open website');
}
}
} catch (e) {
if (context.mounted) {
_showErrorSnackBar(context, 'Error opening website: $e');
}
}
}
void _showErrorSnackBar(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
}
void _openAdvancedEdit(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => AdvancedEditOptionsSheet(node: session.originalNode),
builder: (context) => AdvancedEditOptionsSheet(node: widget.session.originalNode),
);
}
}

View File

@@ -170,7 +170,8 @@ class DirectionConesBuilder {
bool isActiveDirection = true,
}) {
// Handle full circle case (360-degree FOV)
if (halfAngleDeg >= 180) {
// Use 179.5 threshold to account for floating point precision
if (halfAngleDeg >= 179.5) {
return _buildFullCircle(
origin: origin,
zoom: zoom,
@@ -232,6 +233,7 @@ class DirectionConesBuilder {
}
/// Build a full circle for 360-degree FOV cases
/// Returns just the outer circle - we'll handle the donut effect differently
static Polygon _buildFullCircle({
required LatLng origin,
required double zoom,
@@ -239,17 +241,15 @@ class DirectionConesBuilder {
bool isSession = false,
bool isActiveDirection = true,
}) {
// Calculate pixel-based radii
// Calculate pixel-based radii
final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength);
final innerRadiusPx = kNodeIconDiameter + (2 * getNodeRingThickness(context));
// Convert pixels to coordinate distances with zoom scaling
final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom);
final outerRadius = outerRadiusPx * pixelToCoordinate;
final innerRadius = innerRadiusPx * pixelToCoordinate;
// Create full circle with many points for smooth rendering
const int circlePoints = 36;
// Create simple filled circle - no donut complexity
const int circlePoints = 60;
final points = <LatLng>[];
LatLng project(double deg, double distance) {
@@ -260,17 +260,11 @@ class DirectionConesBuilder {
return LatLng(origin.latitude + dLat, origin.longitude + dLon);
}
// Add outer circle points
for (int i = 0; i < circlePoints; i++) {
final angle = i * 360.0 / circlePoints;
// Add outer circle points - simple complete circle
for (int i = 0; i <= circlePoints; i++) { // Note: <= to ensure closure
final angle = (i * 360.0 / circlePoints) % 360.0;
points.add(project(angle, outerRadius));
}
// Add inner circle points in reverse order to create donut
for (int i = circlePoints - 1; i >= 0; i--) {
final angle = i * 360.0 / circlePoints;
points.add(project(angle, innerRadius));
}
// Adjust opacity based on direction state
double opacity = kDirectionConeOpacity;

View File

@@ -10,156 +10,38 @@ import '../../services/proximity_alert_service.dart';
import '../../models/osm_node.dart';
import '../../models/node_profile.dart';
/// Manages GPS location tracking, follow-me modes, and location-based map animations.
/// Handles GPS permissions, position streams, and follow-me behavior.
/// Simple GPS controller that handles precise location permissions only.
/// Key principles:
/// - Respect "denied forever" - stop trying
/// - Retry "denied" - user might enable later
/// - Only works with precise location permissions
class GpsController {
StreamSubscription<Position>? _positionSub;
LatLng? _currentLatLng;
Timer? _retryTimer;
// Location state
LatLng? _currentLocation;
bool _hasLocation = false;
// Callbacks - set during initialization
AnimatedMapController? _mapController;
VoidCallback? _onLocationUpdated;
FollowMeMode Function()? _getCurrentFollowMeMode;
bool Function()? _getProximityAlertsEnabled;
int Function()? _getProximityAlertDistance;
List<OsmNode> Function()? _getNearbyNodes;
List<NodeProfile> Function()? _getEnabledProfiles;
VoidCallback? _onMapMovedProgrammatically;
/// Get the current GPS location (if available)
LatLng? get currentLocation => _currentLatLng;
LatLng? get currentLocation => _currentLocation;
/// Whether we currently have a valid GPS location
bool get hasLocation => _hasLocation;
/// Initialize GPS location tracking
Future<void> initializeLocation() async {
final perm = await Geolocator.requestPermission();
if (perm == LocationPermission.denied ||
perm == LocationPermission.deniedForever) {
debugPrint('[GpsController] Location permission denied');
return;
}
_positionSub = Geolocator.getPositionStream().listen((Position position) {
final latLng = LatLng(position.latitude, position.longitude);
_currentLatLng = latLng;
debugPrint('[GpsController] GPS position updated: ${latLng.latitude}, ${latLng.longitude}');
});
}
/// Retry location initialization (e.g., after permission granted)
Future<void> retryLocationInit() async {
debugPrint('[GpsController] Retrying location initialization');
await initializeLocation();
}
/// Handle follow-me mode changes and animate map accordingly
void handleFollowMeModeChange({
required FollowMeMode newMode,
required FollowMeMode oldMode,
required AnimatedMapController controller,
VoidCallback? onMapMovedProgrammatically,
}) {
debugPrint('[GpsController] Follow-me mode changed: $oldMode$newMode');
// Only act when follow-me is first enabled and we have a current location
if (newMode != FollowMeMode.off &&
oldMode == FollowMeMode.off &&
_currentLatLng != null) {
try {
if (newMode == FollowMeMode.follow) {
controller.animateTo(
dest: _currentLatLng!,
zoom: controller.mapController.camera.zoom,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
onMapMovedProgrammatically?.call();
} else if (newMode == FollowMeMode.rotating) {
// When switching to rotating mode, reset to north-up first
controller.animateTo(
dest: _currentLatLng!,
zoom: controller.mapController.camera.zoom,
rotation: 0.0,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
onMapMovedProgrammatically?.call();
}
} catch (e) {
debugPrint('[GpsController] MapController not ready for follow-me change: $e');
}
}
}
/// Process GPS position updates and handle follow-me animations
void processPositionUpdate({
required Position position,
required FollowMeMode followMeMode,
required AnimatedMapController controller,
required VoidCallback onLocationUpdated,
// Optional parameters for proximity alerts
bool proximityAlertsEnabled = false,
int proximityAlertDistance = 200,
List<OsmNode> nearbyNodes = const [],
List<NodeProfile> enabledProfiles = const [],
// Optional callback when map is moved programmatically
VoidCallback? onMapMovedProgrammatically,
}) {
final latLng = LatLng(position.latitude, position.longitude);
_currentLatLng = latLng;
// Notify that location was updated (for setState, etc.)
onLocationUpdated();
// Check proximity alerts if enabled
if (proximityAlertsEnabled && nearbyNodes.isNotEmpty) {
ProximityAlertService().checkProximity(
userLocation: latLng,
nodes: nearbyNodes,
enabledProfiles: enabledProfiles,
alertDistance: proximityAlertDistance,
);
}
// Handle follow-me animations if enabled - use current mode from app state
if (followMeMode != FollowMeMode.off) {
debugPrint('[GpsController] GPS position update: ${latLng.latitude}, ${latLng.longitude}, follow-me: $followMeMode');
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
if (followMeMode == FollowMeMode.follow) {
// Follow position only, keep current rotation
controller.animateTo(
dest: latLng,
zoom: controller.mapController.camera.zoom,
rotation: controller.mapController.camera.rotation,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
// Notify that we moved the map programmatically (for node refresh)
onMapMovedProgrammatically?.call();
} else if (followMeMode == FollowMeMode.rotating) {
// Follow position and rotation based on heading
final heading = position.heading;
final speed = position.speed; // Speed in m/s
// Only apply rotation if moving fast enough to avoid wild spinning when stationary
final shouldRotate = !speed.isNaN && speed >= kMinSpeedForRotationMps && !heading.isNaN;
final rotation = shouldRotate ? -heading : controller.mapController.camera.rotation;
controller.animateTo(
dest: latLng,
zoom: controller.mapController.camera.zoom,
rotation: rotation,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
// Notify that we moved the map programmatically (for node refresh)
onMapMovedProgrammatically?.call();
}
} catch (e) {
debugPrint('[GpsController] MapController not ready for position animation: $e');
}
});
}
}
/// Initialize GPS with custom position processing callback
Future<void> initializeWithCallback({
required FollowMeMode followMeMode,
required AnimatedMapController controller,
/// Initialize GPS tracking with callbacks
Future<void> initialize({
required AnimatedMapController mapController,
required VoidCallback onLocationUpdated,
required FollowMeMode Function() getCurrentFollowMeMode,
required bool Function() getProximityAlertsEnabled,
@@ -167,40 +49,308 @@ class GpsController {
required List<OsmNode> Function() getNearbyNodes,
required List<NodeProfile> Function() getEnabledProfiles,
VoidCallback? onMapMovedProgrammatically,
}) async {
final perm = await Geolocator.requestPermission();
if (perm == LocationPermission.denied ||
perm == LocationPermission.deniedForever) {
debugPrint('[GpsController] Location permission denied');
debugPrint('[GpsController] Initializing GPS controller');
// Store callbacks
_mapController = mapController;
_onLocationUpdated = onLocationUpdated;
_getCurrentFollowMeMode = getCurrentFollowMeMode;
_getProximityAlertsEnabled = getProximityAlertsEnabled;
_getProximityAlertDistance = getProximityAlertDistance;
_getNearbyNodes = getNearbyNodes;
_getEnabledProfiles = getEnabledProfiles;
_onMapMovedProgrammatically = onMapMovedProgrammatically;
// Start location tracking
await _startLocationTracking();
}
/// Update follow-me mode and restart tracking with appropriate frequency
void updateFollowMeMode({
required FollowMeMode newMode,
required FollowMeMode oldMode,
}) {
debugPrint('[GpsController] Follow-me mode changed: $oldMode$newMode');
// Restart position stream with new frequency settings
_restartPositionStream();
// Handle initial animation when follow-me is first enabled
_handleInitialFollowMeAnimation(newMode, oldMode);
}
/// Manual retry (e.g., user pressed follow-me button)
Future<void> retryLocationInit() async {
debugPrint('[GpsController] Manual retry of location initialization');
_cancelRetry();
await _startLocationTracking();
}
/// Start location tracking - checks permissions and starts stream
Future<void> _startLocationTracking() async {
_stopLocationTracking(); // Clean slate
// Check if location services are enabled
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
debugPrint('[GpsController] Location services disabled');
_hasLocation = false;
_notifyLocationChange();
_scheduleRetry();
return;
}
_positionSub = Geolocator.getPositionStream().listen((Position position) {
// Get the current follow-me mode from the app state each time
final currentFollowMeMode = getCurrentFollowMeMode();
final proximityAlertsEnabled = getProximityAlertsEnabled();
final proximityAlertDistance = getProximityAlertDistance();
final nearbyNodes = getNearbyNodes();
final enabledProfiles = getEnabledProfiles();
processPositionUpdate(
position: position,
followMeMode: currentFollowMeMode,
controller: controller,
onLocationUpdated: onLocationUpdated,
proximityAlertsEnabled: proximityAlertsEnabled,
proximityAlertDistance: proximityAlertDistance,
nearbyNodes: nearbyNodes,
enabledProfiles: enabledProfiles,
onMapMovedProgrammatically: onMapMovedProgrammatically,
// Check permissions
final permission = await Geolocator.requestPermission();
debugPrint('[GpsController] Location permission result: $permission');
switch (permission) {
case LocationPermission.deniedForever:
// User said "never" - respect that and stop trying
debugPrint('[GpsController] Location denied forever - stopping attempts');
_hasLocation = false;
_notifyLocationChange();
return;
case LocationPermission.denied:
// User said "not now" - keep trying later
debugPrint('[GpsController] Location denied - will retry later');
_hasLocation = false;
_notifyLocationChange();
_scheduleRetry();
return;
case LocationPermission.whileInUse:
case LocationPermission.always:
// Permission granted - start stream
debugPrint('[GpsController] Location permission granted: $permission');
_startPositionStream();
return;
case LocationPermission.unableToDetermine:
// Couldn't determine permission state - treat like denied and retry
debugPrint('[GpsController] Unable to determine permission state - will retry');
_hasLocation = false;
_notifyLocationChange();
_scheduleRetry();
return;
}
}
/// Start the GPS position stream
void _startPositionStream() {
final followMeMode = _getCurrentFollowMeMode?.call() ?? FollowMeMode.off;
final distanceFilter = followMeMode == FollowMeMode.off ? 5 : 1; // 5m normal, 1m follow-me
debugPrint('[GpsController] Starting GPS position stream (${distanceFilter}m filter)');
try {
_positionSub = Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.high, // Request best, accept what we get
distanceFilter: distanceFilter,
),
).listen(
_onPositionReceived,
onError: _onPositionError,
);
} catch (e) {
debugPrint('[GpsController] Failed to start position stream: $e');
_hasLocation = false;
_notifyLocationChange();
_scheduleRetry();
}
}
/// Restart position stream with current follow-me settings
void _restartPositionStream() {
if (_positionSub == null) {
// No active stream, let retry logic handle it
return;
}
debugPrint('[GpsController] Restarting position stream for follow-me mode change');
_stopLocationTracking();
_startPositionStream();
}
/// Handle incoming GPS position
void _onPositionReceived(Position position) {
final newLocation = LatLng(position.latitude, position.longitude);
_currentLocation = newLocation;
if (!_hasLocation) {
debugPrint('[GpsController] GPS location acquired');
}
_hasLocation = true;
_cancelRetry(); // Got location, stop any retry attempts
debugPrint('[GpsController] GPS position: ${newLocation.latitude}, ${newLocation.longitude}${position.accuracy}m)');
// Notify UI
_notifyLocationChange();
// Handle proximity alerts
_checkProximityAlerts(newLocation);
// Handle follow-me animations
_handleFollowMeUpdate(position, newLocation);
}
/// Handle GPS stream errors
void _onPositionError(dynamic error) {
debugPrint('[GpsController] Position stream error: $error');
if (_hasLocation) {
debugPrint('[GpsController] Lost GPS location - will retry');
}
_hasLocation = false;
_currentLocation = null;
_notifyLocationChange();
_scheduleRetry();
}
/// Check proximity alerts if enabled
void _checkProximityAlerts(LatLng userLocation) {
final proximityEnabled = _getProximityAlertsEnabled?.call() ?? false;
if (!proximityEnabled) return;
final nearbyNodes = _getNearbyNodes?.call() ?? [];
if (nearbyNodes.isEmpty) return;
final alertDistance = _getProximityAlertDistance?.call() ?? 200;
final enabledProfiles = _getEnabledProfiles?.call() ?? [];
ProximityAlertService().checkProximity(
userLocation: userLocation,
nodes: nearbyNodes,
enabledProfiles: enabledProfiles,
alertDistance: alertDistance,
);
}
/// Handle follow-me animations
void _handleFollowMeUpdate(Position position, LatLng location) {
final followMeMode = _getCurrentFollowMeMode?.call() ?? FollowMeMode.off;
if (followMeMode == FollowMeMode.off || _mapController == null) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
if (followMeMode == FollowMeMode.follow) {
// Follow position, preserve rotation
_mapController!.animateTo(
dest: location,
zoom: _mapController!.mapController.camera.zoom,
rotation: _mapController!.mapController.camera.rotation,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
} else if (followMeMode == FollowMeMode.rotating) {
// Follow position and heading
final heading = position.heading;
final speed = position.speed;
// Only rotate if moving fast enough and heading is valid
final shouldRotate = !speed.isNaN && speed >= kMinSpeedForRotationMps && !heading.isNaN;
final rotation = shouldRotate ? -heading : _mapController!.mapController.camera.rotation;
_mapController!.animateTo(
dest: location,
zoom: _mapController!.mapController.camera.zoom,
rotation: rotation,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
}
// Notify that map was moved programmatically
_onMapMovedProgrammatically?.call();
} catch (e) {
debugPrint('[GpsController] Map animation error: $e');
}
});
}
/// Dispose of GPS resources
void dispose() {
/// Handle initial animation when follow-me mode is enabled
void _handleInitialFollowMeAnimation(FollowMeMode newMode, FollowMeMode oldMode) {
if (newMode == FollowMeMode.off || oldMode != FollowMeMode.off) {
return; // Not enabling follow-me, or already enabled
}
if (_currentLocation == null || _mapController == null) {
return; // No location or map controller
}
try {
if (newMode == FollowMeMode.follow) {
_mapController!.animateTo(
dest: _currentLocation!,
zoom: _mapController!.mapController.camera.zoom,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
} else if (newMode == FollowMeMode.rotating) {
// Reset to north-up when starting rotating mode
_mapController!.animateTo(
dest: _currentLocation!,
zoom: _mapController!.mapController.camera.zoom,
rotation: 0.0,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
}
_onMapMovedProgrammatically?.call();
} catch (e) {
debugPrint('[GpsController] Initial follow-me animation error: $e');
}
}
/// Notify UI that location status changed
void _notifyLocationChange() {
_onLocationUpdated?.call();
}
/// Schedule retry attempts for location access
void _scheduleRetry() {
_cancelRetry();
_retryTimer = Timer.periodic(const Duration(seconds: 15), (timer) {
debugPrint('[GpsController] Retry attempt ${timer.tick}');
_startLocationTracking();
});
}
/// Cancel any pending retry attempts
void _cancelRetry() {
if (_retryTimer != null) {
debugPrint('[GpsController] Canceling retry timer');
_retryTimer?.cancel();
_retryTimer = null;
}
}
/// Stop the position stream
void _stopLocationTracking() {
_positionSub?.cancel();
_positionSub = null;
debugPrint('[GpsController] GPS controller disposed');
}
/// Clean up all resources
void dispose() {
debugPrint('[GpsController] Disposing GPS controller');
_stopLocationTracking();
_cancelRetry();
// Clear callbacks
_mapController = null;
_onLocationUpdated = null;
_getCurrentFollowMeMode = null;
_getProximityAlertsEnabled = null;
_getProximityAlertDistance = null;
_getNearbyNodes = null;
_getEnabledProfiles = null;
_onMapMovedProgrammatically = null;
}
}

View File

@@ -5,7 +5,7 @@ import 'package:latlong2/latlong.dart';
import '../../models/node_profile.dart';
import '../../app_state.dart' show UploadMode;
import '../../services/prefetch_area_service.dart';
import '../node_provider_with_cache.dart';
import '../../dev_config.dart';
@@ -44,8 +44,6 @@ class NodeRefreshController {
WidgetsBinding.instance.addPostFrameCallback((_) {
// 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)
_nodeProvider.refreshDisplay();
// Notify that profiles changed (triggers node refresh)

View File

@@ -7,7 +7,7 @@ import 'package:provider/provider.dart';
import '../app_state.dart' show AppState, FollowMeMode, UploadMode;
import '../services/offline_area_service.dart';
import '../services/network_status.dart';
import '../services/prefetch_area_service.dart';
import '../models/osm_node.dart';
import '../models/node_profile.dart';
import '../models/suspected_location.dart';
@@ -44,6 +44,7 @@ class MapView extends StatefulWidget {
this.onSuspectedLocationTap,
this.onSearchPressed,
this.onNodeLimitChanged,
this.onLocationStatusChanged,
});
final FollowMeMode followMeMode;
@@ -54,6 +55,7 @@ class MapView extends StatefulWidget {
final void Function(SuspectedLocation)? onSuspectedLocationTap;
final VoidCallback? onSearchPressed;
final void Function(bool isLimited)? onNodeLimitChanged;
final VoidCallback? onLocationStatusChanged;
@override
State<MapView> createState() => MapViewState();
@@ -118,10 +120,12 @@ class MapViewState extends State<MapView> {
});
// Initialize GPS with callback for position updates and follow-me
_gpsController.initializeWithCallback(
followMeMode: widget.followMeMode,
controller: _controller,
onLocationUpdated: () => setState(() {}),
_gpsController.initialize(
mapController: _controller,
onLocationUpdated: () {
setState(() {});
widget.onLocationStatusChanged?.call(); // Notify parent about location status change
},
getCurrentFollowMeMode: () {
// Use mounted check to avoid calling context when widget is disposed
if (mounted) {
@@ -190,7 +194,6 @@ class MapViewState extends State<MapView> {
// Refresh nodes when GPS controller moves the map
_refreshNodesFromProvider();
},
);
// Fetch initial cameras
@@ -211,7 +214,7 @@ class MapViewState extends State<MapView> {
_nodeController.dispose();
_tileManager.dispose();
_gpsController.dispose();
PrefetchAreaService().dispose();
// PrefetchAreaService no longer used - replaced with NodeDataManager
super.dispose();
}
@@ -230,6 +233,9 @@ class MapViewState extends State<MapView> {
LatLng? getUserLocation() {
return _gpsController.currentLocation;
}
/// Whether we currently have a valid GPS location
bool get hasLocation => _gpsController.hasLocation;
/// Expose static methods from MapPositionManager for external access
static Future<void> clearStoredMapPosition() =>
@@ -259,13 +265,9 @@ class MapViewState extends State<MapView> {
super.didUpdateWidget(oldWidget);
// Handle follow-me mode changes - only if it actually changed
if (widget.followMeMode != oldWidget.followMeMode) {
_gpsController.handleFollowMeModeChange(
_gpsController.updateFollowMeMode(
newMode: widget.followMeMode,
oldMode: oldWidget.followMeMode,
controller: _controller,
onMapMovedProgrammatically: () {
_refreshNodesFromProvider();
},
);
}
}

View File

@@ -4,7 +4,8 @@ import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import '../services/map_data_provider.dart';
import '../services/node_cache.dart';
import '../services/node_data_manager.dart';
import '../services/node_spatial_cache.dart';
import '../services/network_status.dart';
import '../models/node_profile.dart';
import '../models/osm_node.dart';
@@ -17,51 +18,47 @@ class NodeProviderWithCache extends ChangeNotifier {
factory NodeProviderWithCache() => instance;
NodeProviderWithCache._internal();
final NodeDataManager _nodeDataManager = NodeDataManager();
Timer? _debounceTimer;
/// Call this to get (quickly) all cached overlays for the given view.
/// Filters by currently enabled profiles only. Limiting is handled by MapView.
/// Get cached nodes for the given bounds, filtered by enabled profiles
List<OsmNode> getCachedNodesForBounds(LatLngBounds bounds) {
final allNodes = NodeCache.instance.queryByBounds(bounds);
// Use the same cache instance as NodeDataManager
final allNodes = NodeSpatialCache().getNodesFor(bounds);
final enabledProfiles = AppState.instance.enabledProfiles;
// If no profiles are enabled, show no nodes
if (enabledProfiles.isEmpty) return [];
// Filter nodes to only show those matching enabled profiles
// Note: This uses ALL enabled profiles for filtering, even though Overpass queries
// may be deduplicated for efficiency (broader profiles capture nodes for specific ones)
return allNodes.where((node) {
return _matchesAnyProfile(node, enabledProfiles);
}).toList();
}
/// Call this when the map view changes (bounds/profiles), triggers async fetch
/// and notifies listeners/UI when new data is available.
/// Fetch and update nodes for the given view, with debouncing for rapid map movement
void fetchAndUpdate({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
}) {
// Fast: serve cached immediately
// Serve cached immediately
notifyListeners();
// Debounce rapid panning/zooming
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 400), () async {
try {
// Use MapSource.auto to handle both offline and online modes appropriately
final fresh = await MapDataProvider().getNodes(
await _nodeDataManager.getNodesFor(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
source: MapSource.auto,
isUserInitiated: true,
);
if (fresh.isNotEmpty) {
NodeCache.instance.addOrUpdate(fresh);
// Clear waiting status when node data arrives
NetworkStatus.instance.clearWaiting();
notifyListeners();
}
// Notify UI of new data
notifyListeners();
} catch (e) {
debugPrint('[NodeProviderWithCache] Node fetch failed: $e');
// Cache already holds whatever is available for the view
@@ -71,7 +68,7 @@ class NodeProviderWithCache extends ChangeNotifier {
/// Clear the cache and repopulate with pending nodes from upload queue
void clearCache() {
NodeCache.instance.clear();
_nodeDataManager.clearCache();
// Repopulate with pending nodes from upload queue if available
_repopulatePendingNodesAfterClear();
notifyListeners();
@@ -79,12 +76,7 @@ class NodeProviderWithCache extends ChangeNotifier {
/// 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();
});
}

View File

@@ -38,6 +38,7 @@ class _NSITagValueFieldState extends State<NSITagValueField> {
_loadSuggestions();
_focusNode.addListener(_onFocusChanged);
_controller.addListener(_onTextChanged);
}
@override
@@ -65,6 +66,26 @@ class _NSITagValueFieldState extends State<NSITagValueField> {
super.dispose();
}
/// Get filtered suggestions based on current text input (case-sensitive)
List<String> _getFilteredSuggestions() {
final currentText = _controller.text;
if (currentText.isEmpty) {
return _suggestions;
}
return _suggestions
.where((suggestion) => suggestion.contains(currentText))
.toList();
}
/// Handle text changes to update suggestion filtering
void _onTextChanged() {
if (_showingSuggestions) {
// Update the overlay with filtered suggestions
_updateSuggestionsOverlay();
}
}
void _loadSuggestions() async {
if (widget.tagKey.trim().isEmpty) return;
@@ -86,7 +107,8 @@ class _NSITagValueFieldState extends State<NSITagValueField> {
}
void _onFocusChanged() {
if (_focusNode.hasFocus && _suggestions.isNotEmpty && !widget.readOnly) {
final filteredSuggestions = _getFilteredSuggestions();
if (_focusNode.hasFocus && filteredSuggestions.isNotEmpty && !widget.readOnly) {
_showSuggestions();
} else {
_hideSuggestions();
@@ -94,11 +116,38 @@ class _NSITagValueFieldState extends State<NSITagValueField> {
}
void _showSuggestions() {
if (_showingSuggestions || _suggestions.isEmpty) return;
final filteredSuggestions = _getFilteredSuggestions();
if (_showingSuggestions || filteredSuggestions.isEmpty) return;
_overlayEntry = OverlayEntry(
_overlayEntry = _buildSuggestionsOverlay(filteredSuggestions);
Overlay.of(context).insert(_overlayEntry);
setState(() {
_showingSuggestions = true;
});
}
/// Update the suggestions overlay with current filtered suggestions
void _updateSuggestionsOverlay() {
final filteredSuggestions = _getFilteredSuggestions();
if (filteredSuggestions.isEmpty) {
_hideSuggestions();
return;
}
if (_showingSuggestions) {
// Remove current overlay and create new one with filtered suggestions
_overlayEntry.remove();
_overlayEntry = _buildSuggestionsOverlay(filteredSuggestions);
Overlay.of(context).insert(_overlayEntry);
}
}
/// Build the suggestions overlay with the given suggestions list
OverlayEntry _buildSuggestionsOverlay(List<String> suggestions) {
return OverlayEntry(
builder: (context) => Positioned(
width: 200, // Fixed width for suggestions
width: 250, // Slightly wider to fit more content in refine tags
child: CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
@@ -111,9 +160,9 @@ class _NSITagValueFieldState extends State<NSITagValueField> {
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: _suggestions.length,
itemCount: suggestions.length,
itemBuilder: (context, index) {
final suggestion = _suggestions[index];
final suggestion = suggestions[index];
return ListTile(
dense: true,
title: Text(suggestion, style: const TextStyle(fontSize: 14)),
@@ -126,11 +175,6 @@ class _NSITagValueFieldState extends State<NSITagValueField> {
),
),
);
Overlay.of(context).insert(_overlayEntry);
setState(() {
_showingSuggestions = true;
});
}
void _hideSuggestions() {
@@ -150,6 +194,8 @@ class _NSITagValueFieldState extends State<NSITagValueField> {
@override
Widget build(BuildContext context) {
final filteredSuggestions = _getFilteredSuggestions();
return CompositedTransformTarget(
link: _layerLink,
child: TextField(
@@ -171,7 +217,7 @@ class _NSITagValueFieldState extends State<NSITagValueField> {
widget.onChanged(value);
},
onTap: () {
if (!widget.readOnly && _suggestions.isNotEmpty) {
if (!widget.readOnly && filteredSuggestions.isNotEmpty) {
_showSuggestions();
}
},

View File

@@ -0,0 +1,92 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import '../dev_config.dart';
import '../services/localization_service.dart';
/// Overlay that appears over add/edit node sheets to guide users through
/// the positioning tutorial. Shows a blurred background with tutorial text.
class PositioningTutorialOverlay extends StatelessWidget {
const PositioningTutorialOverlay({
super.key,
this.onFadeOutComplete,
});
/// Called when the fade-out animation completes (if animated)
final VoidCallback? onFadeOutComplete;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: kPositioningTutorialBlurSigma,
sigmaY: kPositioningTutorialBlurSigma,
),
child: Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.3), // Semi-transparent overlay
),
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Tutorial icon
Icon(
Icons.pan_tool_outlined,
size: 48,
color: Colors.white,
),
const SizedBox(height: 16),
// Tutorial title
Text(
locService.t('positioningTutorial.title'),
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
// Tutorial instructions
Text(
locService.t('positioningTutorial.instructions'),
style: const TextStyle(
color: Colors.white,
fontSize: 16,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
// Additional hint
Text(
locService.t('positioningTutorial.hint'),
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 14,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
],
),
),
),
),
),
);
},
);
}
}

View File

@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/localization_service.dart';
/// Dialog offering users a choice between creating a custom profile or importing from website
class ProfileAddChoiceDialog extends StatelessWidget {
const ProfileAddChoiceDialog({super.key});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
return AlertDialog(
title: Text(locService.t('profiles.addProfileChoice')),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(locService.t('profiles.addProfileChoiceMessage')),
const SizedBox(height: 16),
// Create custom profile option
Card(
child: ListTile(
leading: const Icon(Icons.add_circle_outline),
title: Text(locService.t('profiles.createCustomProfile')),
subtitle: Text(locService.t('profiles.createCustomProfileDescription')),
onTap: () => Navigator.of(context).pop('create'),
),
),
const SizedBox(height: 8),
// Import from website option
Card(
child: ListTile(
leading: const Icon(Icons.language),
title: Text(locService.t('profiles.importFromWebsite')),
subtitle: Text(locService.t('profiles.importFromWebsiteDescription')),
onTap: () => _openWebsite(context),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(locService.cancel),
),
],
);
},
);
}
void _openWebsite(BuildContext context) async {
const url = 'https://deflock.me/identify';
try {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(
uri,
mode: LaunchMode.externalApplication, // Force external browser
);
// Close dialog after opening website
if (context.mounted) {
Navigator.of(context).pop();
}
} else {
if (context.mounted) {
_showErrorSnackBar(context, 'Unable to open website');
}
}
} catch (e) {
if (context.mounted) {
_showErrorSnackBar(context, 'Error opening website: $e');
}
}
}
void _showErrorSnackBar(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
}
}

View File

@@ -5,7 +5,7 @@ import '../app_state.dart';
import '../models/operator_profile.dart';
import '../models/node_profile.dart';
import '../services/localization_service.dart';
import '../services/nsi_service.dart';
import 'nsi_tag_value_field.dart';
/// Result returned from RefineTagsSheet
class RefineTagsResult {
@@ -37,47 +37,12 @@ class RefineTagsSheet extends StatefulWidget {
class _RefineTagsSheetState extends State<RefineTagsSheet> {
OperatorProfile? _selectedOperatorProfile;
Map<String, String> _refinedTags = {};
Map<String, List<String>> _tagSuggestions = {};
Map<String, bool> _loadingSuggestions = {};
@override
void initState() {
super.initState();
_selectedOperatorProfile = widget.selectedOperatorProfile;
_refinedTags = Map<String, String>.from(widget.currentRefinedTags ?? {});
_loadTagSuggestions();
}
/// Load suggestions for all empty-value tags in the selected profile
void _loadTagSuggestions() async {
if (widget.selectedProfile == null) return;
final refinableTags = _getRefinableTags();
for (final tagKey in refinableTags) {
if (_tagSuggestions.containsKey(tagKey)) continue;
setState(() {
_loadingSuggestions[tagKey] = true;
});
try {
final suggestions = await NSIService().getAllSuggestions(tagKey);
if (mounted) {
setState(() {
_tagSuggestions[tagKey] = suggestions;
_loadingSuggestions[tagKey] = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_tagSuggestions[tagKey] = [];
_loadingSuggestions[tagKey] = false;
});
}
}
}
}
/// Get list of tag keys that have empty values and can be refined
@@ -262,11 +227,9 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
];
}
/// Build a dropdown for a single refineable tag
/// Build a text field for a single refineable tag (similar to profile editor)
Widget _buildTagDropdown(String tagKey, LocalizationService locService) {
final suggestions = _tagSuggestions[tagKey] ?? [];
final isLoading = _loadingSuggestions[tagKey] ?? false;
final currentValue = _refinedTags[tagKey];
final currentValue = _refinedTags[tagKey] ?? '';
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
@@ -278,58 +241,21 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
const SizedBox(height: 4),
if (isLoading)
const Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 8),
Text('Loading suggestions...', style: TextStyle(color: Colors.grey)),
],
)
else if (suggestions.isEmpty)
DropdownButtonFormField<String>(
value: currentValue?.isNotEmpty == true ? currentValue : null,
decoration: InputDecoration(
hintText: locService.t('refineTagsSheet.noSuggestions'),
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
items: const [],
onChanged: null, // Disabled when no suggestions
)
else
DropdownButtonFormField<String>(
value: currentValue?.isNotEmpty == true ? currentValue : null,
decoration: InputDecoration(
hintText: locService.t('refineTagsSheet.selectValue'),
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
items: [
DropdownMenuItem<String>(
value: null,
child: Text(locService.t('refineTagsSheet.noValue'),
style: const TextStyle(color: Colors.grey)),
),
...suggestions.map((suggestion) => DropdownMenuItem<String>(
value: suggestion,
child: Text(suggestion),
)),
],
onChanged: (value) {
setState(() {
if (value == null) {
_refinedTags.remove(tagKey);
} else {
_refinedTags[tagKey] = value;
}
});
},
),
NSITagValueField(
key: ValueKey('${tagKey}_refine'),
tagKey: tagKey,
initialValue: currentValue,
hintText: locService.t('refineTagsSheet.selectValue'),
onChanged: (value) {
setState(() {
if (value.trim().isEmpty) {
_refinedTags.remove(tagKey);
} else {
_refinedTags[tagKey] = value.trim();
}
});
},
),
],
),
);

View File

@@ -50,7 +50,7 @@ class _SubmissionGuideDialogState extends State<SubmissionGuideDialog> {
}
if (mounted) {
Navigator.of(context).pop();
Navigator.of(context).pop(true); // Return true to indicate "proceed with submission"
}
}
@@ -148,6 +148,13 @@ class _SubmissionGuideDialogState extends State<SubmissionGuideDialog> {
],
),
actions: [
TextButton(
onPressed: () {
// Cancel - just close dialog without marking as seen, return false to cancel submission
Navigator.of(context).pop(false);
},
child: Text(locService.cancel),
),
TextButton(
onPressed: _onClose,
child: Text(locService.t('submissionGuide.gotIt')),

View File

@@ -9,6 +9,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.3"
app_links:
dependency: "direct main"
description:
name: app_links
sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
app_links_linux:
dependency: transitive
description:
name: app_links_linux
sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
url: "https://pub.dev"
source: hosted
version: "1.0.3"
app_links_platform_interface:
dependency: transitive
description:
name: app_links_platform_interface
sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
app_links_web:
dependency: transitive
description:
name: app_links_web
sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
url: "https://pub.dev"
source: hosted
version: "1.0.4"
archive:
dependency: transitive
description:
@@ -347,6 +379,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.5"
gtk:
dependency: transitive
description:
name: gtk
sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c
url: "https://pub.dev"
source: hosted
version: "2.1.0"
html:
dependency: transitive
description:

View File

@@ -1,7 +1,7 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 2.1.1+34 # The thing after the + is the version code, incremented with each release
version: 2.6.0+44 # 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+
@@ -22,6 +22,7 @@ dependencies:
flutter_local_notifications: ^17.2.2
url_launcher: ^6.3.0
flutter_linkify: ^6.0.0
app_links: ^6.1.4
# Auth, storage, prefs
oauth2_client: ^4.2.0

View File

@@ -0,0 +1,91 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:deflockapp/models/osm_node.dart';
void main() {
group('OsmNode Direction Parsing', () {
test('should parse 360-degree FOV from X-X notation', () {
final node = OsmNode(
id: 1,
coord: const LatLng(0, 0),
tags: {'direction': '180-180'},
);
final directionFovPairs = node.directionFovPairs;
expect(directionFovPairs, hasLength(1));
expect(directionFovPairs[0].centerDegrees, equals(180.0));
expect(directionFovPairs[0].fovDegrees, equals(360.0));
});
test('should parse 360-degree FOV from 0-0 notation', () {
final node = OsmNode(
id: 1,
coord: const LatLng(0, 0),
tags: {'direction': '0-0'},
);
final directionFovPairs = node.directionFovPairs;
expect(directionFovPairs, hasLength(1));
expect(directionFovPairs[0].centerDegrees, equals(0.0));
expect(directionFovPairs[0].fovDegrees, equals(360.0));
});
test('should parse 360-degree FOV from 270-270 notation', () {
final node = OsmNode(
id: 1,
coord: const LatLng(0, 0),
tags: {'direction': '270-270'},
);
final directionFovPairs = node.directionFovPairs;
expect(directionFovPairs, hasLength(1));
expect(directionFovPairs[0].centerDegrees, equals(270.0));
expect(directionFovPairs[0].fovDegrees, equals(360.0));
});
test('should parse normal range notation correctly', () {
final node = OsmNode(
id: 1,
coord: const LatLng(0, 0),
tags: {'direction': '90-270'},
);
final directionFovPairs = node.directionFovPairs;
expect(directionFovPairs, hasLength(1));
expect(directionFovPairs[0].centerDegrees, equals(180.0));
expect(directionFovPairs[0].fovDegrees, equals(180.0));
});
test('should parse wrapping range notation correctly', () {
final node = OsmNode(
id: 1,
coord: const LatLng(0, 0),
tags: {'direction': '270-90'},
);
final directionFovPairs = node.directionFovPairs;
expect(directionFovPairs, hasLength(1));
expect(directionFovPairs[0].centerDegrees, equals(0.0));
expect(directionFovPairs[0].fovDegrees, equals(180.0));
});
test('should parse single direction correctly', () {
final node = OsmNode(
id: 1,
coord: const LatLng(0, 0),
tags: {'direction': '90'},
);
final directionFovPairs = node.directionFovPairs;
expect(directionFovPairs, hasLength(1));
expect(directionFovPairs[0].centerDegrees, equals(90.0));
// Default FOV from dev_config (kDirectionConeHalfAngle * 2)
});
});
}