mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-13 09:12:56 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eca227032d | ||
|
|
ca7bfc01ad | ||
|
|
4a4fc30828 | ||
|
|
e6b18bf89b | ||
|
|
6ed30dcff8 | ||
|
|
98e7e499d4 | ||
|
|
7fb467872a | ||
|
|
405ec220d0 |
12
DEVELOPER.md
12
DEVELOPER.md
@@ -284,13 +284,21 @@ These are internal app tags, not OSM tags. The underscore prefix makes this expl
|
||||
- **Rate limiting**: Extended backoff (30s), no splitting (would make it worse)
|
||||
- **Surgical detection**: Only splits on actual limit errors, not network issues
|
||||
|
||||
**Query optimization:**
|
||||
**Query optimization & deduplication:**
|
||||
- **Pre-fetch limit**: 4x user's display limit (e.g., 1000 nodes for 250 display limit)
|
||||
- **Profile deduplication**: Automatically removes redundant profiles from queries using subsumption analysis
|
||||
- **User-initiated detection**: Only reports loading status for user-facing operations
|
||||
- **Background operations**: Pre-fetch runs silently, doesn't trigger loading states
|
||||
|
||||
**Profile subsumption optimization (v2.1.1+):**
|
||||
To reduce Overpass query complexity, profiles are deduplicated before query generation:
|
||||
- **Subsumption rule**: Profile A subsumes profile B if all of A's non-empty tags exist in B with identical values
|
||||
- **Example**: `Generic ALPR` (tags: `man_made=surveillance, surveillance:type=ALPR`) subsumes `Flock` (same tags + `manufacturer=Flock Safety`)
|
||||
- **Result**: Default profile set reduces from ~11 to ~2 query clauses (Generic ALPR + Generic Gunshot)
|
||||
- **UI unchanged**: All enabled profiles still used for post-query filtering and display matching
|
||||
|
||||
**Why this approach:**
|
||||
Dense urban areas (SF, NYC) with many profiles enabled can easily exceed both 50k node limits and 25s timeouts. Splitting reduces query complexity while surgical error detection avoids unnecessary API load from network issues.
|
||||
Dense urban areas (SF, NYC) with many profiles enabled can easily exceed both 50k node limits and 25s timeouts. Profile deduplication reduces query complexity by ~80% for default setups, while automatic splitting handles remaining edge cases. Surgical error detection avoids unnecessary API load from network issues.
|
||||
|
||||
### 6. Uploader Service Architecture (Refactored v1.5.3)
|
||||
|
||||
|
||||
@@ -102,12 +102,11 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
|
||||
### Current Development
|
||||
- Optional reason message when deleting
|
||||
- Dropdown on "refine tags" page to select acceptable options for camera:mount= (is this a boolean property of a profile?)
|
||||
- Option to pull in profiles from NSI (man_made=surveillance only?)
|
||||
- Option to import profiles from deflock identify page?
|
||||
|
||||
### On Pause
|
||||
- Import/Export map providers
|
||||
- Clean cache when nodes have disappeared / been deleted by others / queue item was deleted
|
||||
- Import/Export map providers, profiles
|
||||
- Clean cache when nodes have been deleted by others
|
||||
- Improve offline area node refresh live display
|
||||
|
||||
### Future Features & Wishlist
|
||||
|
||||
166
V1.8.2_SHEET_POSITIONING_FIX.md
Normal file
166
V1.8.2_SHEET_POSITIONING_FIX.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# 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.
|
||||
69
V1.8.3_NODE_LIMIT_INDICATOR_FIX.md
Normal file
69
V1.8.3_NODE_LIMIT_INDICATOR_FIX.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# 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.
|
||||
46
V2.1.1_OVERPASS_QUERY_OPTIMIZATION.md
Normal file
46
V2.1.1_OVERPASS_QUERY_OPTIMIZATION.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 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,4 +1,25 @@
|
||||
{
|
||||
"2.1.0": {
|
||||
"content": [
|
||||
"• Profile tag refinement system - any profile tag with an empty value now shows a dropdown in refine tags",
|
||||
"• OSM Name Suggestion Index (NSI) integration - shows most commonly used tag values from TagInfo API, both when creating/editing profiles and refining tags",
|
||||
"• FIXED: Can now remove FOV values from profiles",
|
||||
"• FIXED: Profile deletion while add/edit sheets are open no longer causes a crash"
|
||||
]
|
||||
},
|
||||
"1.8.3": {
|
||||
"content": [
|
||||
"• Fixed node limit indicator disappearing when navigation sheet opens during search/routing",
|
||||
"• Improved indicator architecture - moved node limit indicator to screen coordinates for consistency with other UI overlays"
|
||||
]
|
||||
},
|
||||
"1.8.2": {
|
||||
"content": [
|
||||
"• Fixed map positioning for node tags and suspected location sheets - map now correctly centers above sheet when opened",
|
||||
"• Improved sheet transition coordination - prevents map bounce when transitioning from tag sheet to edit sheet",
|
||||
"• Enhanced debugging for sheet height measurement and coordination"
|
||||
]
|
||||
},
|
||||
"1.8.0": {
|
||||
"content": [
|
||||
"• Better performance and reduced memory usage when using suspected location data by using a database"
|
||||
|
||||
@@ -208,6 +208,9 @@ class AppState extends ChangeNotifier {
|
||||
await _operatorProfileState.init(addDefaults: shouldAddOperatorDefaults);
|
||||
await _profileState.init(addDefaults: shouldAddNodeDefaults);
|
||||
|
||||
// Set up callback to clear stale sessions when profiles are deleted
|
||||
_profileState.setProfileDeletedCallback(_onProfileDeleted);
|
||||
|
||||
// Mark defaults as initialized if this was first launch
|
||||
if (isFirstLaunch) {
|
||||
await prefs.setBool(firstLaunchKey, true);
|
||||
@@ -388,6 +391,19 @@ class AppState extends ChangeNotifier {
|
||||
void deleteProfile(NodeProfile p) {
|
||||
_profileState.deleteProfile(p);
|
||||
}
|
||||
|
||||
// Callback when a profile is deleted - clear any stale session references
|
||||
void _onProfileDeleted(NodeProfile deletedProfile) {
|
||||
// Clear add session if it references the deleted profile
|
||||
if (_sessionState.session?.profile?.id == deletedProfile.id) {
|
||||
cancelSession();
|
||||
}
|
||||
|
||||
// Clear edit session if it references the deleted profile
|
||||
if (_sessionState.editSession?.profile?.id == deletedProfile.id) {
|
||||
cancelEditSession();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Operator Profile Methods ----------
|
||||
void addOrUpdateOperatorProfile(OperatorProfile p) {
|
||||
@@ -412,12 +428,14 @@ class AppState extends ChangeNotifier {
|
||||
NodeProfile? profile,
|
||||
OperatorProfile? operatorProfile,
|
||||
LatLng? target,
|
||||
Map<String, String>? refinedTags,
|
||||
}) {
|
||||
_sessionState.updateSession(
|
||||
directionDeg: directionDeg,
|
||||
profile: profile,
|
||||
operatorProfile: operatorProfile,
|
||||
target: target,
|
||||
refinedTags: refinedTags,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -427,6 +445,7 @@ class AppState extends ChangeNotifier {
|
||||
OperatorProfile? operatorProfile,
|
||||
LatLng? target,
|
||||
bool? extractFromWay,
|
||||
Map<String, String>? refinedTags,
|
||||
}) {
|
||||
_sessionState.updateEditSession(
|
||||
directionDeg: directionDeg,
|
||||
@@ -434,6 +453,7 @@ class AppState extends ChangeNotifier {
|
||||
operatorProfile: operatorProfile,
|
||||
target: target,
|
||||
extractFromWay: extractFromWay,
|
||||
refinedTags: refinedTags,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -368,7 +368,12 @@
|
||||
"additionalTagsTitle": "Zusätzliche Tags",
|
||||
"noTagsDefinedForProfile": "Keine Tags für dieses Betreiber-Profil definiert.",
|
||||
"noOperatorProfiles": "Keine Betreiber-Profile definiert",
|
||||
"noOperatorProfilesMessage": "Erstellen Sie Betreiber-Profile in den Einstellungen, um zusätzliche Tags auf Ihre Knoten-Übertragungen anzuwenden."
|
||||
"noOperatorProfilesMessage": "Erstellen Sie Betreiber-Profile in den Einstellungen, um zusätzliche Tags auf Ihre Knoten-Übertragungen anzuwenden.",
|
||||
"profileTags": "Profil-Tags",
|
||||
"profileTagsDescription": "Geben Sie Werte für Tags an, die verfeinert werden müssen:",
|
||||
"selectValue": "Wert auswählen...",
|
||||
"noValue": "(Kein Wert)",
|
||||
"noSuggestions": "Keine Vorschläge verfügbar"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Kachel-Typen können während des Herunterladens von Offline-Bereichen nicht geändert werden",
|
||||
|
||||
@@ -400,7 +400,12 @@
|
||||
"additionalTagsTitle": "Additional Tags",
|
||||
"noTagsDefinedForProfile": "No tags defined for this operator profile.",
|
||||
"noOperatorProfiles": "No operator profiles defined",
|
||||
"noOperatorProfilesMessage": "Create operator profiles in Settings to apply additional tags to your node submissions."
|
||||
"noOperatorProfilesMessage": "Create operator profiles in Settings to apply additional tags to your node submissions.",
|
||||
"profileTags": "Profile Tags",
|
||||
"profileTagsDescription": "Complete these optional tag values for more detailed submissions:",
|
||||
"selectValue": "Select value...",
|
||||
"noValue": "(leave empty)",
|
||||
"noSuggestions": "No suggestions available"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Cannot change tile types while downloading offline areas",
|
||||
|
||||
@@ -400,7 +400,12 @@
|
||||
"additionalTagsTitle": "Etiquetas Adicionales",
|
||||
"noTagsDefinedForProfile": "No hay etiquetas definidas para este perfil de operador.",
|
||||
"noOperatorProfiles": "No hay perfiles de operador definidos",
|
||||
"noOperatorProfilesMessage": "Cree perfiles de operador en Configuración para aplicar etiquetas adicionales a sus envíos de nodos."
|
||||
"noOperatorProfilesMessage": "Cree perfiles de operador en Configuración para aplicar etiquetas adicionales a sus envíos de nodos.",
|
||||
"profileTags": "Etiquetas de Perfil",
|
||||
"profileTagsDescription": "Especifique valores para etiquetas que necesitan refinamiento:",
|
||||
"selectValue": "Seleccionar un valor...",
|
||||
"noValue": "(Sin valor)",
|
||||
"noSuggestions": "No hay sugerencias disponibles"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "No se pueden cambiar los tipos de teselas mientras se descargan áreas sin conexión",
|
||||
|
||||
@@ -400,7 +400,12 @@
|
||||
"additionalTagsTitle": "Étiquettes Supplémentaires",
|
||||
"noTagsDefinedForProfile": "Aucune étiquette définie pour ce profil d'opérateur.",
|
||||
"noOperatorProfiles": "Aucun profil d'opérateur défini",
|
||||
"noOperatorProfilesMessage": "Créez des profils d'opérateur dans les Paramètres pour appliquer des étiquettes supplémentaires à vos soumissions de nœuds."
|
||||
"noOperatorProfilesMessage": "Créez des profils d'opérateur dans les Paramètres pour appliquer des étiquettes supplémentaires à vos soumissions de nœuds.",
|
||||
"profileTags": "Étiquettes de Profil",
|
||||
"profileTagsDescription": "Spécifiez des valeurs pour les étiquettes qui nécessitent un raffinement :",
|
||||
"selectValue": "Sélectionner une valeur...",
|
||||
"noValue": "(Aucune valeur)",
|
||||
"noSuggestions": "Aucune suggestion disponible"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Impossible de changer les types de tuiles pendant le téléchargement des zones hors ligne",
|
||||
|
||||
@@ -400,7 +400,12 @@
|
||||
"additionalTagsTitle": "Tag Aggiuntivi",
|
||||
"noTagsDefinedForProfile": "Nessun tag definito per questo profilo operatore.",
|
||||
"noOperatorProfiles": "Nessun profilo operatore definito",
|
||||
"noOperatorProfilesMessage": "Crea profili operatore nelle Impostazioni per applicare tag aggiuntivi ai tuoi invii di nodi."
|
||||
"noOperatorProfilesMessage": "Crea profili operatore nelle Impostazioni per applicare tag aggiuntivi ai tuoi invii di nodi.",
|
||||
"profileTags": "Tag del Profilo",
|
||||
"profileTagsDescription": "Specificare valori per i tag che necessitano di raffinamento:",
|
||||
"selectValue": "Seleziona un valore...",
|
||||
"noValue": "(Nessun valore)",
|
||||
"noSuggestions": "Nessun suggerimento disponibile"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Impossibile cambiare tipi di tile durante il download di aree offline",
|
||||
|
||||
@@ -400,7 +400,12 @@
|
||||
"additionalTagsTitle": "Tags Adicionais",
|
||||
"noTagsDefinedForProfile": "Nenhuma tag definida para este perfil de operador.",
|
||||
"noOperatorProfiles": "Nenhum perfil de operador definido",
|
||||
"noOperatorProfilesMessage": "Crie perfis de operador nas Configurações para aplicar tags adicionais aos seus envios de nós."
|
||||
"noOperatorProfilesMessage": "Crie perfis de operador nas Configurações para aplicar tags adicionais aos seus envios de nós.",
|
||||
"profileTags": "Tags do Perfil",
|
||||
"profileTagsDescription": "Especifique valores para tags que precisam de refinamento:",
|
||||
"selectValue": "Selecionar um valor...",
|
||||
"noValue": "(Sem valor)",
|
||||
"noSuggestions": "Nenhuma sugestão disponível"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Não é possível alterar tipos de tiles durante o download de áreas offline",
|
||||
|
||||
@@ -400,7 +400,12 @@
|
||||
"additionalTagsTitle": "额外标签",
|
||||
"noTagsDefinedForProfile": "此运营商配置文件未定义标签。",
|
||||
"noOperatorProfiles": "未定义运营商配置文件",
|
||||
"noOperatorProfilesMessage": "在设置中创建运营商配置文件,以将额外标签应用于您的节点提交。"
|
||||
"noOperatorProfilesMessage": "在设置中创建运营商配置文件,以将额外标签应用于您的节点提交。",
|
||||
"profileTags": "配置文件标签",
|
||||
"profileTagsDescription": "为需要细化的标签指定值:",
|
||||
"selectValue": "选择值...",
|
||||
"noValue": "(无值)",
|
||||
"noSuggestions": "无建议可用"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "在下载离线区域时无法更改瓦片类型",
|
||||
|
||||
@@ -100,6 +100,21 @@ class OneTimeMigrations {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear any active sessions to reset refined tags system (v2.1.0)
|
||||
static Future<void> migrate_2_1_0(AppState appState) async {
|
||||
try {
|
||||
// Clear any existing sessions since they won't have refinedTags field
|
||||
// This is simpler and safer than trying to migrate session data
|
||||
appState.cancelSession();
|
||||
appState.cancelEditSession();
|
||||
|
||||
debugPrint('[Migration] 2.1.0 completed: cleared sessions for refined tags system');
|
||||
} catch (e) {
|
||||
debugPrint('[Migration] 2.1.0 ERROR: Failed to clear sessions: $e');
|
||||
// Don't rethrow - this is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the migration function for a specific version
|
||||
static Future<void> Function(AppState)? getMigrationForVersion(String version) {
|
||||
switch (version) {
|
||||
@@ -111,6 +126,8 @@ class OneTimeMigrations {
|
||||
return migrate_1_6_3;
|
||||
case '1.8.0':
|
||||
return migrate_1_8_0;
|
||||
case '2.1.0':
|
||||
return migrate_2_1_0;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ class NodeProfile {
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'camera:mount': '', // Empty value for refinement
|
||||
'manufacturer': 'Flock Safety',
|
||||
'manufacturer:wikidata': 'Q108485435',
|
||||
},
|
||||
@@ -62,6 +63,7 @@ class NodeProfile {
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'camera:mount': '', // Empty value for refinement
|
||||
'manufacturer': 'Motorola Solutions',
|
||||
'manufacturer:wikidata': 'Q634815',
|
||||
},
|
||||
@@ -79,6 +81,7 @@ class NodeProfile {
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'camera:mount': '', // Empty value for refinement
|
||||
'manufacturer': 'Genetec',
|
||||
'manufacturer:wikidata': 'Q30295174',
|
||||
},
|
||||
@@ -96,6 +99,7 @@ class NodeProfile {
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'camera:mount': '', // Empty value for refinement
|
||||
'manufacturer': 'Leonardo',
|
||||
'manufacturer:wikidata': 'Q910379',
|
||||
},
|
||||
@@ -113,6 +117,7 @@ class NodeProfile {
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'camera:mount': '', // Empty value for refinement
|
||||
'manufacturer': 'Neology, Inc.',
|
||||
},
|
||||
builtin: true,
|
||||
@@ -129,6 +134,7 @@ class NodeProfile {
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'camera:mount': '', // Empty value for refinement
|
||||
'manufacturer': 'Rekor',
|
||||
},
|
||||
builtin: true,
|
||||
@@ -145,6 +151,7 @@ class NodeProfile {
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'camera:mount': '', // Empty value for refinement
|
||||
'manufacturer': 'Axis Communications',
|
||||
'manufacturer:wikidata': 'Q2347731',
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ class PendingUpload {
|
||||
final dynamic direction; // Can be double or String for multiple directions
|
||||
final NodeProfile? profile;
|
||||
final OperatorProfile? operatorProfile;
|
||||
final Map<String, String> refinedTags; // User-selected values for empty profile tags
|
||||
final UploadMode uploadMode; // Capture upload destination when queued
|
||||
final UploadOperation operation; // Type of operation: create, modify, or delete
|
||||
final int? originalNodeId; // If this is modify/delete, the ID of the original OSM node
|
||||
@@ -43,6 +44,7 @@ class PendingUpload {
|
||||
required this.direction,
|
||||
this.profile,
|
||||
this.operatorProfile,
|
||||
Map<String, String>? refinedTags,
|
||||
required this.uploadMode,
|
||||
required this.operation,
|
||||
this.originalNodeId,
|
||||
@@ -59,7 +61,8 @@ class PendingUpload {
|
||||
this.lastChangesetCloseAttemptAt,
|
||||
this.nodeSubmissionAttempts = 0,
|
||||
this.lastNodeSubmissionAttemptAt,
|
||||
}) : assert(
|
||||
}) : refinedTags = refinedTags ?? {},
|
||||
assert(
|
||||
(operation == UploadOperation.create && originalNodeId == null) ||
|
||||
(operation == UploadOperation.create) || (originalNodeId != null),
|
||||
'originalNodeId must be null for create operations and non-null for modify/delete/extract operations'
|
||||
@@ -219,7 +222,7 @@ class PendingUpload {
|
||||
return DateTime.now().isAfter(nextRetryTime);
|
||||
}
|
||||
|
||||
// Get combined tags from node profile and operator profile
|
||||
// Get combined tags from node profile, operator profile, and refined tags
|
||||
Map<String, String> getCombinedTags() {
|
||||
// Deletions don't need tags
|
||||
if (operation == UploadOperation.delete || profile == null) {
|
||||
@@ -228,6 +231,14 @@ class PendingUpload {
|
||||
|
||||
final tags = Map<String, String>.from(profile!.tags);
|
||||
|
||||
// Apply refined tags (these fill in empty values from the profile)
|
||||
for (final entry in refinedTags.entries) {
|
||||
// Only apply refined tags if the profile tag value is empty
|
||||
if (tags.containsKey(entry.key) && tags[entry.key]?.trim().isEmpty == true) {
|
||||
tags[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Add operator profile tags (they override node profile tags if there are conflicts)
|
||||
if (operatorProfile != null) {
|
||||
tags.addAll(operatorProfile!.tags);
|
||||
@@ -244,6 +255,10 @@ class PendingUpload {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out any tags that are still empty after refinement
|
||||
// Empty tags in profiles are fine for refinement UI, but shouldn't be submitted to OSM
|
||||
tags.removeWhere((key, value) => value.trim().isEmpty);
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
@@ -253,6 +268,7 @@ class PendingUpload {
|
||||
'dir': direction,
|
||||
'profile': profile?.toJson(),
|
||||
'operatorProfile': operatorProfile?.toJson(),
|
||||
'refinedTags': refinedTags,
|
||||
'uploadMode': uploadMode.index,
|
||||
'operation': operation.index,
|
||||
'originalNodeId': originalNodeId,
|
||||
@@ -280,6 +296,9 @@ class PendingUpload {
|
||||
operatorProfile: j['operatorProfile'] != null
|
||||
? OperatorProfile.fromJson(j['operatorProfile'])
|
||||
: null,
|
||||
refinedTags: j['refinedTags'] != null
|
||||
? Map<String, String>.from(j['refinedTags'])
|
||||
: {}, // Default empty map for legacy entries
|
||||
uploadMode: j['uploadMode'] != null
|
||||
? UploadMode.values[j['uploadMode']]
|
||||
: UploadMode.production, // Default for legacy entries
|
||||
|
||||
@@ -239,12 +239,14 @@ class SheetCoordinator {
|
||||
|
||||
/// Update tag sheet height (called externally)
|
||||
void updateTagSheetHeight(double height, VoidCallback onStateChanged) {
|
||||
debugPrint('[SheetCoordinator] Updating tag sheet height: $_tagSheetHeight -> $height');
|
||||
_tagSheetHeight = height;
|
||||
onStateChanged();
|
||||
}
|
||||
|
||||
/// Reset tag sheet height
|
||||
void resetTagSheetHeight(VoidCallback onStateChanged) {
|
||||
debugPrint('[SheetCoordinator] Resetting tag sheet height from: $_tagSheetHeight');
|
||||
_tagSheetHeight = 0.0;
|
||||
onStateChanged();
|
||||
}
|
||||
|
||||
@@ -114,6 +114,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
|
||||
void _openEditNodeSheet() {
|
||||
// Set transition flag BEFORE closing tag sheet to prevent map bounce
|
||||
_sheetCoordinator.setTransitioningToEdit(true);
|
||||
|
||||
// Close any existing tag sheet first
|
||||
if (_sheetCoordinator.tagSheetHeight > 0) {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../widgets/nsi_tag_value_field.dart';
|
||||
|
||||
class OperatorProfileEditor extends StatefulWidget {
|
||||
const OperatorProfileEditor({super.key, required this.profile});
|
||||
@@ -123,14 +124,12 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: locService.t('profileEditor.valueHint'),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
controller: valueController,
|
||||
onChanged: (v) => _tags[i] = MapEntry(_tags[i].key, v),
|
||||
child: NSITagValueField(
|
||||
key: ValueKey('${_tags[i].key}_$i'), // Rebuild when key changes
|
||||
tagKey: _tags[i].key,
|
||||
initialValue: _tags[i].value,
|
||||
hintText: locService.t('profileEditor.valueHint'),
|
||||
onChanged: (v) => setState(() => _tags[i] = MapEntry(_tags[i].key, v)),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
@@ -155,8 +154,8 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
|
||||
final tagMap = <String, String>{};
|
||||
for (final e in _tags) {
|
||||
if (e.key.trim().isEmpty || e.value.trim().isEmpty) continue;
|
||||
tagMap[e.key.trim()] = e.value.trim();
|
||||
if (e.key.trim().isEmpty) continue; // Skip only if key is empty
|
||||
tagMap[e.key.trim()] = e.value.trim(); // Allow empty values for refinement
|
||||
}
|
||||
|
||||
final newProfile = widget.profile.copyWith(
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../widgets/nsi_tag_value_field.dart';
|
||||
|
||||
class ProfileEditor extends StatefulWidget {
|
||||
const ProfileEditor({super.key, required this.profile});
|
||||
@@ -175,17 +176,15 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: locService.t('profileEditor.valueHint'),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
controller: valueController,
|
||||
child: NSITagValueField(
|
||||
key: ValueKey('${_tags[i].key}_$i'), // Rebuild when key changes
|
||||
tagKey: _tags[i].key,
|
||||
initialValue: _tags[i].value,
|
||||
hintText: locService.t('profileEditor.valueHint'),
|
||||
readOnly: !widget.profile.editable,
|
||||
onChanged: !widget.profile.editable
|
||||
? null
|
||||
: (v) => _tags[i] = MapEntry(_tags[i].key, v),
|
||||
? (v) {} // No-op when read-only
|
||||
: (v) => setState(() => _tags[i] = MapEntry(_tags[i].key, v)),
|
||||
),
|
||||
),
|
||||
if (widget.profile.editable)
|
||||
@@ -231,8 +230,8 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
|
||||
final tagMap = <String, String>{};
|
||||
for (final e in _tags) {
|
||||
if (e.key.trim().isEmpty || e.value.trim().isEmpty) continue;
|
||||
tagMap[e.key.trim()] = e.value.trim();
|
||||
if (e.key.trim().isEmpty) continue; // Skip only if key is empty
|
||||
tagMap[e.key.trim()] = e.value.trim(); // Allow empty values for refinement
|
||||
}
|
||||
|
||||
if (tagMap.isEmpty) {
|
||||
|
||||
@@ -202,7 +202,11 @@ bool _nodeMatchesProfiles(Map<String, String> nodeTags, List<NodeProfile> profil
|
||||
/// Check if a node's tags match a specific profile
|
||||
bool _nodeMatchesProfile(Map<String, String> nodeTags, NodeProfile profile) {
|
||||
// All profile tags must be present in the node for it to match
|
||||
// Skip empty values as they are for refinement purposes only
|
||||
for (final entry in profile.tags.entries) {
|
||||
if (entry.value.trim().isEmpty) {
|
||||
continue; // Skip empty values - they don't need to match anything
|
||||
}
|
||||
if (nodeTags[entry.key] != entry.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -195,10 +195,21 @@ Future<List<OsmNode>> _fetchSingleOverpassQuery({
|
||||
/// Builds an Overpass API query for surveillance nodes matching the given profiles within bounds.
|
||||
/// Also fetches ways and relations that reference these nodes to determine constraint status.
|
||||
String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int maxResults) {
|
||||
// Build node clauses for each profile
|
||||
final nodeClauses = profiles.map((profile) {
|
||||
// Convert profile tags to Overpass filter format
|
||||
// Deduplicate profiles to reduce query complexity - broader profiles subsume more specific ones
|
||||
final deduplicatedProfiles = _deduplicateProfilesForQuery(profiles);
|
||||
|
||||
// Safety check: if deduplication removed all profiles (edge case), fall back to original list
|
||||
final profilesToQuery = deduplicatedProfiles.isNotEmpty ? deduplicatedProfiles : profiles;
|
||||
|
||||
if (deduplicatedProfiles.length < profiles.length) {
|
||||
debugPrint('[Overpass] Deduplicated ${profiles.length} profiles to ${deduplicatedProfiles.length} for query efficiency');
|
||||
}
|
||||
|
||||
// Build node clauses for deduplicated profiles only
|
||||
final nodeClauses = profilesToQuery.map((profile) {
|
||||
// Convert profile tags to Overpass filter format, excluding empty values
|
||||
final tagFilters = profile.tags.entries
|
||||
.where((entry) => entry.value.trim().isNotEmpty) // Skip empty values
|
||||
.map((entry) => '["${entry.key}"="${entry.value}"]')
|
||||
.join();
|
||||
|
||||
@@ -220,6 +231,68 @@ out meta;
|
||||
''';
|
||||
}
|
||||
|
||||
/// Deduplicate profiles for Overpass queries by removing profiles that are subsumed by others.
|
||||
/// A profile A subsumes profile B if all of A's non-empty tags exist in B with identical values.
|
||||
/// This optimization reduces query complexity while returning the same nodes (since broader
|
||||
/// profiles capture all nodes that more specific profiles would).
|
||||
List<NodeProfile> _deduplicateProfilesForQuery(List<NodeProfile> profiles) {
|
||||
if (profiles.length <= 1) return profiles;
|
||||
|
||||
final result = <NodeProfile>[];
|
||||
|
||||
for (final candidate in profiles) {
|
||||
// Skip profiles that only have empty tags - they would match everything and break queries
|
||||
final candidateNonEmptyTags = candidate.tags.entries
|
||||
.where((entry) => entry.value.trim().isNotEmpty)
|
||||
.toList();
|
||||
|
||||
if (candidateNonEmptyTags.isEmpty) continue;
|
||||
|
||||
// Check if any existing profile in our result subsumes this candidate
|
||||
bool isSubsumed = false;
|
||||
for (final existing in result) {
|
||||
if (_profileSubsumes(existing, candidate)) {
|
||||
isSubsumed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSubsumed) {
|
||||
// This candidate is not subsumed, so add it
|
||||
// But first, remove any existing profiles that this candidate subsumes
|
||||
result.removeWhere((existing) => _profileSubsumes(candidate, existing));
|
||||
result.add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Check if broaderProfile subsumes specificProfile.
|
||||
/// Returns true if all non-empty tags in broaderProfile exist in specificProfile with identical values.
|
||||
bool _profileSubsumes(NodeProfile broaderProfile, NodeProfile specificProfile) {
|
||||
// Get non-empty tags from both profiles
|
||||
final broaderTags = Map.fromEntries(
|
||||
broaderProfile.tags.entries.where((entry) => entry.value.trim().isNotEmpty)
|
||||
);
|
||||
final specificTags = Map.fromEntries(
|
||||
specificProfile.tags.entries.where((entry) => entry.value.trim().isNotEmpty)
|
||||
);
|
||||
|
||||
// If broader has no non-empty tags, it doesn't subsume anything (would match everything)
|
||||
if (broaderTags.isEmpty) return false;
|
||||
|
||||
// If broader has more non-empty tags than specific, it can't subsume
|
||||
if (broaderTags.length > specificTags.length) return false;
|
||||
|
||||
// Check if all broader tags exist in specific with same values
|
||||
for (final entry in broaderTags.entries) {
|
||||
if (specificTags[entry.key] != entry.value) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Split a LatLngBounds into 4 quadrants (NW, NE, SW, SE).
|
||||
List<LatLngBounds> _splitBounds(LatLngBounds bounds) {
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
|
||||
132
lib/services/nsi_service.dart
Normal file
132
lib/services/nsi_service.dart
Normal file
@@ -0,0 +1,132 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../app_state.dart';
|
||||
|
||||
/// Service for fetching tag value suggestions from OpenStreetMap Name Suggestion Index
|
||||
class NSIService {
|
||||
static final NSIService _instance = NSIService._();
|
||||
factory NSIService() => _instance;
|
||||
NSIService._();
|
||||
|
||||
static const String _userAgent = 'DeFlock/2.1.0 (OSM surveillance mapping app)';
|
||||
static const Duration _timeout = Duration(seconds: 10);
|
||||
|
||||
// Cache to avoid repeated API calls
|
||||
final Map<String, List<String>> _suggestionCache = {};
|
||||
|
||||
/// Get suggested values for a given OSM tag key
|
||||
/// Returns a list of the most commonly used values, or empty list if none found
|
||||
Future<List<String>> getSuggestionsForTag(String tagKey) async {
|
||||
if (tagKey.trim().isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final cacheKey = tagKey.trim().toLowerCase();
|
||||
|
||||
// Return cached results if available
|
||||
if (_suggestionCache.containsKey(cacheKey)) {
|
||||
return _suggestionCache[cacheKey]!;
|
||||
}
|
||||
|
||||
try {
|
||||
final suggestions = await _fetchSuggestionsForTag(tagKey);
|
||||
_suggestionCache[cacheKey] = suggestions;
|
||||
return suggestions;
|
||||
} catch (e) {
|
||||
debugPrint('[NSIService] Failed to fetch suggestions for $tagKey: $e');
|
||||
// Cache empty result to avoid repeated failures
|
||||
_suggestionCache[cacheKey] = [];
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch tag value suggestions from TagInfo API
|
||||
Future<List<String>> _fetchSuggestionsForTag(String tagKey) async {
|
||||
final uri = Uri.parse('https://taginfo.openstreetmap.org/api/4/key/values')
|
||||
.replace(queryParameters: {
|
||||
'key': tagKey,
|
||||
'format': 'json',
|
||||
'sortname': 'count',
|
||||
'sortorder': 'desc',
|
||||
'page': '1',
|
||||
'rp': '15', // Get top 15 most commonly used values
|
||||
});
|
||||
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: {'User-Agent': _userAgent},
|
||||
).timeout(_timeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('TagInfo API returned status ${response.statusCode}');
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final values = data['data'] as List<dynamic>? ?? [];
|
||||
|
||||
// Extract the most commonly used values
|
||||
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)) {
|
||||
suggestions.add(value.trim());
|
||||
}
|
||||
}
|
||||
|
||||
// Limit to top 10 suggestions for UI performance
|
||||
if (suggestions.length >= 10) break;
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/// Filter out common unwanted values that appear in TagInfo but aren't useful suggestions
|
||||
bool _isValidSuggestion(String value) {
|
||||
final lowercaseValue = value.toLowerCase();
|
||||
|
||||
// Filter out obvious non-useful values
|
||||
final unwanted = {
|
||||
'yes', 'no', 'unknown', '?', 'null', 'none', 'n/a', 'na',
|
||||
'todo', 'fixme', 'check', 'verify', 'test', 'temp', 'temporary'
|
||||
};
|
||||
|
||||
if (unwanted.contains(lowercaseValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out very short generic values (except single letters that might be valid)
|
||||
if (value.length == 1 && !RegExp(r'[A-Z]').hasMatch(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Get suggestions for a tag key - returns empty list when offline mode enabled
|
||||
Future<List<String>> getAllSuggestions(String tagKey) async {
|
||||
// Check if app is in offline mode
|
||||
if (AppState.instance.offlineMode) {
|
||||
debugPrint('[NSIService] Offline mode enabled - no suggestions available for $tagKey');
|
||||
return []; // No suggestions when in offline mode - user must input manually
|
||||
}
|
||||
|
||||
// Online mode: try to get suggestions from API
|
||||
try {
|
||||
return await getSuggestionsForTag(tagKey);
|
||||
} catch (e) {
|
||||
debugPrint('[NSIService] API call failed: $e');
|
||||
return []; // No fallback - just return empty list
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the suggestion cache (useful for testing or memory management)
|
||||
void clearCache() {
|
||||
_suggestionCache.clear();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,13 @@ class ProfileState extends ChangeNotifier {
|
||||
|
||||
final List<NodeProfile> _profiles = [];
|
||||
final Set<NodeProfile> _enabled = {};
|
||||
|
||||
// Callback for when a profile is deleted (used to clear stale sessions)
|
||||
void Function(NodeProfile)? _onProfileDeleted;
|
||||
|
||||
void setProfileDeletedCallback(void Function(NodeProfile) callback) {
|
||||
_onProfileDeleted = callback;
|
||||
}
|
||||
|
||||
// Getters
|
||||
List<NodeProfile> get profiles => List.unmodifiable(_profiles);
|
||||
@@ -78,6 +85,10 @@ class ProfileState extends ChangeNotifier {
|
||||
}
|
||||
_saveEnabledProfiles();
|
||||
ProfileService().save(_profiles);
|
||||
|
||||
// Notify about profile deletion so other parts can clean up
|
||||
_onProfileDeleted?.call(p);
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -12,14 +12,17 @@ class AddNodeSession {
|
||||
LatLng? target;
|
||||
List<double> directions; // All directions [90, 180, 270]
|
||||
int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°)
|
||||
Map<String, String> refinedTags; // User-selected values for empty profile tags
|
||||
|
||||
AddNodeSession({
|
||||
this.profile,
|
||||
double initialDirection = 0,
|
||||
this.operatorProfile,
|
||||
this.target,
|
||||
Map<String, String>? refinedTags,
|
||||
}) : directions = [initialDirection],
|
||||
currentDirectionIndex = 0;
|
||||
currentDirectionIndex = 0,
|
||||
refinedTags = refinedTags ?? {};
|
||||
|
||||
// Slider always shows the current direction being edited
|
||||
double get directionDegrees => directions[currentDirectionIndex];
|
||||
@@ -35,6 +38,7 @@ class EditNodeSession {
|
||||
List<double> directions; // All directions [90, 180, 270]
|
||||
int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°)
|
||||
bool extractFromWay; // True if user wants to extract this constrained node
|
||||
Map<String, String> refinedTags; // User-selected values for empty profile tags
|
||||
|
||||
EditNodeSession({
|
||||
required this.originalNode,
|
||||
@@ -42,8 +46,10 @@ class EditNodeSession {
|
||||
required double initialDirection,
|
||||
required this.target,
|
||||
this.extractFromWay = false,
|
||||
Map<String, String>? refinedTags,
|
||||
}) : directions = [initialDirection],
|
||||
currentDirectionIndex = 0;
|
||||
currentDirectionIndex = 0,
|
||||
refinedTags = refinedTags ?? {};
|
||||
|
||||
// Slider always shows the current direction being edited
|
||||
double get directionDegrees => directions[currentDirectionIndex];
|
||||
@@ -112,6 +118,7 @@ class SessionState extends ChangeNotifier {
|
||||
NodeProfile? profile,
|
||||
OperatorProfile? operatorProfile,
|
||||
LatLng? target,
|
||||
Map<String, String>? refinedTags,
|
||||
}) {
|
||||
if (_session == null) return;
|
||||
|
||||
@@ -132,6 +139,10 @@ class SessionState extends ChangeNotifier {
|
||||
_session!.target = target;
|
||||
dirty = true;
|
||||
}
|
||||
if (refinedTags != null) {
|
||||
_session!.refinedTags = Map<String, String>.from(refinedTags);
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) notifyListeners();
|
||||
}
|
||||
|
||||
@@ -141,6 +152,7 @@ class SessionState extends ChangeNotifier {
|
||||
OperatorProfile? operatorProfile,
|
||||
LatLng? target,
|
||||
bool? extractFromWay,
|
||||
Map<String, String>? refinedTags,
|
||||
}) {
|
||||
if (_editSession == null) return;
|
||||
|
||||
@@ -174,6 +186,10 @@ class SessionState extends ChangeNotifier {
|
||||
}
|
||||
dirty = true;
|
||||
}
|
||||
if (refinedTags != null) {
|
||||
_editSession!.refinedTags = Map<String, String>.from(refinedTags);
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
if (dirty) notifyListeners();
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
direction: _formatDirectionsForSubmission(session.directions, session.profile),
|
||||
profile: session.profile!, // Safe to use ! because commitSession() checks for null
|
||||
operatorProfile: session.operatorProfile,
|
||||
refinedTags: session.refinedTags,
|
||||
uploadMode: uploadMode,
|
||||
operation: UploadOperation.create,
|
||||
);
|
||||
@@ -180,6 +181,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
direction: _formatDirectionsForSubmission(session.directions, session.profile),
|
||||
profile: session.profile!, // Safe to use ! because commitEditSession() checks for null
|
||||
operatorProfile: session.operatorProfile,
|
||||
refinedTags: session.refinedTags,
|
||||
uploadMode: uploadMode,
|
||||
operation: operation,
|
||||
originalNodeId: session.originalNode.id, // Track which node we're editing
|
||||
|
||||
@@ -227,17 +227,22 @@ class AddNodeSheet extends StatelessWidget {
|
||||
session.profile!.isSubmittable;
|
||||
|
||||
void _openRefineTags() async {
|
||||
final result = await Navigator.push<OperatorProfile?>(
|
||||
final result = await Navigator.push<RefineTagsResult?>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RefineTagsSheet(
|
||||
selectedOperatorProfile: session.operatorProfile,
|
||||
selectedProfile: session.profile,
|
||||
currentRefinedTags: session.refinedTags,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
if (result != session.operatorProfile) {
|
||||
appState.updateSession(operatorProfile: result);
|
||||
if (result != null) {
|
||||
appState.updateSession(
|
||||
operatorProfile: result.operatorProfile,
|
||||
refinedTags: result.refinedTags,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -226,17 +226,22 @@ class EditNodeSheet extends StatelessWidget {
|
||||
session.profile!.isSubmittable;
|
||||
|
||||
void _openRefineTags() async {
|
||||
final result = await Navigator.push<OperatorProfile?>(
|
||||
final result = await Navigator.push<RefineTagsResult?>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RefineTagsSheet(
|
||||
selectedOperatorProfile: session.operatorProfile,
|
||||
selectedProfile: session.profile,
|
||||
currentRefinedTags: session.refinedTags,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
if (result != session.operatorProfile) {
|
||||
appState.updateEditSession(operatorProfile: result);
|
||||
if (result != null) {
|
||||
appState.updateEditSession(
|
||||
operatorProfile: result.operatorProfile,
|
||||
refinedTags: result.refinedTags,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ class _NodeMapMarkerState extends State<NodeMapMarker> {
|
||||
if (widget.onNodeTap != null) {
|
||||
widget.onNodeTap!(widget.node);
|
||||
} else {
|
||||
// Fallback: This should not happen if callbacks are properly provided,
|
||||
// but if it does, at least open the sheet (without map coordination)
|
||||
debugPrint('[NodeMapMarker] Warning: onNodeTap callback not provided, using fallback');
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (_) => NodeTagSheet(node: widget.node),
|
||||
|
||||
@@ -40,6 +40,9 @@ class _SuspectedLocationMapMarkerState extends State<SuspectedLocationMapMarker>
|
||||
if (widget.onLocationTap != null) {
|
||||
widget.onLocationTap!(widget.location);
|
||||
} else {
|
||||
// Fallback: This should not happen if callbacks are properly provided,
|
||||
// but if it does, at least open the sheet (without map coordination)
|
||||
debugPrint('[SuspectedLocationMapMarker] Warning: onLocationTap callback not provided, using fallback');
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (_) => SuspectedLocationSheet(location: widget.location),
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../app_state.dart' show AppState, FollowMeMode, UploadMode;
|
||||
import '../services/offline_area_service.dart';
|
||||
import '../services/network_status.dart';
|
||||
import '../services/prefetch_area_service.dart';
|
||||
@@ -28,7 +28,6 @@ import 'network_status_indicator.dart';
|
||||
import 'node_limit_indicator.dart';
|
||||
import 'proximity_alert_banner.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../app_state.dart' show FollowMeMode;
|
||||
import '../services/proximity_alert_service.dart';
|
||||
import 'sheet_aware_map.dart';
|
||||
|
||||
@@ -249,6 +248,11 @@ class MapViewState extends State<MapView> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Calculate search bar offset for screen-positioned indicators
|
||||
double _calculateScreenIndicatorSearchOffset(AppState appState) {
|
||||
return (!appState.offlineMode && appState.isInSearchMode) ? 60.0 : 0.0;
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MapView oldWidget) {
|
||||
@@ -370,23 +374,6 @@ class MapViewState extends State<MapView> {
|
||||
children: [
|
||||
...overlayLayers,
|
||||
markerLayer,
|
||||
|
||||
// Node limit indicator (top-left) - shown when limit is active
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final appState = context.read<AppState>();
|
||||
// Add search bar offset when search bar is visible
|
||||
final searchBarOffset = (!appState.offlineMode && appState.isInSearchMode) ? 60.0 : 0.0;
|
||||
|
||||
return NodeLimitIndicator(
|
||||
isActive: nodeData.isLimitActive,
|
||||
renderedCount: nodeData.nodesToRender.length,
|
||||
totalCount: nodeData.validNodesCount,
|
||||
top: 8.0 + searchBarOffset,
|
||||
left: 8.0,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -540,12 +527,28 @@ class MapViewState extends State<MapView> {
|
||||
onSearchPressed: widget.onSearchPressed,
|
||||
),
|
||||
|
||||
// Node limit indicator (top-left) - shown when limit is active
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final searchBarOffset = _calculateScreenIndicatorSearchOffset(appState);
|
||||
|
||||
return NodeLimitIndicator(
|
||||
isActive: nodeData.isLimitActive,
|
||||
renderedCount: nodeData.nodesToRender.length,
|
||||
totalCount: nodeData.validNodesCount,
|
||||
top: 8.0 + searchBarOffset,
|
||||
left: 8.0,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Network status indicator (top-left) - conditionally shown
|
||||
if (appState.networkStatusIndicatorEnabled)
|
||||
Builder(
|
||||
builder: (context) {
|
||||
// Calculate position based on node limit indicator presence and search bar
|
||||
final searchBarOffset = (!appState.offlineMode && appState.isInSearchMode) ? 60.0 : 0.0;
|
||||
final appState = context.watch<AppState>();
|
||||
final searchBarOffset = _calculateScreenIndicatorSearchOffset(appState);
|
||||
final nodeLimitOffset = nodeData.isLimitActive ? 48.0 : 0.0; // Height of node limit indicator + spacing
|
||||
|
||||
return NetworkStatusIndicator(
|
||||
|
||||
@@ -29,6 +29,8 @@ class NodeProviderWithCache extends ChangeNotifier {
|
||||
if (enabledProfiles.isEmpty) return [];
|
||||
|
||||
// Filter nodes to only show those matching enabled profiles
|
||||
// Note: This uses ALL enabled profiles for filtering, even though Overpass queries
|
||||
// may be deduplicated for efficiency (broader profiles capture nodes for specific ones)
|
||||
return allNodes.where((node) {
|
||||
return _matchesAnyProfile(node, enabledProfiles);
|
||||
}).toList();
|
||||
@@ -107,9 +109,12 @@ class NodeProviderWithCache extends ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Check if a node matches a specific profile (all profile tags must match)
|
||||
/// Check if a node matches a specific profile (all non-empty profile tags must match)
|
||||
bool _nodeMatchesProfile(OsmNode node, NodeProfile profile) {
|
||||
for (final entry in profile.tags.entries) {
|
||||
// Skip empty values - they are used for refinement UI, not matching
|
||||
if (entry.value.trim().isEmpty) continue;
|
||||
|
||||
if (node.tags[entry.key] != entry.value) return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
181
lib/widgets/nsi_tag_value_field.dart
Normal file
181
lib/widgets/nsi_tag_value_field.dart
Normal file
@@ -0,0 +1,181 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/nsi_service.dart';
|
||||
|
||||
/// A text field that provides NSI suggestions for OSM tag values
|
||||
class NSITagValueField extends StatefulWidget {
|
||||
const NSITagValueField({
|
||||
super.key,
|
||||
required this.tagKey,
|
||||
required this.initialValue,
|
||||
required this.onChanged,
|
||||
this.readOnly = false,
|
||||
this.hintText,
|
||||
});
|
||||
|
||||
final String tagKey;
|
||||
final String initialValue;
|
||||
final ValueChanged<String> onChanged;
|
||||
final bool readOnly;
|
||||
final String? hintText;
|
||||
|
||||
@override
|
||||
State<NSITagValueField> createState() => _NSITagValueFieldState();
|
||||
}
|
||||
|
||||
class _NSITagValueFieldState extends State<NSITagValueField> {
|
||||
late TextEditingController _controller;
|
||||
List<String> _suggestions = [];
|
||||
bool _showingSuggestions = false;
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
late OverlayEntry _overlayEntry;
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialValue);
|
||||
_loadSuggestions();
|
||||
|
||||
_focusNode.addListener(_onFocusChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(NSITagValueField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// If the tag key changed, reload suggestions
|
||||
if (oldWidget.tagKey != widget.tagKey) {
|
||||
_hideSuggestions(); // Hide old suggestions immediately
|
||||
_suggestions.clear();
|
||||
_loadSuggestions(); // Load new suggestions for new key
|
||||
}
|
||||
|
||||
// If the initial value changed, update the controller
|
||||
if (oldWidget.initialValue != widget.initialValue) {
|
||||
_controller.text = widget.initialValue;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
_hideSuggestions();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadSuggestions() async {
|
||||
if (widget.tagKey.trim().isEmpty) return;
|
||||
|
||||
try {
|
||||
final suggestions = await NSIService().getAllSuggestions(widget.tagKey);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_suggestions = suggestions.take(10).toList(); // Limit to 10 suggestions
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail - field still works as regular text field
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_suggestions = [];
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onFocusChanged() {
|
||||
if (_focusNode.hasFocus && _suggestions.isNotEmpty && !widget.readOnly) {
|
||||
_showSuggestions();
|
||||
} else {
|
||||
_hideSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
void _showSuggestions() {
|
||||
if (_showingSuggestions || _suggestions.isEmpty) return;
|
||||
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (context) => Positioned(
|
||||
width: 200, // Fixed width for suggestions
|
||||
child: CompositedTransformFollower(
|
||||
link: _layerLink,
|
||||
showWhenUnlinked: false,
|
||||
offset: const Offset(0.0, 35.0), // Below the text field
|
||||
child: Material(
|
||||
elevation: 4.0,
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
shrinkWrap: true,
|
||||
itemCount: _suggestions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final suggestion = _suggestions[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text(suggestion, style: const TextStyle(fontSize: 14)),
|
||||
onTap: () => _selectSuggestion(suggestion),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_overlayEntry);
|
||||
setState(() {
|
||||
_showingSuggestions = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _hideSuggestions() {
|
||||
if (!_showingSuggestions) return;
|
||||
|
||||
_overlayEntry.remove();
|
||||
setState(() {
|
||||
_showingSuggestions = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _selectSuggestion(String suggestion) {
|
||||
_controller.text = suggestion;
|
||||
widget.onChanged(suggestion);
|
||||
_hideSuggestions();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
readOnly: widget.readOnly,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hintText,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
suffixIcon: _suggestions.isNotEmpty && !widget.readOnly
|
||||
? Icon(
|
||||
Icons.arrow_drop_down,
|
||||
color: _showingSuggestions ? Theme.of(context).primaryColor : Colors.grey,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: widget.readOnly ? null : (value) {
|
||||
widget.onChanged(value);
|
||||
},
|
||||
onTap: () {
|
||||
if (!widget.readOnly && _suggestions.isNotEmpty) {
|
||||
_showSuggestions();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,32 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/nsi_service.dart';
|
||||
|
||||
/// Result returned from RefineTagsSheet
|
||||
class RefineTagsResult {
|
||||
final OperatorProfile? operatorProfile;
|
||||
final Map<String, String> refinedTags;
|
||||
|
||||
RefineTagsResult({
|
||||
required this.operatorProfile,
|
||||
required this.refinedTags,
|
||||
});
|
||||
}
|
||||
|
||||
class RefineTagsSheet extends StatefulWidget {
|
||||
const RefineTagsSheet({
|
||||
super.key,
|
||||
this.selectedOperatorProfile,
|
||||
this.selectedProfile,
|
||||
this.currentRefinedTags,
|
||||
});
|
||||
|
||||
final OperatorProfile? selectedOperatorProfile;
|
||||
final NodeProfile? selectedProfile;
|
||||
final Map<String, String>? currentRefinedTags;
|
||||
|
||||
@override
|
||||
State<RefineTagsSheet> createState() => _RefineTagsSheetState();
|
||||
@@ -19,11 +36,58 @@ 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
|
||||
List<String> _getRefinableTags() {
|
||||
if (widget.selectedProfile == null) return [];
|
||||
|
||||
return widget.selectedProfile!.tags.entries
|
||||
.where((entry) => entry.value.trim().isEmpty)
|
||||
.map((entry) => entry.key)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -37,11 +101,17 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
|
||||
title: Text(locService.t('refineTagsSheet.title')),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context, widget.selectedOperatorProfile),
|
||||
onPressed: () => Navigator.pop(context, RefineTagsResult(
|
||||
operatorProfile: widget.selectedOperatorProfile,
|
||||
refinedTags: widget.currentRefinedTags ?? {},
|
||||
)),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, _selectedOperatorProfile),
|
||||
onPressed: () => Navigator.pop(context, RefineTagsResult(
|
||||
operatorProfile: _selectedOperatorProfile,
|
||||
refinedTags: _refinedTags,
|
||||
)),
|
||||
child: Text(locService.t('refineTagsSheet.done')),
|
||||
),
|
||||
],
|
||||
@@ -152,6 +222,114 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
|
||||
),
|
||||
],
|
||||
],
|
||||
// Add refineable tags section
|
||||
..._buildRefinableTagsSection(locService),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build the section for refineable tags (empty-value profile tags)
|
||||
List<Widget> _buildRefinableTagsSection(LocalizationService locService) {
|
||||
final refinableTags = _getRefinableTags();
|
||||
if (refinableTags.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
locService.t('refineTagsSheet.profileTags'),
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('refineTagsSheet.profileTagsDescription'),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...refinableTags.map((tagKey) => _buildTagDropdown(tagKey, locService)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Build a dropdown for a single refineable tag
|
||||
Widget _buildTagDropdown(String tagKey, LocalizationService locService) {
|
||||
final suggestions = _tagSuggestions[tagKey] ?? [];
|
||||
final isLoading = _loadingSuggestions[tagKey] ?? false;
|
||||
final currentValue = _refinedTags[tagKey];
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tagKey,
|
||||
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;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -29,6 +29,8 @@ class SheetAwareMap extends StatelessWidget {
|
||||
// Use the actual available height from constraints, not full screen height
|
||||
final availableHeight = constraints.maxHeight;
|
||||
|
||||
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: deflockapp
|
||||
description: Map public surveillance infrastructure with OpenStreetMap
|
||||
publish_to: "none"
|
||||
version: 1.8.1+31 # The thing after the + is the version code, incremented with each release
|
||||
version: 2.1.1+34 # 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