mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-13 09:12:56 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89fb0d9bbd | ||
|
|
9db7c11a49 | ||
|
|
c3752fd17e | ||
|
|
aab4f6d445 | ||
|
|
f8643da8e2 | ||
|
|
45a72ede30 | ||
|
|
0c324fc78f | ||
|
|
42b5707d0e | ||
|
|
a941a5a5f0 | ||
|
|
6363cabacf |
28
README.md
28
README.md
@@ -98,27 +98,33 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
## Roadmap
|
||||
|
||||
### Needed Bugfixes
|
||||
- Add cancel button to submission guide
|
||||
- When not logged in, submit button should take users to settings>account to log in.
|
||||
- Ensure GPS/follow-me works after recent revamp (loses lock? have to move map for button state to update?)
|
||||
- Add new tags to top of a profile so they're visible immediately
|
||||
- Allow arbitrary entry on refine tags page
|
||||
- Don't show NSI suggestions that aren't sufficiently popular (image=)
|
||||
- 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?
|
||||
- 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, profiles (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
|
||||
- Offline navigation (pending vector map tiles)
|
||||
|
||||
### Future Features & Wishlist
|
||||
- Update offline area nodes while browsing?
|
||||
- Offline navigation (pending vector map tiles)
|
||||
- Android Auto / CarPlay
|
||||
- Optional reason message when deleting
|
||||
- Update offline area data while browsing?
|
||||
|
||||
### Maybes
|
||||
- Yellow ring for devices missing specific tag details
|
||||
- "Cache accumulating" offline area
|
||||
- "Offline areas" as tile provider
|
||||
- Grab the full latest database for each profile just like for suspected locations (instead of overpass)
|
||||
- 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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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**.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,10 +1,20 @@
|
||||
{
|
||||
"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-second updates vs 5-second)"
|
||||
"• Higher frequency GPS updates when follow-me modes are active for smoother tracking (1-meter updates vs 5-meter)"
|
||||
]
|
||||
},
|
||||
"2.1.2": {
|
||||
|
||||
@@ -137,6 +137,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)
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"ok": "OK",
|
||||
"close": "Schließen",
|
||||
"submit": "Senden",
|
||||
"logIn": "Anmelden",
|
||||
"saveEdit": "Bearbeitung Speichern",
|
||||
"clear": "Löschen",
|
||||
"viewOnOSM": "Auf OSM anzeigen",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"ok": "OK",
|
||||
"close": "Close",
|
||||
"submit": "Submit",
|
||||
"logIn": "Log In",
|
||||
"saveEdit": "Save Edit",
|
||||
"clear": "Clear",
|
||||
"viewOnOSM": "View on OSM",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"ok": "Aceptar",
|
||||
"close": "Cerrar",
|
||||
"submit": "Enviar",
|
||||
"logIn": "Iniciar Sesión",
|
||||
"saveEdit": "Guardar Edición",
|
||||
"clear": "Limpiar",
|
||||
"viewOnOSM": "Ver en OSM",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"ok": "OK",
|
||||
"close": "Fermer",
|
||||
"submit": "Soumettre",
|
||||
"logIn": "Se Connecter",
|
||||
"saveEdit": "Sauvegarder Modification",
|
||||
"clear": "Effacer",
|
||||
"viewOnOSM": "Voir sur OSM",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"ok": "OK",
|
||||
"close": "Chiudi",
|
||||
"submit": "Invia",
|
||||
"logIn": "Accedi",
|
||||
"saveEdit": "Salva Modifica",
|
||||
"clear": "Pulisci",
|
||||
"viewOnOSM": "Visualizza su OSM",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"ok": "OK",
|
||||
"close": "Fechar",
|
||||
"submit": "Enviar",
|
||||
"logIn": "Entrar",
|
||||
"saveEdit": "Salvar Edição",
|
||||
"clear": "Limpar",
|
||||
"viewOnOSM": "Ver no OSM",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"ok": "确定",
|
||||
"close": "关闭",
|
||||
"submit": "提交",
|
||||
"logIn": "登录",
|
||||
"saveEdit": "保存编辑",
|
||||
"clear": "清空",
|
||||
"viewOnOSM": "在OSM上查看",
|
||||
|
||||
@@ -126,7 +126,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')),
|
||||
),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,11 +96,16 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
|
||||
|
||||
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
|
||||
@@ -296,6 +301,10 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
|
||||
session.profile != null &&
|
||||
session.profile!.isSubmittable;
|
||||
|
||||
void _navigateToLogin() {
|
||||
Navigator.pushNamed(context, '/settings/osm-account');
|
||||
}
|
||||
|
||||
void _openRefineTags() async {
|
||||
final result = await Navigator.push<RefineTagsResult?>(
|
||||
context,
|
||||
@@ -439,8 +448,8 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
|
||||
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')),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -81,11 +81,16 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
|
||||
|
||||
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
|
||||
@@ -278,6 +283,10 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
|
||||
session.profile != null &&
|
||||
session.profile!.isSubmittable;
|
||||
|
||||
void _navigateToLogin() {
|
||||
Navigator.pushNamed(context, '/settings/osm-account');
|
||||
}
|
||||
|
||||
void _openRefineTags() async {
|
||||
final result = await Navigator.push<RefineTagsResult?>(
|
||||
context,
|
||||
@@ -495,8 +504,8 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
|
||||
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')),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -10,223 +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 respects permissions and provides location updates.
|
||||
/// Key principles:
|
||||
/// - Respect "denied forever" - stop trying
|
||||
/// - Retry "denied" - user might enable later
|
||||
/// - Accept whatever accuracy is available once granted
|
||||
class GpsController {
|
||||
StreamSubscription<Position>? _positionSub;
|
||||
LatLng? _currentLatLng;
|
||||
bool _hasLocation = false;
|
||||
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 {
|
||||
// Check if location services are enabled first
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
debugPrint('[GpsController] Location services disabled');
|
||||
_hasLocation = false;
|
||||
_scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
final perm = await Geolocator.requestPermission();
|
||||
debugPrint('[GpsController] Location permission result: $perm');
|
||||
|
||||
if (perm == LocationPermission.denied ||
|
||||
perm == LocationPermission.deniedForever) {
|
||||
debugPrint('[GpsController] Precise location permission denied, trying approximate location');
|
||||
|
||||
// Try approximate location as fallback
|
||||
try {
|
||||
await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.low,
|
||||
timeLimit: const Duration(seconds: 10),
|
||||
);
|
||||
debugPrint('[GpsController] Approximate location available, proceeding with location stream');
|
||||
// If we got here, approximate location works, continue with stream setup below
|
||||
} catch (e) {
|
||||
debugPrint('[GpsController] Approximate location also unavailable: $e');
|
||||
_hasLocation = false;
|
||||
_scheduleRetry();
|
||||
return;
|
||||
}
|
||||
} else if (perm == LocationPermission.whileInUse || perm == LocationPermission.always) {
|
||||
debugPrint('[GpsController] Location permission granted: $perm');
|
||||
// Permission is granted, continue with normal setup
|
||||
} else {
|
||||
debugPrint('[GpsController] Unexpected permission state: $perm');
|
||||
_hasLocation = false;
|
||||
_scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
_positionSub?.cancel(); // Cancel any existing subscription
|
||||
debugPrint('[GpsController] Starting GPS position stream');
|
||||
_positionSub = Geolocator.getPositionStream(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: 5, // Update when moved at least 5 meters (standard frequency)
|
||||
),
|
||||
).listen(
|
||||
(Position position) {
|
||||
final latLng = LatLng(position.latitude, position.longitude);
|
||||
_currentLatLng = latLng;
|
||||
if (!_hasLocation) {
|
||||
debugPrint('[GpsController] GPS location acquired');
|
||||
}
|
||||
_hasLocation = true;
|
||||
_cancelRetry(); // Got location, stop retrying
|
||||
debugPrint('[GpsController] GPS position updated: ${latLng.latitude}, ${latLng.longitude} (accuracy: ${position.accuracy}m)');
|
||||
},
|
||||
onError: (error) {
|
||||
debugPrint('[GpsController] Position stream error: $error');
|
||||
if (_hasLocation) {
|
||||
debugPrint('[GpsController] GPS location lost, starting retry attempts');
|
||||
}
|
||||
_hasLocation = false;
|
||||
_currentLatLng = null;
|
||||
_scheduleRetry(); // Lost location, start retrying
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Retry location initialization (e.g., after permission granted)
|
||||
Future<void> retryLocationInit() async {
|
||||
debugPrint('[GpsController] Manual retry of location initialization');
|
||||
_cancelRetry(); // Cancel automatic retries, this is a manual retry
|
||||
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');
|
||||
|
||||
// Restart position stream with appropriate frequency for new mode
|
||||
_restartPositionStream(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;
|
||||
_hasLocation = true;
|
||||
_cancelRetry(); // Got location, stop any retries
|
||||
|
||||
// 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,
|
||||
@@ -234,186 +49,308 @@ class GpsController {
|
||||
required List<OsmNode> Function() getNearbyNodes,
|
||||
required List<NodeProfile> Function() getEnabledProfiles,
|
||||
VoidCallback? onMapMovedProgrammatically,
|
||||
|
||||
}) async {
|
||||
// Check if location services are enabled first
|
||||
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;
|
||||
}
|
||||
|
||||
final perm = await Geolocator.requestPermission();
|
||||
debugPrint('[GpsController] Location permission result: $perm');
|
||||
// Check permissions
|
||||
final permission = await Geolocator.requestPermission();
|
||||
debugPrint('[GpsController] Location permission result: $permission');
|
||||
|
||||
if (perm == LocationPermission.denied ||
|
||||
perm == LocationPermission.deniedForever) {
|
||||
debugPrint('[GpsController] Precise location permission denied, trying approximate location');
|
||||
|
||||
// Try approximate location as fallback
|
||||
try {
|
||||
await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.low,
|
||||
timeLimit: const Duration(seconds: 10),
|
||||
);
|
||||
debugPrint('[GpsController] Approximate location available, proceeding with location stream');
|
||||
// If we got here, approximate location works, continue with stream setup below
|
||||
} catch (e) {
|
||||
debugPrint('[GpsController] Approximate location also unavailable: $e');
|
||||
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;
|
||||
}
|
||||
} else if (perm == LocationPermission.whileInUse || perm == LocationPermission.always) {
|
||||
debugPrint('[GpsController] Location permission granted: $perm');
|
||||
// Permission is granted, continue with normal setup
|
||||
} else {
|
||||
debugPrint('[GpsController] Unexpected permission state: $perm');
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
_positionSub?.cancel(); // Cancel any existing subscription
|
||||
debugPrint('[GpsController] Starting GPS position stream');
|
||||
_positionSub = Geolocator.getPositionStream(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: 5, // Update when moved at least 5 meters (standard frequency)
|
||||
),
|
||||
).listen(
|
||||
(Position position) {
|
||||
if (!_hasLocation) {
|
||||
debugPrint('[GpsController] GPS location acquired');
|
||||
}
|
||||
_hasLocation = true;
|
||||
_cancelRetry(); // Got location, stop retrying
|
||||
|
||||
// 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,
|
||||
);
|
||||
},
|
||||
onError: (error) {
|
||||
debugPrint('[GpsController] Position stream error: $error');
|
||||
if (_hasLocation) {
|
||||
debugPrint('[GpsController] GPS location lost, starting retry attempts');
|
||||
}
|
||||
_hasLocation = false;
|
||||
_currentLatLng = null;
|
||||
onLocationUpdated(); // Notify UI that location was lost
|
||||
_scheduleRetry(); // Lost location, start retrying
|
||||
},
|
||||
/// 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,
|
||||
);
|
||||
}
|
||||
|
||||
/// Schedule periodic retry attempts to get location
|
||||
void _scheduleRetry() {
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = Timer.periodic(const Duration(seconds: 15), (timer) {
|
||||
debugPrint('[GpsController] Automatic retry of location initialization (attempt ${timer.tick})');
|
||||
initializeLocation(); // This will cancel the timer if successful
|
||||
/// 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Cancel any scheduled retry attempts
|
||||
|
||||
/// 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 location retry timer');
|
||||
debugPrint('[GpsController] Canceling retry timer');
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart position stream with frequency optimized for follow-me mode
|
||||
void _restartPositionStream(FollowMeMode followMeMode) {
|
||||
if (_positionSub == null || !_hasLocation) {
|
||||
// No active stream or no location - let normal initialization handle it
|
||||
return;
|
||||
}
|
||||
|
||||
_positionSub?.cancel();
|
||||
|
||||
// Use higher frequency when follow-me is enabled
|
||||
if (followMeMode != FollowMeMode.off) {
|
||||
debugPrint('[GpsController] Starting high-frequency GPS updates for follow-me mode');
|
||||
_positionSub = Geolocator.getPositionStream(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: 1, // Update when moved at least 1 meter
|
||||
),
|
||||
).listen(
|
||||
(Position position) {
|
||||
final latLng = LatLng(position.latitude, position.longitude);
|
||||
_currentLatLng = latLng;
|
||||
if (!_hasLocation) {
|
||||
debugPrint('[GpsController] GPS location acquired');
|
||||
}
|
||||
_hasLocation = true;
|
||||
_cancelRetry(); // Got location, stop retrying
|
||||
debugPrint('[GpsController] GPS position updated: ${latLng.latitude}, ${latLng.longitude} (accuracy: ${position.accuracy}m)');
|
||||
},
|
||||
onError: (error) {
|
||||
debugPrint('[GpsController] Position stream error: $error');
|
||||
if (_hasLocation) {
|
||||
debugPrint('[GpsController] GPS location lost, starting retry attempts');
|
||||
}
|
||||
_hasLocation = false;
|
||||
_currentLatLng = null;
|
||||
_scheduleRetry(); // Lost location, start retrying
|
||||
},
|
||||
);
|
||||
} else {
|
||||
debugPrint('[GpsController] Starting standard-frequency GPS updates');
|
||||
_positionSub = Geolocator.getPositionStream(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: 5, // Update when moved at least 5 meters
|
||||
),
|
||||
).listen(
|
||||
(Position position) {
|
||||
final latLng = LatLng(position.latitude, position.longitude);
|
||||
_currentLatLng = latLng;
|
||||
if (!_hasLocation) {
|
||||
debugPrint('[GpsController] GPS location acquired');
|
||||
}
|
||||
_hasLocation = true;
|
||||
_cancelRetry(); // Got location, stop retrying
|
||||
debugPrint('[GpsController] GPS position updated: ${latLng.latitude}, ${latLng.longitude} (accuracy: ${position.accuracy}m)');
|
||||
},
|
||||
onError: (error) {
|
||||
debugPrint('[GpsController] Position stream error: $error');
|
||||
if (_hasLocation) {
|
||||
debugPrint('[GpsController] GPS location lost, starting retry attempts');
|
||||
}
|
||||
_hasLocation = false;
|
||||
_currentLatLng = null;
|
||||
_scheduleRetry(); // Lost location, start retrying
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispose of GPS resources
|
||||
void dispose() {
|
||||
/// Stop the position stream
|
||||
void _stopLocationTracking() {
|
||||
_positionSub?.cancel();
|
||||
_positionSub = null;
|
||||
}
|
||||
|
||||
/// Clean up all resources
|
||||
void dispose() {
|
||||
debugPrint('[GpsController] Disposing GPS controller');
|
||||
_stopLocationTracking();
|
||||
_cancelRetry();
|
||||
debugPrint('[GpsController] GPS controller disposed');
|
||||
|
||||
// Clear callbacks
|
||||
_mapController = null;
|
||||
_onLocationUpdated = null;
|
||||
_getCurrentFollowMeMode = null;
|
||||
_getProximityAlertsEnabled = null;
|
||||
_getProximityAlertDistance = null;
|
||||
_getNearbyNodes = null;
|
||||
_getEnabledProfiles = null;
|
||||
_onMapMovedProgrammatically = null;
|
||||
}
|
||||
}
|
||||
@@ -120,9 +120,8 @@ class MapViewState extends State<MapView> {
|
||||
});
|
||||
|
||||
// Initialize GPS with callback for position updates and follow-me
|
||||
_gpsController.initializeWithCallback(
|
||||
followMeMode: widget.followMeMode,
|
||||
controller: _controller,
|
||||
_gpsController.initialize(
|
||||
mapController: _controller,
|
||||
onLocationUpdated: () {
|
||||
setState(() {});
|
||||
widget.onLocationStatusChanged?.call(); // Notify parent about location status change
|
||||
@@ -195,7 +194,6 @@ class MapViewState extends State<MapView> {
|
||||
// Refresh nodes when GPS controller moves the map
|
||||
_refreshNodesFromProvider();
|
||||
},
|
||||
|
||||
);
|
||||
|
||||
// Fetch initial cameras
|
||||
@@ -267,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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: deflockapp
|
||||
description: Map public surveillance infrastructure with OpenStreetMap
|
||||
publish_to: "none"
|
||||
version: 2.1.3+36 # The thing after the + is the version code, incremented with each release
|
||||
version: 2.2.0+36 # 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+
|
||||
|
||||
Reference in New Issue
Block a user