mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-13 09:12:56 +00:00
Compare commits
18 Commits
v2.3.1-rel
...
v2.6.0-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6707efebbe | ||
|
|
69ebd43e0d | ||
|
|
79d2fe711d | ||
|
|
4a36c52982 | ||
|
|
f478a3eb2d | ||
|
|
9621e5f35a | ||
|
|
f048ebc7db | ||
|
|
33ae6473bb | ||
|
|
0957670a15 | ||
|
|
3fc3a72cde | ||
|
|
1d65d5ecca | ||
|
|
1873d6e768 | ||
|
|
4638a18887 | ||
|
|
6bfdfadd97 | ||
|
|
72f3c9ee79 | ||
|
|
05e2e4e7c6 | ||
|
|
2e679c9a7e | ||
|
|
3ef053126b |
@@ -809,7 +809,8 @@ cd ios && pod install
|
||||
### Running
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter run
|
||||
./gen_icons_splashes.sh
|
||||
flutter run --dart-define=OSM_PROD_CLIENT_ID=[your OAuth2 client ID]
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
20
README.md
20
README.md
@@ -53,6 +53,12 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
||||
- **Queue management**: Review, edit, retry, or cancel pending uploads
|
||||
- **Changeset tracking**: Automatic grouping and commenting for organized contributions
|
||||
|
||||
### Profile Import & Sharing
|
||||
- **Deep link support**: Import custom profiles via `deflockapp://profiles/add?p=<base64>` URLs
|
||||
- **Website integration**: Generate profile import links from [deflock.me](https://deflock.me)
|
||||
- **Pre-filled editor**: Imported profiles open in the profile editor for review and modification
|
||||
- **Seamless workflow**: Edit imported profiles like any custom profile before saving
|
||||
|
||||
### Offline Operations
|
||||
- **Smart area downloads**: Automatically calculate tile counts and storage requirements
|
||||
- **Device caching**: Offline areas include surveillance device data for complete functionality without network
|
||||
@@ -98,23 +104,27 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
## Roadmap
|
||||
|
||||
### Needed Bugfixes
|
||||
- Ensure GPS/follow-me works after recent revamp (loses lock? have to move map for button state to update?)
|
||||
- Make submission guide scarier
|
||||
- Node data fetching super slow; retries not working?
|
||||
- Tile cache trimming? Does fluttermap handle?
|
||||
- Filter NSI suggestions based on what has already been typed in
|
||||
- NSI sometimes doesn't populate a dropdown, maybe always on the second tag added during an edit session?
|
||||
- Clean cache when nodes have been deleted by others
|
||||
- Are offline areas preferred for fast loading even when online? Check working.
|
||||
|
||||
### Current Development
|
||||
- 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
|
||||
- Offline navigation (pending vector map tiles)
|
||||
- Import/Export map providers
|
||||
|
||||
### Future Features & Wishlist
|
||||
- Optional reason message when deleting
|
||||
- Update offline area data while browsing?
|
||||
- Save named locations to more easily navigate to home or work
|
||||
- Offline navigation (pending vector map tiles)
|
||||
|
||||
### Maybes
|
||||
- "Universal Links" for better handling of profile import when app not installed?
|
||||
- Yellow ring for devices missing specific tag details
|
||||
- Android Auto / CarPlay
|
||||
- "Cache accumulating" offline area?
|
||||
|
||||
@@ -35,6 +35,14 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
|
||||
<!-- Profile import deep links -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="deflockapp" android:host="profiles"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- flutter_web_auth_2 callback activity (V2 embedding) -->
|
||||
|
||||
@@ -1,8 +1,45 @@
|
||||
{
|
||||
"2.6.0": {
|
||||
"content": [
|
||||
"• Fix slow node loading, offline node loading",
|
||||
"• Prevent submissions when we have no data in that area"
|
||||
]
|
||||
},
|
||||
"2.5.0": {
|
||||
"content": [
|
||||
"• NEW: 'Get more...' button in profile dropdowns - easily browse and import profiles from deflock.me/identify",
|
||||
"• NEW: Profile creation choice dialog - when adding profiles in settings, choose between creating custom profiles or importing from website",
|
||||
"• Enhanced profile discovery workflow - clearer path for users to find and import community-created profiles"
|
||||
]
|
||||
},
|
||||
"2.4.4": {
|
||||
"content": [
|
||||
"• Search results now prioritize locations near your current map view"
|
||||
]
|
||||
},
|
||||
"2.4.3": {
|
||||
"content": [
|
||||
"• Fixed 360° FOV rendering - devices with full circle coverage now render as complete rings instead of having a wedge cut out or being a line",
|
||||
"• Fixed 360° FOV submission - now correctly submits '0-360' to OpenStreetMap instead of incorrect '180-180' values, disables direction slider"
|
||||
]
|
||||
},
|
||||
"2.4.1": {
|
||||
"content": [
|
||||
"• Save button moved to top-right corner of profile editor screens",
|
||||
"• Fixed issue where FOV values could not be removed from profiles",
|
||||
"• Direction slider is now disabled for profiles with 360° FOV"
|
||||
]
|
||||
},
|
||||
"2.4.0": {
|
||||
"content": [
|
||||
"• Profile import from website links",
|
||||
"• Visit deflock.me for profile links to auto-populate custom profiles"
|
||||
]
|
||||
},
|
||||
"2.3.1": {
|
||||
"content": [
|
||||
"• Follow-me mode now automatically restores when add/edit/tag sheets are closed",
|
||||
"• Follow-me button is greyed out while node sheets are open (add/edit/tag) since following doesn't make sense during node operations"
|
||||
"• Follow-me button is greyed out while node sheets are open (add/edit/tag) since following doesn't make sense during node operations",
|
||||
"• Drop support for approximate location since I can't get it to work reliably; apologies"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -14,10 +15,12 @@ import 'models/suspected_location.dart';
|
||||
import 'models/tile_provider.dart';
|
||||
import 'models/search_result.dart';
|
||||
import 'services/offline_area_service.dart';
|
||||
import 'services/node_cache.dart';
|
||||
import 'services/map_data_provider.dart';
|
||||
import 'services/node_data_manager.dart';
|
||||
import 'services/tile_preview_service.dart';
|
||||
import 'services/changelog_service.dart';
|
||||
import 'services/operator_profile_service.dart';
|
||||
import 'services/deep_link_service.dart';
|
||||
import 'widgets/node_provider_with_cache.dart';
|
||||
import 'services/profile_service.dart';
|
||||
import 'widgets/proximity_warning_dialog.dart';
|
||||
@@ -239,11 +242,19 @@ class AppState extends ChangeNotifier {
|
||||
// Initialize OfflineAreaService to ensure offline areas are loaded
|
||||
await OfflineAreaService().ensureInitialized();
|
||||
|
||||
// Preload offline nodes into cache for immediate display
|
||||
await NodeDataManager().preloadOfflineNodes();
|
||||
|
||||
// Start uploader if conditions are met
|
||||
_startUploader();
|
||||
|
||||
_isInitialized = true;
|
||||
|
||||
// Check for initial deep link after a small delay to let navigation settle
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
DeepLinkService().checkInitialLink();
|
||||
});
|
||||
|
||||
// Start periodic message checking
|
||||
_startMessageCheckTimer();
|
||||
|
||||
@@ -563,8 +574,8 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// ---------- Navigation Methods - Simplified ----------
|
||||
void enterSearchMode(LatLng mapCenter) {
|
||||
_navigationState.enterSearchMode(mapCenter);
|
||||
void enterSearchMode(LatLng mapCenter, {LatLngBounds? viewbox}) {
|
||||
_navigationState.enterSearchMode(mapCenter, viewbox: viewbox);
|
||||
}
|
||||
|
||||
void cancelNavigation() {
|
||||
@@ -651,7 +662,7 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
// Clear node cache when switching upload modes to prevent mixing production/sandbox data
|
||||
NodeCache.instance.clear();
|
||||
MapDataProvider().clearCache();
|
||||
debugPrint('[AppState] Cleared node cache due to upload mode change');
|
||||
|
||||
await _settingsState.setUploadMode(mode);
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
"mustBeLoggedIn": "Sie müssen angemeldet sein, um neue Knoten zu übertragen. Bitte melden Sie sich über die Einstellungen an.",
|
||||
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um neue Knoten zu übertragen.",
|
||||
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um neue Knoten zu übertragen.",
|
||||
"loadingAreaData": "Lade Bereichsdaten... Bitte warten Sie vor dem Übertragen.",
|
||||
"refineTags": "Tags Verfeinern",
|
||||
"refineTagsWithProfile": "Tags Verfeinern ({})"
|
||||
},
|
||||
@@ -118,6 +119,7 @@
|
||||
"sandboxModeWarning": "Bearbeitungen von Produktionsknoten können nicht an die Sandbox übertragen werden. Wechseln Sie in den Produktionsmodus in den Einstellungen, um Knoten zu bearbeiten.",
|
||||
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um Knoten zu bearbeiten.",
|
||||
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um Knoten zu bearbeiten.",
|
||||
"loadingAreaData": "Lade Bereichsdaten... Bitte warten Sie vor dem Übertragen.",
|
||||
"cannotMoveConstrainedNode": "Kann diese Kamera nicht verschieben - sie ist mit einem anderen Kartenelement verbunden (OSM-Weg/Relation). Sie können trotzdem ihre Tags und Richtung bearbeiten.",
|
||||
"zoomInRequiredMessage": "Zoomen Sie auf mindestens Stufe {} heran, um Überwachungsknoten hinzuzufügen oder zu bearbeiten. Dies gewährleistet eine präzise Positionierung für genaues Kartieren.",
|
||||
"extractFromWay": "Knoten aus Weg/Relation extrahieren",
|
||||
@@ -279,7 +281,14 @@
|
||||
"view": "Anzeigen",
|
||||
"deleteProfile": "Profil Löschen",
|
||||
"deleteProfileConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
|
||||
"profileDeleted": "Profil gelöscht"
|
||||
"profileDeleted": "Profil gelöscht",
|
||||
"getMore": "Weitere anzeigen...",
|
||||
"addProfileChoice": "Profil Hinzufügen",
|
||||
"addProfileChoiceMessage": "Wie möchten Sie ein Profil hinzufügen?",
|
||||
"createCustomProfile": "Benutzerdefiniertes Profil Erstellen",
|
||||
"createCustomProfileDescription": "Erstellen Sie ein Profil von Grund auf mit Ihren eigenen Tags",
|
||||
"importFromWebsite": "Von Webseite Importieren",
|
||||
"importFromWebsiteDescription": "Profile von deflock.me/identify durchsuchen und importieren"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Karten-Kacheln",
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
"mustBeLoggedIn": "You must be logged in to submit new nodes. Please log in via Settings.",
|
||||
"enableSubmittableProfile": "Enable a submittable profile in Settings to submit new nodes.",
|
||||
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to submit new nodes.",
|
||||
"loadingAreaData": "Loading area data... Please wait before submitting.",
|
||||
"refineTags": "Refine Tags",
|
||||
"refineTagsWithProfile": "Refine Tags ({})"
|
||||
},
|
||||
@@ -155,6 +156,7 @@
|
||||
"sandboxModeWarning": "Cannot submit edits on production nodes to sandbox. Switch to Production mode in Settings to edit nodes.",
|
||||
"enableSubmittableProfile": "Enable a submittable profile in Settings to edit nodes.",
|
||||
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to edit nodes.",
|
||||
"loadingAreaData": "Loading area data... Please wait before submitting.",
|
||||
"cannotMoveConstrainedNode": "Cannot move this camera - it's connected to another map element (OSM way/relation). You can still edit its tags and direction.",
|
||||
"zoomInRequiredMessage": "Zoom in to at least level {} to add or edit surveillance nodes. This ensures precise positioning for accurate mapping.",
|
||||
"extractFromWay": "Extract node from way/relation",
|
||||
@@ -316,7 +318,14 @@
|
||||
"view": "View",
|
||||
"deleteProfile": "Delete Profile",
|
||||
"deleteProfileConfirm": "Are you sure you want to delete \"{}\"?",
|
||||
"profileDeleted": "Profile deleted"
|
||||
"profileDeleted": "Profile deleted",
|
||||
"getMore": "Get more...",
|
||||
"addProfileChoice": "Add Profile",
|
||||
"addProfileChoiceMessage": "How would you like to add a profile?",
|
||||
"createCustomProfile": "Create Custom Profile",
|
||||
"createCustomProfileDescription": "Build a profile from scratch with your own tags",
|
||||
"importFromWebsite": "Import from Website",
|
||||
"importFromWebsiteDescription": "Browse and import profiles from deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Map Tiles",
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
"mustBeLoggedIn": "Debe estar conectado para enviar nuevos nodos. Por favor, inicie sesión a través de Configuración.",
|
||||
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para enviar nuevos nodos.",
|
||||
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para enviar nuevos nodos.",
|
||||
"loadingAreaData": "Cargando datos del área... Por favor espere antes de enviar.",
|
||||
"refineTags": "Refinar Etiquetas",
|
||||
"refineTagsWithProfile": "Refinar Etiquetas ({})"
|
||||
},
|
||||
@@ -155,6 +156,7 @@
|
||||
"sandboxModeWarning": "No se pueden enviar ediciones de nodos de producción al sandbox. Cambie al modo Producción en Configuración para editar nodos.",
|
||||
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para editar nodos.",
|
||||
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para editar nodos.",
|
||||
"loadingAreaData": "Cargando datos del área... Por favor espere antes de enviar.",
|
||||
"cannotMoveConstrainedNode": "No se puede mover esta cámara - está conectada a otro elemento del mapa (OSM way/relation). Aún puede editar sus etiquetas y dirección.",
|
||||
"zoomInRequiredMessage": "Amplíe al menos al nivel {} para agregar o editar nodos de vigilancia. Esto garantiza un posicionamiento preciso para un mapeo exacto.",
|
||||
"extractFromWay": "Extraer nodo de way/relation",
|
||||
@@ -316,7 +318,14 @@
|
||||
"view": "Ver",
|
||||
"deleteProfile": "Eliminar Perfil",
|
||||
"deleteProfileConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
|
||||
"profileDeleted": "Perfil eliminado"
|
||||
"profileDeleted": "Perfil eliminado",
|
||||
"getMore": "Obtener más...",
|
||||
"addProfileChoice": "Añadir Perfil",
|
||||
"addProfileChoiceMessage": "¿Cómo desea añadir un perfil?",
|
||||
"createCustomProfile": "Crear Perfil Personalizado",
|
||||
"createCustomProfileDescription": "Crear un perfil desde cero con sus propias etiquetas",
|
||||
"importFromWebsite": "Importar desde Sitio Web",
|
||||
"importFromWebsiteDescription": "Explorar e importar perfiles desde deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tiles de Mapa",
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
"mustBeLoggedIn": "Vous devez être connecté pour soumettre de nouveaux nœuds. Veuillez vous connecter via les Paramètres.",
|
||||
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour soumettre de nouveaux nœuds.",
|
||||
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour soumettre de nouveaux nœuds.",
|
||||
"loadingAreaData": "Chargement des données de zone... Veuillez patienter avant de soumettre.",
|
||||
"refineTags": "Affiner Balises",
|
||||
"refineTagsWithProfile": "Affiner Balises ({})"
|
||||
},
|
||||
@@ -155,6 +156,7 @@
|
||||
"sandboxModeWarning": "Impossible de soumettre des modifications de nœuds de production au sandbox. Passez au mode Production dans les Paramètres pour modifier les nœuds.",
|
||||
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour modifier les nœuds.",
|
||||
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour modifier les nœuds.",
|
||||
"loadingAreaData": "Chargement des données de zone... Veuillez patienter avant de soumettre.",
|
||||
"cannotMoveConstrainedNode": "Impossible de déplacer cette caméra - elle est connectée à un autre élément de carte (OSM way/relation). Vous pouvez toujours modifier ses balises et sa direction.",
|
||||
"zoomInRequiredMessage": "Zoomez au moins au niveau {} pour ajouter ou modifier des nœuds de surveillance. Cela garantit un positionnement précis pour une cartographie exacte.",
|
||||
"extractFromWay": "Extraire le nœud du way/relation",
|
||||
@@ -316,7 +318,14 @@
|
||||
"view": "Voir",
|
||||
"deleteProfile": "Supprimer Profil",
|
||||
"deleteProfileConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
|
||||
"profileDeleted": "Profil supprimé"
|
||||
"profileDeleted": "Profil supprimé",
|
||||
"getMore": "En obtenir plus...",
|
||||
"addProfileChoice": "Ajouter Profil",
|
||||
"addProfileChoiceMessage": "Comment souhaitez-vous ajouter un profil?",
|
||||
"createCustomProfile": "Créer Profil Personnalisé",
|
||||
"createCustomProfileDescription": "Créer un profil à partir de zéro avec vos propres balises",
|
||||
"importFromWebsite": "Importer depuis Site Web",
|
||||
"importFromWebsiteDescription": "Parcourir et importer des profils depuis deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tuiles de Carte",
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
"mustBeLoggedIn": "Devi essere loggato per inviare nuovi nodi. Per favore accedi tramite Impostazioni.",
|
||||
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per inviare nuovi nodi.",
|
||||
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per inviare nuovi nodi.",
|
||||
"loadingAreaData": "Caricamento dati area... Per favore attendi prima di inviare.",
|
||||
"refineTags": "Affina Tag",
|
||||
"refineTagsWithProfile": "Affina Tag ({})"
|
||||
},
|
||||
@@ -155,6 +156,7 @@
|
||||
"sandboxModeWarning": "Impossibile inviare modifiche di nodi di produzione alla sandbox. Passa alla modalità Produzione nelle Impostazioni per modificare i nodi.",
|
||||
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per modificare i nodi.",
|
||||
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per modificare i nodi.",
|
||||
"loadingAreaData": "Caricamento dati area... Per favore attendi prima di inviare.",
|
||||
"cannotMoveConstrainedNode": "Impossibile spostare questa telecamera - è collegata a un altro elemento della mappa (OSM way/relation). Puoi ancora modificare i suoi tag e direzione.",
|
||||
"zoomInRequiredMessage": "Ingrandisci almeno al livello {} per aggiungere o modificare nodi di sorveglianza. Questo garantisce un posizionamento preciso per una mappatura accurata.",
|
||||
"extractFromWay": "Estrai nodo da way/relation",
|
||||
@@ -316,7 +318,14 @@
|
||||
"view": "Visualizza",
|
||||
"deleteProfile": "Elimina Profilo",
|
||||
"deleteProfileConfirm": "Sei sicuro di voler eliminare \"{}\"?",
|
||||
"profileDeleted": "Profilo eliminato"
|
||||
"profileDeleted": "Profilo eliminato",
|
||||
"getMore": "Ottieni altri...",
|
||||
"addProfileChoice": "Aggiungi Profilo",
|
||||
"addProfileChoiceMessage": "Come desideri aggiungere un profilo?",
|
||||
"createCustomProfile": "Crea Profilo Personalizzato",
|
||||
"createCustomProfileDescription": "Crea un profilo da zero con i tuoi tag",
|
||||
"importFromWebsite": "Importa da Sito Web",
|
||||
"importFromWebsiteDescription": "Sfoglia e importa profili da deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tile Mappa",
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
"mustBeLoggedIn": "Você deve estar logado para enviar novos nós. Por favor, faça login via Configurações.",
|
||||
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para enviar novos nós.",
|
||||
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para enviar novos nós.",
|
||||
"loadingAreaData": "Carregando dados da área... Por favor aguarde antes de enviar.",
|
||||
"refineTags": "Refinar Tags",
|
||||
"refineTagsWithProfile": "Refinar Tags ({})"
|
||||
},
|
||||
@@ -155,6 +156,7 @@
|
||||
"sandboxModeWarning": "Não é possível enviar edições de nós de produção para o sandbox. Mude para o modo Produção nas Configurações para editar nós.",
|
||||
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para editar nós.",
|
||||
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para editar nós.",
|
||||
"loadingAreaData": "Carregando dados da área... Por favor aguarde antes de enviar.",
|
||||
"cannotMoveConstrainedNode": "Não é possível mover esta câmera - ela está conectada a outro elemento do mapa (OSM way/relation). Você ainda pode editar suas tags e direção.",
|
||||
"zoomInRequiredMessage": "Amplie para pelo menos o nível {} para adicionar ou editar nós de vigilância. Isto garante um posicionamento preciso para mapeamento exato.",
|
||||
"extractFromWay": "Extrair nó do way/relation",
|
||||
@@ -316,7 +318,14 @@
|
||||
"view": "Ver",
|
||||
"deleteProfile": "Excluir Perfil",
|
||||
"deleteProfileConfirm": "Tem certeza de que deseja excluir \"{}\"?",
|
||||
"profileDeleted": "Perfil excluído"
|
||||
"profileDeleted": "Perfil excluído",
|
||||
"getMore": "Obter mais...",
|
||||
"addProfileChoice": "Adicionar Perfil",
|
||||
"addProfileChoiceMessage": "Como gostaria de adicionar um perfil?",
|
||||
"createCustomProfile": "Criar Perfil Personalizado",
|
||||
"createCustomProfileDescription": "Construir um perfil do zero com suas próprias tags",
|
||||
"importFromWebsite": "Importar do Site",
|
||||
"importFromWebsiteDescription": "Navegar e importar perfis do deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tiles do Mapa",
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
"mustBeLoggedIn": "您必须登录才能提交新节点。请通过设置登录。",
|
||||
"enableSubmittableProfile": "在设置中启用可提交的配置文件以提交新节点。",
|
||||
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来提交新节点。",
|
||||
"loadingAreaData": "正在加载区域数据...提交前请稍候。",
|
||||
"refineTags": "细化标签",
|
||||
"refineTagsWithProfile": "细化标签({})"
|
||||
},
|
||||
@@ -155,6 +156,7 @@
|
||||
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
|
||||
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
|
||||
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
|
||||
"loadingAreaData": "正在加载区域数据...提交前请稍候。",
|
||||
"cannotMoveConstrainedNode": "无法移动此相机 - 它连接到另一个地图元素(OSM way/relation)。您仍可以编辑其标签和方向。",
|
||||
"zoomInRequiredMessage": "请放大至至少第{}级来添加或编辑监控节点。这确保精确定位以便准确制图。",
|
||||
"extractFromWay": "从way/relation中提取节点",
|
||||
@@ -316,7 +318,14 @@
|
||||
"view": "查看",
|
||||
"deleteProfile": "删除配置文件",
|
||||
"deleteProfileConfirm": "您确定要删除 \"{}\" 吗?",
|
||||
"profileDeleted": "配置文件已删除"
|
||||
"profileDeleted": "配置文件已删除",
|
||||
"getMore": "获取更多...",
|
||||
"addProfileChoice": "添加配置文件",
|
||||
"addProfileChoiceMessage": "您希望如何添加配置文件?",
|
||||
"createCustomProfile": "创建自定义配置文件",
|
||||
"createCustomProfileDescription": "从头开始构建带有您自己标签的配置文件",
|
||||
"importFromWebsite": "从网站导入",
|
||||
"importFromWebsiteDescription": "浏览并从 deflock.me/identify 导入配置文件"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "地图瓦片",
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'screens/osm_account_screen.dart';
|
||||
import 'screens/upload_queue_screen.dart';
|
||||
import 'services/localization_service.dart';
|
||||
import 'services/version_service.dart';
|
||||
import 'services/deep_link_service.dart';
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +28,10 @@ Future<void> main() async {
|
||||
// Initialize localization service
|
||||
await LocalizationService.instance.init();
|
||||
|
||||
// Initialize deep link service
|
||||
await DeepLinkService().init();
|
||||
DeepLinkService().setNavigatorKey(_navigatorKey);
|
||||
|
||||
runApp(
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => AppState(),
|
||||
@@ -68,6 +73,7 @@ class DeFlockApp extends StatelessWidget {
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
navigatorKey: _navigatorKey,
|
||||
routes: {
|
||||
'/': (context) => const HomeScreen(),
|
||||
'/settings': (context) => const SettingsScreen(),
|
||||
@@ -82,7 +88,11 @@ class DeFlockApp extends StatelessWidget {
|
||||
'/settings/release-notes': (context) => const ReleaseNotesScreen(),
|
||||
},
|
||||
initialRoute: '/',
|
||||
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Global navigator key for deep link navigation
|
||||
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Sentinel value for copyWith methods to distinguish between null and not provided
|
||||
const Object _notProvided = Object();
|
||||
|
||||
/// A bundle of preset OSM tags that describe a particular surveillance node model/type.
|
||||
class NodeProfile {
|
||||
final String id;
|
||||
@@ -217,7 +220,7 @@ class NodeProfile {
|
||||
bool? requiresDirection,
|
||||
bool? submittable,
|
||||
bool? editable,
|
||||
double? fov,
|
||||
Object? fov = _notProvided,
|
||||
}) =>
|
||||
NodeProfile(
|
||||
id: id ?? this.id,
|
||||
@@ -227,7 +230,7 @@ class NodeProfile {
|
||||
requiresDirection: requiresDirection ?? this.requiresDirection,
|
||||
submittable: submittable ?? this.submittable,
|
||||
editable: editable ?? this.editable,
|
||||
fov: fov ?? this.fov,
|
||||
fov: fov == _notProvided ? this.fov : fov as double?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
|
||||
@@ -107,6 +107,11 @@ class OsmNode {
|
||||
start = ((start % 360) + 360) % 360;
|
||||
end = ((end % 360) + 360) % 360;
|
||||
|
||||
// Special case: if start equals end, this represents 360° FOV
|
||||
if (start == end) {
|
||||
return DirectionFov(start, 360.0);
|
||||
}
|
||||
|
||||
double width, center;
|
||||
|
||||
if (start > end) {
|
||||
|
||||
@@ -138,7 +138,8 @@ class NavigationCoordinator {
|
||||
// Enter search mode
|
||||
try {
|
||||
final center = mapController.mapController.camera.center;
|
||||
appState.enterSearchMode(center);
|
||||
final viewbox = mapController.mapController.camera.visibleBounds;
|
||||
appState.enterSearchMode(center, viewbox: viewbox);
|
||||
} catch (e) {
|
||||
debugPrint('[NavigationCoordinator] Could not get map center for search: $e');
|
||||
// Fallback to default location
|
||||
|
||||
@@ -55,6 +55,12 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.profile.name.isEmpty ? locService.t('operatorProfileEditor.newOperatorProfile') : locService.t('operatorProfileEditor.editOperatorProfile')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _save,
|
||||
child: Text(locService.t('profileEditor.saveProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
@@ -87,10 +93,6 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
const SizedBox(height: 8),
|
||||
..._buildTagRows(),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: Text(locService.t('profileEditor.saveProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -69,6 +69,12 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
title: Text(!widget.profile.editable
|
||||
? locService.t('profileEditor.viewProfile')
|
||||
: (widget.profile.name.isEmpty ? locService.t('profileEditor.newProfile') : locService.t('profileEditor.editProfile'))),
|
||||
actions: widget.profile.editable ? [
|
||||
TextButton(
|
||||
onPressed: _save,
|
||||
child: Text(locService.t('profileEditor.saveProfile')),
|
||||
),
|
||||
] : null,
|
||||
),
|
||||
body: ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
@@ -135,11 +141,6 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
const SizedBox(height: 8),
|
||||
..._buildTagRows(),
|
||||
const SizedBox(height: 24),
|
||||
if (widget.profile.editable)
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: Text(locService.t('profileEditor.saveProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../models/node_profile.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../../widgets/profile_add_choice_dialog.dart';
|
||||
import '../../profile_editor.dart';
|
||||
|
||||
class NodeProfilesSection extends StatelessWidget {
|
||||
@@ -27,18 +28,7 @@ class NodeProfilesSection extends StatelessWidget {
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(
|
||||
profile: NodeProfile(
|
||||
id: const Uuid().v4(),
|
||||
name: '',
|
||||
tags: const {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: () => _showAddProfileDialog(context),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(locService.t('profiles.newProfile')),
|
||||
),
|
||||
@@ -121,6 +111,34 @@ class NodeProfilesSection extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddProfileDialog(BuildContext context) async {
|
||||
final result = await showDialog<String?>(
|
||||
context: context,
|
||||
builder: (context) => const ProfileAddChoiceDialog(),
|
||||
);
|
||||
|
||||
// If user chose to create custom profile, open the profile editor
|
||||
if (result == 'create') {
|
||||
_createNewProfile(context);
|
||||
}
|
||||
// If user chose import from website, ProfileAddChoiceDialog handles opening the URL
|
||||
}
|
||||
|
||||
void _createNewProfile(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(
|
||||
profile: NodeProfile(
|
||||
id: const Uuid().v4(),
|
||||
name: '',
|
||||
tags: const {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteProfileDialog(BuildContext context, NodeProfile profile) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
157
lib/services/deep_link_service.dart
Normal file
157
lib/services/deep_link_service.dart
Normal file
@@ -0,0 +1,157 @@
|
||||
import 'dart:async';
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import 'profile_import_service.dart';
|
||||
import '../screens/profile_editor.dart';
|
||||
|
||||
class DeepLinkService {
|
||||
static final DeepLinkService _instance = DeepLinkService._internal();
|
||||
factory DeepLinkService() => _instance;
|
||||
DeepLinkService._internal();
|
||||
|
||||
late AppLinks _appLinks;
|
||||
StreamSubscription<Uri>? _linkSubscription;
|
||||
|
||||
/// Initialize deep link handling (sets up stream listener only)
|
||||
Future<void> init() async {
|
||||
_appLinks = AppLinks();
|
||||
|
||||
// Set up stream listener for links when app is already running
|
||||
_linkSubscription = _appLinks.uriLinkStream.listen(
|
||||
_processLink,
|
||||
onError: (err) {
|
||||
debugPrint('[DeepLinkService] Link stream error: $err');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Process a deep link
|
||||
void _processLink(Uri uri) {
|
||||
debugPrint('[DeepLinkService] Processing deep link: $uri');
|
||||
|
||||
// Only handle deflockapp scheme
|
||||
if (uri.scheme != 'deflockapp') {
|
||||
debugPrint('[DeepLinkService] Ignoring non-deflockapp scheme: ${uri.scheme}');
|
||||
return;
|
||||
}
|
||||
|
||||
// Route based on path
|
||||
switch (uri.host) {
|
||||
case 'profiles':
|
||||
_handleProfilesLink(uri);
|
||||
break;
|
||||
case 'auth':
|
||||
// OAuth links are handled by flutter_web_auth_2
|
||||
debugPrint('[DeepLinkService] OAuth link handled by flutter_web_auth_2');
|
||||
break;
|
||||
default:
|
||||
debugPrint('[DeepLinkService] Unknown deep link host: ${uri.host}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for initial link after app is fully ready
|
||||
Future<void> checkInitialLink() async {
|
||||
debugPrint('[DeepLinkService] Checking for initial link...');
|
||||
|
||||
try {
|
||||
final initialLink = await _appLinks.getInitialLink();
|
||||
if (initialLink != null) {
|
||||
debugPrint('[DeepLinkService] Found initial link: $initialLink');
|
||||
_processLink(initialLink);
|
||||
} else {
|
||||
debugPrint('[DeepLinkService] No initial link found');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[DeepLinkService] Failed to get initial link: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle profile-related deep links
|
||||
void _handleProfilesLink(Uri uri) {
|
||||
final segments = uri.pathSegments;
|
||||
|
||||
if (segments.isEmpty) {
|
||||
debugPrint('[DeepLinkService] No path segments in profiles link');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (segments[0]) {
|
||||
case 'add':
|
||||
_handleAddProfileLink(uri);
|
||||
break;
|
||||
default:
|
||||
debugPrint('[DeepLinkService] Unknown profiles path: ${segments[0]}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle profile add deep link: deflockapp://profiles/add?p=<base64>
|
||||
void _handleAddProfileLink(Uri uri) {
|
||||
final base64Data = uri.queryParameters['p'];
|
||||
|
||||
if (base64Data == null || base64Data.isEmpty) {
|
||||
_showError('Invalid profile link: missing profile data');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse profile from base64
|
||||
final profile = ProfileImportService.parseProfileFromBase64(base64Data);
|
||||
|
||||
if (profile == null) {
|
||||
_showError('Invalid profile data');
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to profile editor with the imported profile
|
||||
_navigateToProfileEditor(profile);
|
||||
}
|
||||
|
||||
/// Navigate to profile editor with pre-filled profile data
|
||||
void _navigateToProfileEditor(NodeProfile profile) {
|
||||
final context = _navigatorKey?.currentContext;
|
||||
|
||||
if (context == null) {
|
||||
debugPrint('[DeepLinkService] No navigator context available');
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: profile),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show error message to user
|
||||
void _showError(String message) {
|
||||
final context = _navigatorKey?.currentContext;
|
||||
|
||||
if (context == null) {
|
||||
debugPrint('[DeepLinkService] Error (no context): $message');
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Global navigator key for navigation
|
||||
GlobalKey<NavigatorState>? _navigatorKey;
|
||||
|
||||
/// Set the global navigator key
|
||||
void setNavigatorKey(GlobalKey<NavigatorState> navigatorKey) {
|
||||
_navigatorKey = navigatorKey;
|
||||
}
|
||||
|
||||
/// Clean up resources
|
||||
void dispose() {
|
||||
_linkSubscription?.cancel();
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,10 @@ import 'package:flutter/foundation.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../app_state.dart';
|
||||
import 'map_data_submodules/nodes_from_overpass.dart';
|
||||
import 'map_data_submodules/nodes_from_osm_api.dart';
|
||||
import 'map_data_submodules/tiles_from_remote.dart';
|
||||
import 'map_data_submodules/nodes_from_local.dart';
|
||||
import 'map_data_submodules/tiles_from_local.dart';
|
||||
import 'network_status.dart';
|
||||
import 'prefetch_area_service.dart';
|
||||
import 'node_data_manager.dart';
|
||||
import 'node_spatial_cache.dart';
|
||||
|
||||
enum MapSource { local, remote, auto } // For future use
|
||||
|
||||
@@ -27,103 +24,31 @@ class MapDataProvider {
|
||||
factory MapDataProvider() => _instance;
|
||||
MapDataProvider._();
|
||||
|
||||
// REMOVED: AppState get _appState => AppState();
|
||||
final NodeDataManager _nodeDataManager = NodeDataManager();
|
||||
|
||||
bool get isOfflineMode => AppState.instance.offlineMode;
|
||||
void setOfflineMode(bool enabled) {
|
||||
AppState.instance.setOfflineMode(enabled);
|
||||
}
|
||||
|
||||
/// Fetch surveillance nodes from OSM/Overpass or local storage.
|
||||
/// Remote is default. If source is MapSource.auto, remote is tried first unless offline.
|
||||
/// Fetch surveillance nodes using the new simplified system.
|
||||
/// Returns cached data immediately if available, otherwise fetches from appropriate source.
|
||||
Future<List<OsmNode>> getNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
MapSource source = MapSource.auto,
|
||||
bool isUserInitiated = false,
|
||||
}) async {
|
||||
final offline = AppState.instance.offlineMode;
|
||||
|
||||
// Explicit remote request: error if offline, else always remote
|
||||
if (source == MapSource.remote) {
|
||||
if (offline) {
|
||||
throw OfflineModeException("Cannot fetch remote nodes in offline mode.");
|
||||
}
|
||||
return _fetchRemoteNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: 0, // No limit - fetch all available data
|
||||
);
|
||||
}
|
||||
|
||||
// Explicit local request: always use local
|
||||
if (source == MapSource.local) {
|
||||
return fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
);
|
||||
}
|
||||
|
||||
// AUTO: In offline mode, behavior depends on upload mode
|
||||
if (offline) {
|
||||
if (uploadMode == UploadMode.sandbox) {
|
||||
// Offline + Sandbox = no nodes (local cache is production data)
|
||||
debugPrint('[MapDataProvider] Offline + Sandbox mode: returning no nodes (local cache is production data)');
|
||||
return <OsmNode>[];
|
||||
} else {
|
||||
// Offline + Production = use local cache
|
||||
return fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxNodes: 0, // No limit - get all available data
|
||||
);
|
||||
}
|
||||
} else if (uploadMode == UploadMode.sandbox) {
|
||||
// Sandbox mode: Only fetch from sandbox API, ignore local production nodes
|
||||
debugPrint('[MapDataProvider] Sandbox mode: fetching only from sandbox API, ignoring local cache');
|
||||
return _fetchRemoteNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: 0, // No limit - fetch all available data
|
||||
);
|
||||
} else {
|
||||
// Production mode: use pre-fetch service for efficient area loading
|
||||
final preFetchService = PrefetchAreaService();
|
||||
|
||||
// Always get local nodes first (fast, from cache)
|
||||
final localNodes = await fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxNodes: AppState.instance.maxNodes,
|
||||
);
|
||||
|
||||
// Check if we need to trigger a new pre-fetch (spatial or temporal)
|
||||
final needsFetch = !preFetchService.isWithinPreFetchedArea(bounds, profiles, uploadMode) ||
|
||||
preFetchService.isDataStale();
|
||||
|
||||
if (needsFetch) {
|
||||
// Outside area OR data stale - start pre-fetch with loading state
|
||||
debugPrint('[MapDataProvider] Starting pre-fetch with loading state');
|
||||
NetworkStatus.instance.setWaiting();
|
||||
preFetchService.requestPreFetchIfNeeded(
|
||||
viewBounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
);
|
||||
} else {
|
||||
debugPrint('[MapDataProvider] Using existing fresh pre-fetched area cache');
|
||||
}
|
||||
|
||||
// Return all local nodes without any rendering limit
|
||||
// Rendering limits are applied at the UI layer
|
||||
return localNodes;
|
||||
}
|
||||
return _nodeDataManager.getNodesFor(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
isUserInitiated: isUserInitiated,
|
||||
);
|
||||
}
|
||||
|
||||
/// Bulk/paged node fetch for offline downloads (handling paging, dedup, and Overpass retries)
|
||||
/// Only use for offline area download, not for map browsing! Ignores maxNodes config.
|
||||
/// Bulk node fetch for offline downloads using new system
|
||||
Future<List<OsmNode>> getAllNodesForDownload({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
@@ -131,16 +56,12 @@ class MapDataProvider {
|
||||
int maxResults = 0, // 0 = no limit for offline downloads
|
||||
int maxTries = 3,
|
||||
}) async {
|
||||
final offline = AppState.instance.offlineMode;
|
||||
if (offline) {
|
||||
if (AppState.instance.offlineMode) {
|
||||
throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode.");
|
||||
}
|
||||
return _fetchRemoteNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults, // Pass 0 for unlimited
|
||||
);
|
||||
|
||||
// For downloads, always fetch fresh data (don't use cache)
|
||||
return _nodeDataManager.fetchWithSplitting(bounds, profiles);
|
||||
}
|
||||
|
||||
/// Fetch tile image bytes. Default is to try local first, then remote if not offline. Honors explicit source.
|
||||
@@ -202,57 +123,48 @@ class MapDataProvider {
|
||||
clearRemoteTileQueueSelective(currentBounds);
|
||||
}
|
||||
|
||||
/// Fetch remote nodes with Overpass first, OSM API fallback
|
||||
Future<List<OsmNode>> _fetchRemoteNodes({
|
||||
/// Add or update nodes in cache (for upload queue integration)
|
||||
void addOrUpdateNodes(List<OsmNode> nodes) {
|
||||
_nodeDataManager.addOrUpdateNodes(nodes);
|
||||
}
|
||||
|
||||
/// NodeCache compatibility - alias for addOrUpdateNodes
|
||||
void addOrUpdate(List<OsmNode> nodes) {
|
||||
addOrUpdateNodes(nodes);
|
||||
}
|
||||
|
||||
/// Remove node from cache (for deletions)
|
||||
void removeNodeById(int nodeId) {
|
||||
_nodeDataManager.removeNodeById(nodeId);
|
||||
}
|
||||
|
||||
/// Clear cache (when profiles change)
|
||||
void clearCache() {
|
||||
_nodeDataManager.clearCache();
|
||||
}
|
||||
|
||||
/// Force refresh current area (manual retry)
|
||||
Future<void> refreshArea({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
required int maxResults,
|
||||
}) async {
|
||||
// For sandbox mode, skip Overpass and go directly to OSM API
|
||||
// (Overpass doesn't have sandbox data)
|
||||
if (uploadMode == UploadMode.sandbox) {
|
||||
debugPrint('[MapDataProvider] Sandbox mode detected, using OSM API directly');
|
||||
return fetchOsmApiNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults,
|
||||
);
|
||||
}
|
||||
|
||||
// For production mode, try Overpass first, then fallback to OSM API
|
||||
try {
|
||||
final nodes = await fetchOverpassNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults,
|
||||
);
|
||||
|
||||
// If Overpass returns nodes, we're good
|
||||
if (nodes.isNotEmpty) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// If Overpass returns empty (could be no data or could be an issue),
|
||||
// try OSM API as well to be thorough
|
||||
debugPrint('[MapDataProvider] Overpass returned no nodes, trying OSM API fallback');
|
||||
return fetchOsmApiNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults,
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[MapDataProvider] Overpass failed ($e), trying OSM API fallback');
|
||||
return fetchOsmApiNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults,
|
||||
);
|
||||
}
|
||||
return _nodeDataManager.refreshArea(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
);
|
||||
}
|
||||
|
||||
/// NodeCache compatibility methods for upload queue
|
||||
/// These all delegate to the singleton cache to ensure consistency
|
||||
OsmNode? getNodeById(int nodeId) => NodeSpatialCache().getNodeById(nodeId);
|
||||
void removePendingEditMarker(int nodeId) => NodeSpatialCache().removePendingEditMarker(nodeId);
|
||||
void removePendingDeletionMarker(int nodeId) => NodeSpatialCache().removePendingDeletionMarker(nodeId);
|
||||
void removeTempNodeById(int tempNodeId) => NodeSpatialCache().removeTempNodeById(tempNodeId);
|
||||
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) =>
|
||||
NodeSpatialCache().findNodesWithinDistance(coord, distanceMeters, excludeNodeId: excludeNodeId);
|
||||
|
||||
/// Check if we have good cache coverage for the given area (prevents submission in uncovered areas)
|
||||
bool hasGoodCoverageFor(LatLngBounds bounds) => NodeSpatialCache().hasDataFor(bounds);
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../../models/node_profile.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../models/pending_upload.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
import '../network_status.dart';
|
||||
import '../overpass_node_limit_exception.dart';
|
||||
|
||||
/// Fetches surveillance nodes from the Overpass OSM API for the given bounds and profiles.
|
||||
/// If the query fails due to too many nodes, automatically splits the area and retries.
|
||||
Future<List<OsmNode>> fetchOverpassNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
required int maxResults,
|
||||
}) async {
|
||||
// Check if this is a user-initiated fetch (indicated by loading state)
|
||||
final wasUserInitiated = NetworkStatus.instance.currentStatus == NetworkStatusType.waiting;
|
||||
|
||||
try {
|
||||
final nodes = await _fetchOverpassNodesWithSplitting(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults,
|
||||
splitDepth: 0,
|
||||
reportStatus: wasUserInitiated, // Only top level reports status
|
||||
);
|
||||
|
||||
// Only report success at the top level if this was user-initiated
|
||||
if (wasUserInitiated) {
|
||||
NetworkStatus.instance.setSuccess();
|
||||
}
|
||||
|
||||
return nodes;
|
||||
} catch (e) {
|
||||
// Only report errors at the top level if this was user-initiated
|
||||
if (wasUserInitiated) {
|
||||
if (e.toString().contains('timeout') || e.toString().contains('timed out')) {
|
||||
NetworkStatus.instance.setTimeoutError();
|
||||
} else {
|
||||
NetworkStatus.instance.setNetworkError();
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('[fetchOverpassNodes] Top-level operation failed: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal method that handles splitting when node limit is exceeded.
|
||||
Future<List<OsmNode>> _fetchOverpassNodesWithSplitting({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
required int maxResults,
|
||||
required int splitDepth,
|
||||
required bool reportStatus, // Only true for top level
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
const int maxSplitDepth = kMaxPreFetchSplitDepth; // Maximum times we'll split (4^3 = 64 max sub-areas)
|
||||
|
||||
try {
|
||||
return await _fetchSingleOverpassQuery(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxResults: maxResults,
|
||||
reportStatus: reportStatus,
|
||||
);
|
||||
} on OverpassRateLimitException catch (e) {
|
||||
// Rate limits should NOT be split - just fail with extended backoff
|
||||
debugPrint('[fetchOverpassNodes] Rate limited - using extended backoff, not splitting');
|
||||
|
||||
// Report slow progress when backing off
|
||||
if (reportStatus) {
|
||||
NetworkStatus.instance.reportSlowProgress();
|
||||
}
|
||||
|
||||
// Wait longer for rate limits before giving up entirely
|
||||
await Future.delayed(const Duration(seconds: 30));
|
||||
return []; // Return empty rather than rethrowing - let caller handle error reporting
|
||||
} on OverpassNodeLimitException {
|
||||
// If we've hit max split depth, give up to avoid infinite recursion
|
||||
if (splitDepth >= maxSplitDepth) {
|
||||
debugPrint('[fetchOverpassNodes] Max split depth reached, giving up on area: $bounds');
|
||||
return []; // Return empty - let caller handle error reporting
|
||||
}
|
||||
|
||||
// Report slow progress when we start splitting (only at the top level)
|
||||
if (reportStatus) {
|
||||
NetworkStatus.instance.reportSlowProgress();
|
||||
}
|
||||
|
||||
// Split the bounds into 4 quadrants and try each separately
|
||||
debugPrint('[fetchOverpassNodes] Splitting area into quadrants (depth: $splitDepth)');
|
||||
final quadrants = _splitBounds(bounds);
|
||||
final List<OsmNode> allNodes = [];
|
||||
|
||||
for (final quadrant in quadrants) {
|
||||
final nodes = await _fetchOverpassNodesWithSplitting(
|
||||
bounds: quadrant,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: 0, // No limit on individual quadrants to avoid double-limiting
|
||||
splitDepth: splitDepth + 1,
|
||||
reportStatus: false, // Sub-requests don't report status
|
||||
);
|
||||
allNodes.addAll(nodes);
|
||||
}
|
||||
|
||||
debugPrint('[fetchOverpassNodes] Collected ${allNodes.length} nodes from ${quadrants.length} quadrants');
|
||||
return allNodes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform a single Overpass query without splitting logic.
|
||||
Future<List<OsmNode>> _fetchSingleOverpassQuery({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
required int maxResults,
|
||||
required bool reportStatus,
|
||||
}) async {
|
||||
const String overpassEndpoint = 'https://overpass-api.de/api/interpreter';
|
||||
|
||||
// Build the Overpass query
|
||||
final query = _buildOverpassQuery(bounds, profiles, maxResults);
|
||||
|
||||
try {
|
||||
debugPrint('[fetchOverpassNodes] Querying Overpass for surveillance nodes...');
|
||||
debugPrint('[fetchOverpassNodes] Query:\n$query');
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(overpassEndpoint),
|
||||
body: {'data': query.trim()}
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
final errorBody = response.body;
|
||||
debugPrint('[fetchOverpassNodes] Overpass API error: $errorBody');
|
||||
|
||||
// Check if it's specifically the 50k node limit error (HTTP 400)
|
||||
// Exact message: "You requested too many nodes (limit is 50000)"
|
||||
if (errorBody.contains('too many nodes') &&
|
||||
errorBody.contains('50000')) {
|
||||
debugPrint('[fetchOverpassNodes] Detected 50k node limit error, will attempt splitting');
|
||||
throw OverpassNodeLimitException('Query exceeded node limit', serverResponse: errorBody);
|
||||
}
|
||||
|
||||
// Check for timeout errors that indicate query complexity (should split)
|
||||
// Common timeout messages from Overpass
|
||||
if (errorBody.contains('timeout') ||
|
||||
errorBody.contains('runtime limit exceeded') ||
|
||||
errorBody.contains('Query timed out')) {
|
||||
debugPrint('[fetchOverpassNodes] Detected timeout error, will attempt splitting to reduce complexity');
|
||||
throw OverpassNodeLimitException('Query timed out', serverResponse: errorBody);
|
||||
}
|
||||
|
||||
// Check for rate limiting (should NOT split - needs longer backoff)
|
||||
if (errorBody.contains('rate limited') ||
|
||||
errorBody.contains('too many requests') ||
|
||||
response.statusCode == 429) {
|
||||
debugPrint('[fetchOverpassNodes] Rate limited by Overpass API - needs extended backoff');
|
||||
throw OverpassRateLimitException('Rate limited by server', serverResponse: errorBody);
|
||||
}
|
||||
|
||||
// Don't report status here - let the top level handle it
|
||||
throw Exception('Overpass API error: $errorBody');
|
||||
}
|
||||
|
||||
final data = await compute(jsonDecode, response.body) as Map<String, dynamic>;
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
|
||||
if (elements.length > 20) {
|
||||
debugPrint('[fetchOverpassNodes] Retrieved ${elements.length} elements (nodes + ways/relations)');
|
||||
}
|
||||
|
||||
// Don't report success here - let the top level handle it
|
||||
|
||||
// Parse response to determine which nodes are constrained
|
||||
final nodes = _parseOverpassResponseWithConstraints(elements);
|
||||
|
||||
// Clean up any pending uploads that now appear in Overpass results
|
||||
_cleanupCompletedUploads(nodes);
|
||||
|
||||
return nodes;
|
||||
|
||||
} catch (e) {
|
||||
// Re-throw OverpassNodeLimitException so splitting logic can catch it
|
||||
if (e is OverpassNodeLimitException) rethrow;
|
||||
|
||||
debugPrint('[fetchOverpassNodes] Exception: $e');
|
||||
|
||||
// Don't report status here - let the top level handle it
|
||||
throw e; // Re-throw to let caller handle
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an Overpass API query for surveillance nodes matching the given profiles within bounds.
|
||||
/// Also fetches ways and relations that reference these nodes to determine constraint status.
|
||||
String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int maxResults) {
|
||||
// Deduplicate profiles to reduce query complexity - broader profiles subsume more specific ones
|
||||
final deduplicatedProfiles = _deduplicateProfilesForQuery(profiles);
|
||||
|
||||
// Safety check: if deduplication removed all profiles (edge case), fall back to original list
|
||||
final profilesToQuery = deduplicatedProfiles.isNotEmpty ? deduplicatedProfiles : profiles;
|
||||
|
||||
if (deduplicatedProfiles.length < profiles.length) {
|
||||
debugPrint('[Overpass] Deduplicated ${profiles.length} profiles to ${deduplicatedProfiles.length} for query efficiency');
|
||||
}
|
||||
|
||||
// Build node clauses for deduplicated profiles only
|
||||
final nodeClauses = profilesToQuery.map((profile) {
|
||||
// Convert profile tags to Overpass filter format, excluding empty values
|
||||
final tagFilters = profile.tags.entries
|
||||
.where((entry) => entry.value.trim().isNotEmpty) // Skip empty values
|
||||
.map((entry) => '["${entry.key}"="${entry.value}"]')
|
||||
.join();
|
||||
|
||||
// Build the node query with tag filters and bounding box
|
||||
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
|
||||
}).join('\n ');
|
||||
|
||||
return '''
|
||||
[out:json][timeout:${kOverpassQueryTimeout.inSeconds}];
|
||||
(
|
||||
$nodeClauses
|
||||
);
|
||||
out body ${maxResults > 0 ? maxResults : ''};
|
||||
(
|
||||
way(bn);
|
||||
rel(bn);
|
||||
);
|
||||
out meta;
|
||||
''';
|
||||
}
|
||||
|
||||
/// Deduplicate profiles for Overpass queries by removing profiles that are subsumed by others.
|
||||
/// A profile A subsumes profile B if all of A's non-empty tags exist in B with identical values.
|
||||
/// This optimization reduces query complexity while returning the same nodes (since broader
|
||||
/// profiles capture all nodes that more specific profiles would).
|
||||
List<NodeProfile> _deduplicateProfilesForQuery(List<NodeProfile> profiles) {
|
||||
if (profiles.length <= 1) return profiles;
|
||||
|
||||
final result = <NodeProfile>[];
|
||||
|
||||
for (final candidate in profiles) {
|
||||
// Skip profiles that only have empty tags - they would match everything and break queries
|
||||
final candidateNonEmptyTags = candidate.tags.entries
|
||||
.where((entry) => entry.value.trim().isNotEmpty)
|
||||
.toList();
|
||||
|
||||
if (candidateNonEmptyTags.isEmpty) continue;
|
||||
|
||||
// Check if any existing profile in our result subsumes this candidate
|
||||
bool isSubsumed = false;
|
||||
for (final existing in result) {
|
||||
if (_profileSubsumes(existing, candidate)) {
|
||||
isSubsumed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSubsumed) {
|
||||
// This candidate is not subsumed, so add it
|
||||
// But first, remove any existing profiles that this candidate subsumes
|
||||
result.removeWhere((existing) => _profileSubsumes(candidate, existing));
|
||||
result.add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Check if broaderProfile subsumes specificProfile.
|
||||
/// Returns true if all non-empty tags in broaderProfile exist in specificProfile with identical values.
|
||||
bool _profileSubsumes(NodeProfile broaderProfile, NodeProfile specificProfile) {
|
||||
// Get non-empty tags from both profiles
|
||||
final broaderTags = Map.fromEntries(
|
||||
broaderProfile.tags.entries.where((entry) => entry.value.trim().isNotEmpty)
|
||||
);
|
||||
final specificTags = Map.fromEntries(
|
||||
specificProfile.tags.entries.where((entry) => entry.value.trim().isNotEmpty)
|
||||
);
|
||||
|
||||
// If broader has no non-empty tags, it doesn't subsume anything (would match everything)
|
||||
if (broaderTags.isEmpty) return false;
|
||||
|
||||
// If broader has more non-empty tags than specific, it can't subsume
|
||||
if (broaderTags.length > specificTags.length) return false;
|
||||
|
||||
// Check if all broader tags exist in specific with same values
|
||||
for (final entry in broaderTags.entries) {
|
||||
if (specificTags[entry.key] != entry.value) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Split a LatLngBounds into 4 quadrants (NW, NE, SW, SE).
|
||||
List<LatLngBounds> _splitBounds(LatLngBounds bounds) {
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
final centerLng = (bounds.east + bounds.west) / 2;
|
||||
|
||||
return [
|
||||
// Southwest quadrant (bottom-left)
|
||||
LatLngBounds(
|
||||
LatLng(bounds.south, bounds.west),
|
||||
LatLng(centerLat, centerLng),
|
||||
),
|
||||
// Southeast quadrant (bottom-right)
|
||||
LatLngBounds(
|
||||
LatLng(bounds.south, centerLng),
|
||||
LatLng(centerLat, bounds.east),
|
||||
),
|
||||
// Northwest quadrant (top-left)
|
||||
LatLngBounds(
|
||||
LatLng(centerLat, bounds.west),
|
||||
LatLng(bounds.north, centerLng),
|
||||
),
|
||||
// Northeast quadrant (top-right)
|
||||
LatLngBounds(
|
||||
LatLng(centerLat, centerLng),
|
||||
LatLng(bounds.north, bounds.east),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Parse Overpass response elements to create OsmNode objects with constraint information.
|
||||
List<OsmNode> _parseOverpassResponseWithConstraints(List<dynamic> elements) {
|
||||
final nodeElements = <Map<String, dynamic>>[];
|
||||
final constrainedNodeIds = <int>{};
|
||||
|
||||
// First pass: collect surveillance nodes and identify constrained nodes
|
||||
for (final element in elements.whereType<Map<String, dynamic>>()) {
|
||||
final type = element['type'] as String?;
|
||||
|
||||
if (type == 'node') {
|
||||
// This is a surveillance node - collect it
|
||||
nodeElements.add(element);
|
||||
} else if (type == 'way' || type == 'relation') {
|
||||
// This is a way/relation that references some of our nodes
|
||||
final refs = element['nodes'] as List<dynamic>? ??
|
||||
element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? [];
|
||||
|
||||
// Mark all referenced nodes as constrained
|
||||
for (final ref in refs) {
|
||||
if (ref is int) {
|
||||
constrainedNodeIds.add(ref);
|
||||
} else if (ref is String) {
|
||||
final nodeId = int.tryParse(ref);
|
||||
if (nodeId != null) constrainedNodeIds.add(nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: create OsmNode objects with constraint info
|
||||
final nodes = nodeElements.map((element) {
|
||||
final nodeId = element['id'] as int;
|
||||
final isConstrained = constrainedNodeIds.contains(nodeId);
|
||||
|
||||
return OsmNode(
|
||||
id: nodeId,
|
||||
coord: LatLng(element['lat'], element['lon']),
|
||||
tags: Map<String, String>.from(element['tags'] ?? {}),
|
||||
isConstrained: isConstrained,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final constrainedCount = nodes.where((n) => n.isConstrained).length;
|
||||
if (constrainedCount > 0) {
|
||||
debugPrint('[fetchOverpassNodes] Found $constrainedCount constrained nodes out of ${nodes.length} total');
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/// Clean up pending uploads that now appear in Overpass results
|
||||
void _cleanupCompletedUploads(List<OsmNode> overpassNodes) {
|
||||
try {
|
||||
final appState = AppState.instance;
|
||||
final pendingUploads = appState.pendingUploads;
|
||||
|
||||
if (pendingUploads.isEmpty) return;
|
||||
|
||||
final overpassNodeIds = overpassNodes.map((n) => n.id).toSet();
|
||||
|
||||
// Find pending uploads whose submitted node IDs now appear in Overpass results
|
||||
final uploadsToRemove = <PendingUpload>[];
|
||||
|
||||
for (final upload in pendingUploads) {
|
||||
if (upload.submittedNodeId != null &&
|
||||
overpassNodeIds.contains(upload.submittedNodeId!)) {
|
||||
uploadsToRemove.add(upload);
|
||||
debugPrint('[OverpassCleanup] Found submitted node ${upload.submittedNodeId} in Overpass results, removing from pending queue');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the completed uploads from the queue
|
||||
for (final upload in uploadsToRemove) {
|
||||
appState.removeFromQueue(upload);
|
||||
}
|
||||
|
||||
if (uploadsToRemove.isNotEmpty) {
|
||||
debugPrint('[OverpassCleanup] Cleaned up ${uploadsToRemove.length} completed uploads');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[OverpassCleanup] Error during cleanup: $e');
|
||||
// Don't let cleanup errors break the main functionality
|
||||
}
|
||||
}
|
||||
300
lib/services/node_data_manager.dart
Normal file
300
lib/services/node_data_manager.dart
Normal file
@@ -0,0 +1,300 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../app_state.dart';
|
||||
import 'overpass_service.dart';
|
||||
import 'node_spatial_cache.dart';
|
||||
import 'network_status.dart';
|
||||
import 'map_data_submodules/nodes_from_osm_api.dart';
|
||||
import 'map_data_submodules/nodes_from_local.dart';
|
||||
import 'offline_area_service.dart';
|
||||
import 'offline_areas/offline_area_models.dart';
|
||||
|
||||
/// Coordinates node data fetching between cache, Overpass, and OSM API.
|
||||
/// Simple interface: give me nodes for this view with proper caching and error handling.
|
||||
class NodeDataManager extends ChangeNotifier {
|
||||
static final NodeDataManager _instance = NodeDataManager._();
|
||||
factory NodeDataManager() => _instance;
|
||||
NodeDataManager._();
|
||||
|
||||
final OverpassService _overpassService = OverpassService();
|
||||
final NodeSpatialCache _cache = NodeSpatialCache();
|
||||
|
||||
/// Get nodes for the given bounds and profiles.
|
||||
/// Returns cached data immediately if available, otherwise fetches from appropriate source.
|
||||
Future<List<OsmNode>> getNodesFor({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
bool isUserInitiated = false,
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
// Handle offline mode - no loading states needed, data is instant
|
||||
if (AppState.instance.offlineMode) {
|
||||
// Clear any existing loading states since offline data is instant
|
||||
if (isUserInitiated) {
|
||||
NetworkStatus.instance.clearWaiting();
|
||||
}
|
||||
|
||||
if (uploadMode == UploadMode.sandbox) {
|
||||
// Offline + Sandbox = no nodes (local cache is production data)
|
||||
debugPrint('[NodeDataManager] Offline + Sandbox mode: returning no nodes');
|
||||
return [];
|
||||
} else {
|
||||
// Offline + Production = use local offline areas (instant)
|
||||
final offlineNodes = await fetchLocalNodes(bounds: bounds, profiles: profiles);
|
||||
|
||||
// Add offline nodes to cache so they integrate with the rest of the system
|
||||
if (offlineNodes.isNotEmpty) {
|
||||
_cache.addOrUpdateNodes(offlineNodes);
|
||||
// Mark this area as having coverage for submit button logic
|
||||
_cache.markAreaAsFetched(bounds, offlineNodes);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Show brief success for user-initiated offline loads
|
||||
if (isUserInitiated && offlineNodes.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
NetworkStatus.instance.setSuccess();
|
||||
});
|
||||
}
|
||||
|
||||
return offlineNodes;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle sandbox mode (always fetch from OSM API, no caching)
|
||||
if (uploadMode == UploadMode.sandbox) {
|
||||
debugPrint('[NodeDataManager] Sandbox mode: fetching from OSM API');
|
||||
return fetchOsmApiNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: 0,
|
||||
);
|
||||
}
|
||||
|
||||
// Production mode: check cache first
|
||||
if (_cache.hasDataFor(bounds)) {
|
||||
debugPrint('[NodeDataManager] Using cached data for bounds');
|
||||
return _cache.getNodesFor(bounds);
|
||||
}
|
||||
|
||||
// Not cached - need to fetch
|
||||
if (isUserInitiated) {
|
||||
NetworkStatus.instance.setWaiting();
|
||||
}
|
||||
|
||||
try {
|
||||
final nodes = await fetchWithSplitting(bounds, profiles);
|
||||
|
||||
// Don't set success immediately - wait for UI to render the nodes
|
||||
notifyListeners();
|
||||
|
||||
// Set success after the next frame renders (when nodes are actually visible)
|
||||
if (isUserInitiated) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
NetworkStatus.instance.setSuccess();
|
||||
});
|
||||
}
|
||||
|
||||
return nodes;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[NodeDataManager] Fetch failed: $e');
|
||||
|
||||
if (isUserInitiated) {
|
||||
if (e is RateLimitError) {
|
||||
NetworkStatus.instance.reportOverpassIssue();
|
||||
} else {
|
||||
NetworkStatus.instance.setNetworkError();
|
||||
}
|
||||
}
|
||||
|
||||
// Return whatever we have in cache for this area
|
||||
return _cache.getNodesFor(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch nodes with automatic area splitting if needed
|
||||
Future<List<OsmNode>> fetchWithSplitting(
|
||||
LatLngBounds bounds,
|
||||
List<NodeProfile> profiles, {
|
||||
int splitDepth = 0,
|
||||
}) async {
|
||||
const maxSplitDepth = 3; // 4^3 = 64 max sub-areas
|
||||
|
||||
try {
|
||||
// Expand bounds slightly to reduce edge effects
|
||||
final expandedBounds = _expandBounds(bounds, 1.2);
|
||||
|
||||
final nodes = await _overpassService.fetchNodes(
|
||||
bounds: expandedBounds,
|
||||
profiles: profiles,
|
||||
);
|
||||
|
||||
// Success - cache the data for the expanded area
|
||||
_cache.markAreaAsFetched(expandedBounds, nodes);
|
||||
return nodes;
|
||||
|
||||
} on NodeLimitError {
|
||||
// Hit node limit or timeout - split area if not too deep
|
||||
if (splitDepth >= maxSplitDepth) {
|
||||
debugPrint('[NodeDataManager] Max split depth reached, giving up');
|
||||
return [];
|
||||
}
|
||||
|
||||
debugPrint('[NodeDataManager] Splitting area (depth: $splitDepth)');
|
||||
NetworkStatus.instance.reportSlowProgress();
|
||||
|
||||
return _fetchSplitAreas(bounds, profiles, splitDepth + 1);
|
||||
|
||||
} on RateLimitError {
|
||||
// Rate limited - wait and return empty
|
||||
debugPrint('[NodeDataManager] Rate limited, backing off');
|
||||
await Future.delayed(const Duration(seconds: 30));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch data by splitting area into quadrants
|
||||
Future<List<OsmNode>> _fetchSplitAreas(
|
||||
LatLngBounds bounds,
|
||||
List<NodeProfile> profiles,
|
||||
int splitDepth,
|
||||
) async {
|
||||
final quadrants = _splitBounds(bounds);
|
||||
final allNodes = <OsmNode>[];
|
||||
|
||||
for (final quadrant in quadrants) {
|
||||
try {
|
||||
final nodes = await fetchWithSplitting(quadrant, profiles, splitDepth: splitDepth);
|
||||
allNodes.addAll(nodes);
|
||||
} catch (e) {
|
||||
debugPrint('[NodeDataManager] Quadrant fetch failed: $e');
|
||||
// Continue with other quadrants
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('[NodeDataManager] Split fetch complete: ${allNodes.length} total nodes');
|
||||
return allNodes;
|
||||
}
|
||||
|
||||
/// Split bounds into 4 quadrants
|
||||
List<LatLngBounds> _splitBounds(LatLngBounds bounds) {
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
final centerLng = (bounds.east + bounds.west) / 2;
|
||||
|
||||
return [
|
||||
// Southwest
|
||||
LatLngBounds(LatLng(bounds.south, bounds.west), LatLng(centerLat, centerLng)),
|
||||
// Southeast
|
||||
LatLngBounds(LatLng(bounds.south, centerLng), LatLng(centerLat, bounds.east)),
|
||||
// Northwest
|
||||
LatLngBounds(LatLng(centerLat, bounds.west), LatLng(bounds.north, centerLng)),
|
||||
// Northeast
|
||||
LatLngBounds(LatLng(centerLat, centerLng), LatLng(bounds.north, bounds.east)),
|
||||
];
|
||||
}
|
||||
|
||||
/// Expand bounds by given factor around center point
|
||||
LatLngBounds _expandBounds(LatLngBounds bounds, double factor) {
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
final centerLng = (bounds.east + bounds.west) / 2;
|
||||
|
||||
final latSpan = (bounds.north - bounds.south) * factor / 2;
|
||||
final lngSpan = (bounds.east - bounds.west) * factor / 2;
|
||||
|
||||
return LatLngBounds(
|
||||
LatLng(centerLat - latSpan, centerLng - lngSpan),
|
||||
LatLng(centerLat + latSpan, centerLng + lngSpan),
|
||||
);
|
||||
}
|
||||
|
||||
/// Add or update nodes in cache (for upload queue integration)
|
||||
void addOrUpdateNodes(List<OsmNode> nodes) {
|
||||
_cache.addOrUpdateNodes(nodes);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Remove node from cache (for deletions)
|
||||
void removeNodeById(int nodeId) {
|
||||
_cache.removeNodeById(nodeId);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear cache (when profiles change significantly)
|
||||
void clearCache() {
|
||||
_cache.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Force refresh for current view (manual retry)
|
||||
Future<void> refreshArea({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
}) async {
|
||||
// Clear any cached data for this area
|
||||
_cache.clear(); // Simple: clear everything for now
|
||||
|
||||
// Re-fetch
|
||||
await getNodesFor(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
isUserInitiated: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// NodeCache compatibility methods
|
||||
OsmNode? getNodeById(int nodeId) => _cache.getNodeById(nodeId);
|
||||
void removePendingEditMarker(int nodeId) => _cache.removePendingEditMarker(nodeId);
|
||||
void removePendingDeletionMarker(int nodeId) => _cache.removePendingDeletionMarker(nodeId);
|
||||
void removeTempNodeById(int tempNodeId) => _cache.removeTempNodeById(tempNodeId);
|
||||
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) =>
|
||||
_cache.findNodesWithinDistance(coord, distanceMeters, excludeNodeId: excludeNodeId);
|
||||
|
||||
/// Check if we have good cache coverage for the given area
|
||||
bool hasGoodCoverageFor(LatLngBounds bounds) {
|
||||
return _cache.hasDataFor(bounds);
|
||||
}
|
||||
|
||||
/// Load all offline nodes into cache (call at app startup)
|
||||
Future<void> preloadOfflineNodes() async {
|
||||
try {
|
||||
final offlineAreaService = OfflineAreaService();
|
||||
|
||||
for (final area in offlineAreaService.offlineAreas) {
|
||||
if (area.status != OfflineAreaStatus.complete) continue;
|
||||
|
||||
// Load nodes from this offline area
|
||||
final nodes = await fetchLocalNodes(
|
||||
bounds: area.bounds,
|
||||
profiles: [], // Empty profiles = load all nodes
|
||||
);
|
||||
|
||||
if (nodes.isNotEmpty) {
|
||||
_cache.addOrUpdateNodes(nodes);
|
||||
// Mark the offline area as having coverage so submit buttons work
|
||||
_cache.markAreaAsFetched(area.bounds, nodes);
|
||||
debugPrint('[NodeDataManager] Preloaded ${nodes.length} offline nodes from area ${area.name}');
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('[NodeDataManager] Error preloading offline nodes: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cache statistics
|
||||
String get cacheStats => _cache.stats.toString();
|
||||
}
|
||||
190
lib/services/node_spatial_cache.dart
Normal file
190
lib/services/node_spatial_cache.dart
Normal file
@@ -0,0 +1,190 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../models/osm_node.dart';
|
||||
|
||||
const Distance _distance = Distance();
|
||||
|
||||
/// Simple spatial cache that tracks which areas have been successfully fetched.
|
||||
/// No temporal expiration - data stays cached until app restart or explicit clear.
|
||||
class NodeSpatialCache {
|
||||
static final NodeSpatialCache _instance = NodeSpatialCache._();
|
||||
factory NodeSpatialCache() => _instance;
|
||||
NodeSpatialCache._();
|
||||
|
||||
final List<CachedArea> _fetchedAreas = [];
|
||||
final Map<int, OsmNode> _nodes = {}; // nodeId -> node
|
||||
|
||||
/// Check if we have cached data covering the given bounds
|
||||
bool hasDataFor(LatLngBounds bounds) {
|
||||
return _fetchedAreas.any((area) => area.bounds.containsBounds(bounds));
|
||||
}
|
||||
|
||||
/// Record that we successfully fetched data for this area
|
||||
void markAreaAsFetched(LatLngBounds bounds, List<OsmNode> nodes) {
|
||||
// Add the fetched area
|
||||
_fetchedAreas.add(CachedArea(bounds, DateTime.now()));
|
||||
|
||||
// Update nodes in cache
|
||||
for (final node in nodes) {
|
||||
_nodes[node.id] = node;
|
||||
}
|
||||
|
||||
debugPrint('[NodeSpatialCache] Cached ${nodes.length} nodes for area ${bounds.south.toStringAsFixed(3)},${bounds.west.toStringAsFixed(3)} to ${bounds.north.toStringAsFixed(3)},${bounds.east.toStringAsFixed(3)}');
|
||||
debugPrint('[NodeSpatialCache] Total areas cached: ${_fetchedAreas.length}, total nodes: ${_nodes.length}');
|
||||
}
|
||||
|
||||
/// Get all cached nodes within the given bounds
|
||||
List<OsmNode> getNodesFor(LatLngBounds bounds) {
|
||||
return _nodes.values
|
||||
.where((node) => bounds.contains(node.coord))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Add or update individual nodes (for upload queue integration)
|
||||
void addOrUpdateNodes(List<OsmNode> nodes) {
|
||||
for (final node in nodes) {
|
||||
final existing = _nodes[node.id];
|
||||
if (existing != null) {
|
||||
// Preserve any tags starting with underscore when updating existing nodes
|
||||
final mergedTags = Map<String, String>.from(node.tags);
|
||||
for (final entry in existing.tags.entries) {
|
||||
if (entry.key.startsWith('_')) {
|
||||
mergedTags[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
_nodes[node.id] = OsmNode(
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: mergedTags,
|
||||
isConstrained: node.isConstrained,
|
||||
);
|
||||
} else {
|
||||
_nodes[node.id] = node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a node by ID (for deletions)
|
||||
void removeNodeById(int nodeId) {
|
||||
if (_nodes.remove(nodeId) != null) {
|
||||
debugPrint('[NodeSpatialCache] Removed node $nodeId from cache');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a specific node by ID (returns null if not found)
|
||||
OsmNode? getNodeById(int nodeId) {
|
||||
return _nodes[nodeId];
|
||||
}
|
||||
|
||||
/// Remove the _pending_edit marker from a specific node
|
||||
void removePendingEditMarker(int nodeId) {
|
||||
final node = _nodes[nodeId];
|
||||
if (node != null && node.tags.containsKey('_pending_edit')) {
|
||||
final cleanTags = Map<String, String>.from(node.tags);
|
||||
cleanTags.remove('_pending_edit');
|
||||
|
||||
_nodes[nodeId] = OsmNode(
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: cleanTags,
|
||||
isConstrained: node.isConstrained,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the _pending_deletion marker from a specific node
|
||||
void removePendingDeletionMarker(int nodeId) {
|
||||
final node = _nodes[nodeId];
|
||||
if (node != null && node.tags.containsKey('_pending_deletion')) {
|
||||
final cleanTags = Map<String, String>.from(node.tags);
|
||||
cleanTags.remove('_pending_deletion');
|
||||
|
||||
_nodes[nodeId] = OsmNode(
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: cleanTags,
|
||||
isConstrained: node.isConstrained,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a specific temporary node by its ID
|
||||
void removeTempNodeById(int tempNodeId) {
|
||||
if (tempNodeId >= 0) {
|
||||
debugPrint('[NodeSpatialCache] Warning: Attempted to remove non-temp node ID $tempNodeId');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_nodes.remove(tempNodeId) != null) {
|
||||
debugPrint('[NodeSpatialCache] Removed temp node $tempNodeId from cache');
|
||||
}
|
||||
}
|
||||
|
||||
/// Find nodes within distance of a coordinate (for proximity warnings)
|
||||
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) {
|
||||
final nearbyNodes = <OsmNode>[];
|
||||
|
||||
for (final node in _nodes.values) {
|
||||
// Skip the excluded node
|
||||
if (excludeNodeId != null && node.id == excludeNodeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip nodes marked for deletion
|
||||
if (node.tags.containsKey('_pending_deletion')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final distanceInMeters = _distance.as(LengthUnit.Meter, coord, node.coord);
|
||||
if (distanceInMeters <= distanceMeters) {
|
||||
nearbyNodes.add(node);
|
||||
}
|
||||
}
|
||||
|
||||
return nearbyNodes;
|
||||
}
|
||||
|
||||
/// Clear all cached data
|
||||
void clear() {
|
||||
_fetchedAreas.clear();
|
||||
_nodes.clear();
|
||||
debugPrint('[NodeSpatialCache] Cache cleared');
|
||||
}
|
||||
|
||||
/// Get cache statistics for debugging
|
||||
CacheStats get stats => CacheStats(
|
||||
areasCount: _fetchedAreas.length,
|
||||
nodesCount: _nodes.length,
|
||||
);
|
||||
}
|
||||
|
||||
/// Represents an area that has been successfully fetched
|
||||
class CachedArea {
|
||||
final LatLngBounds bounds;
|
||||
final DateTime fetchedAt;
|
||||
|
||||
CachedArea(this.bounds, this.fetchedAt);
|
||||
}
|
||||
|
||||
/// Cache statistics for debugging
|
||||
class CacheStats {
|
||||
final int areasCount;
|
||||
final int nodesCount;
|
||||
|
||||
CacheStats({required this.areasCount, required this.nodesCount});
|
||||
|
||||
@override
|
||||
String toString() => 'CacheStats(areas: $areasCount, nodes: $nodesCount)';
|
||||
}
|
||||
|
||||
/// Extension to check if one bounds completely contains another
|
||||
extension LatLngBoundsExtension on LatLngBounds {
|
||||
bool containsBounds(LatLngBounds other) {
|
||||
return north >= other.north &&
|
||||
south <= other.south &&
|
||||
east >= other.east &&
|
||||
west <= other.west;
|
||||
}
|
||||
}
|
||||
187
lib/services/overpass_service.dart
Normal file
187
lib/services/overpass_service.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
/// Simple Overpass API client with proper HTTP retry logic.
|
||||
/// Single responsibility: Make requests, handle network errors, return data.
|
||||
class OverpassService {
|
||||
static const String _endpoint = 'https://overpass-api.de/api/interpreter';
|
||||
|
||||
/// Fetch surveillance nodes from Overpass API with proper retry logic.
|
||||
/// Throws NetworkError for retryable failures, NodeLimitError for area splitting.
|
||||
Future<List<OsmNode>> fetchNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
int maxRetries = 3,
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
final query = _buildQuery(bounds, profiles);
|
||||
|
||||
for (int attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
debugPrint('[OverpassService] Attempt ${attempt + 1}/${maxRetries + 1} for ${profiles.length} profiles');
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(_endpoint),
|
||||
body: {'data': query},
|
||||
).timeout(kOverpassQueryTimeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return _parseResponse(response.body);
|
||||
}
|
||||
|
||||
// Check for specific error types
|
||||
final errorBody = response.body;
|
||||
|
||||
// Node limit error - caller should split area
|
||||
if (response.statusCode == 400 &&
|
||||
(errorBody.contains('too many nodes') && errorBody.contains('50000'))) {
|
||||
debugPrint('[OverpassService] Node limit exceeded, area should be split');
|
||||
throw NodeLimitError('Query exceeded 50k node limit');
|
||||
}
|
||||
|
||||
// Timeout error - also try splitting (complex query)
|
||||
if (errorBody.contains('timeout') ||
|
||||
errorBody.contains('runtime limit exceeded') ||
|
||||
errorBody.contains('Query timed out')) {
|
||||
debugPrint('[OverpassService] Query timeout, area should be split');
|
||||
throw NodeLimitError('Query timed out - area too complex');
|
||||
}
|
||||
|
||||
// Rate limit - throw immediately, don't retry
|
||||
if (response.statusCode == 429 ||
|
||||
errorBody.contains('rate limited') ||
|
||||
errorBody.contains('too many requests')) {
|
||||
debugPrint('[OverpassService] Rate limited by Overpass');
|
||||
throw RateLimitError('Rate limited by Overpass API');
|
||||
}
|
||||
|
||||
// Other HTTP errors - retry with backoff
|
||||
if (attempt < maxRetries) {
|
||||
final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000));
|
||||
debugPrint('[OverpassService] HTTP ${response.statusCode} error, retrying in ${delay.inMilliseconds}ms');
|
||||
await Future.delayed(delay);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw NetworkError('HTTP ${response.statusCode}: $errorBody');
|
||||
|
||||
} catch (e) {
|
||||
// Handle specific error types without retry
|
||||
if (e is NodeLimitError || e is RateLimitError) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
// Network/timeout errors - retry with backoff
|
||||
if (attempt < maxRetries) {
|
||||
final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000));
|
||||
debugPrint('[OverpassService] Network error ($e), retrying in ${delay.inMilliseconds}ms');
|
||||
await Future.delayed(delay);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw NetworkError('Network error after $maxRetries retries: $e');
|
||||
}
|
||||
}
|
||||
|
||||
throw NetworkError('Max retries exceeded');
|
||||
}
|
||||
|
||||
/// Build Overpass QL query for given bounds and profiles
|
||||
String _buildQuery(LatLngBounds bounds, List<NodeProfile> profiles) {
|
||||
final nodeClauses = profiles.map((profile) {
|
||||
// Convert profile tags to Overpass filter format, excluding empty values
|
||||
final tagFilters = profile.tags.entries
|
||||
.where((entry) => entry.value.trim().isNotEmpty)
|
||||
.map((entry) => '["${entry.key}"="${entry.value}"]')
|
||||
.join();
|
||||
|
||||
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
|
||||
}).join('\n ');
|
||||
|
||||
return '''
|
||||
[out:json][timeout:${kOverpassQueryTimeout.inSeconds}];
|
||||
(
|
||||
$nodeClauses
|
||||
);
|
||||
out body;
|
||||
(
|
||||
way(bn);
|
||||
rel(bn);
|
||||
);
|
||||
out meta;
|
||||
''';
|
||||
}
|
||||
|
||||
/// Parse Overpass JSON response into OsmNode objects
|
||||
List<OsmNode> _parseResponse(String responseBody) {
|
||||
final data = jsonDecode(responseBody) as Map<String, dynamic>;
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
|
||||
final nodeElements = <Map<String, dynamic>>[];
|
||||
final constrainedNodeIds = <int>{};
|
||||
|
||||
// First pass: collect surveillance nodes and identify constrained nodes
|
||||
for (final element in elements.whereType<Map<String, dynamic>>()) {
|
||||
final type = element['type'] as String?;
|
||||
|
||||
if (type == 'node') {
|
||||
nodeElements.add(element);
|
||||
} else if (type == 'way' || type == 'relation') {
|
||||
// Mark referenced nodes as constrained
|
||||
final refs = element['nodes'] as List<dynamic>? ??
|
||||
element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? [];
|
||||
|
||||
for (final ref in refs) {
|
||||
final nodeId = ref is int ? ref : int.tryParse(ref.toString());
|
||||
if (nodeId != null) constrainedNodeIds.add(nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: create OsmNode objects
|
||||
final nodes = nodeElements.map((element) {
|
||||
final nodeId = element['id'] as int;
|
||||
return OsmNode(
|
||||
id: nodeId,
|
||||
coord: LatLng(element['lat'], element['lon']),
|
||||
tags: Map<String, String>.from(element['tags'] ?? {}),
|
||||
isConstrained: constrainedNodeIds.contains(nodeId),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
debugPrint('[OverpassService] Parsed ${nodes.length} nodes, ${constrainedNodeIds.length} constrained');
|
||||
return nodes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Error thrown when query exceeds node limits or is too complex - area should be split
|
||||
class NodeLimitError extends Error {
|
||||
final String message;
|
||||
NodeLimitError(this.message);
|
||||
@override
|
||||
String toString() => 'NodeLimitError: $message';
|
||||
}
|
||||
|
||||
/// Error thrown when rate limited - should not retry immediately
|
||||
class RateLimitError extends Error {
|
||||
final String message;
|
||||
RateLimitError(this.message);
|
||||
@override
|
||||
String toString() => 'RateLimitError: $message';
|
||||
}
|
||||
|
||||
/// Error thrown for network/HTTP issues - retryable
|
||||
class NetworkError extends Error {
|
||||
final String message;
|
||||
NetworkError(this.message);
|
||||
@override
|
||||
String toString() => 'NetworkError: $message';
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import 'map_data_submodules/nodes_from_overpass.dart';
|
||||
import 'node_cache.dart';
|
||||
import 'network_status.dart';
|
||||
import '../widgets/node_provider_with_cache.dart';
|
||||
|
||||
/// Manages pre-fetching larger areas to reduce Overpass API calls.
|
||||
/// Uses zoom level 10 areas and automatically splits if hitting node limits.
|
||||
class PrefetchAreaService {
|
||||
static final PrefetchAreaService _instance = PrefetchAreaService._();
|
||||
factory PrefetchAreaService() => _instance;
|
||||
PrefetchAreaService._();
|
||||
|
||||
// Current pre-fetched area and associated data
|
||||
LatLngBounds? _preFetchedArea;
|
||||
List<NodeProfile>? _preFetchedProfiles;
|
||||
UploadMode? _preFetchedUploadMode;
|
||||
DateTime? _lastFetchTime;
|
||||
bool _preFetchInProgress = false;
|
||||
|
||||
// Debounce timer to avoid rapid requests while user is panning
|
||||
Timer? _debounceTimer;
|
||||
|
||||
// Configuration from dev_config
|
||||
static const double _areaExpansionMultiplier = kPreFetchAreaExpansionMultiplier;
|
||||
static const int _preFetchZoomLevel = kPreFetchZoomLevel;
|
||||
|
||||
/// Check if the given bounds are fully within the current pre-fetched area.
|
||||
bool isWithinPreFetchedArea(LatLngBounds bounds, List<NodeProfile> profiles, UploadMode uploadMode) {
|
||||
if (_preFetchedArea == null || _preFetchedProfiles == null || _preFetchedUploadMode == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if profiles and upload mode match
|
||||
if (_preFetchedUploadMode != uploadMode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_profileListsEqual(_preFetchedProfiles!, profiles)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if bounds are fully contained within pre-fetched area
|
||||
return bounds.north <= _preFetchedArea!.north &&
|
||||
bounds.south >= _preFetchedArea!.south &&
|
||||
bounds.east <= _preFetchedArea!.east &&
|
||||
bounds.west >= _preFetchedArea!.west;
|
||||
}
|
||||
|
||||
/// Check if cached data is stale (older than configured refresh interval).
|
||||
bool isDataStale() {
|
||||
if (_lastFetchTime == null) return true;
|
||||
return DateTime.now().difference(_lastFetchTime!).inSeconds > kDataRefreshIntervalSeconds;
|
||||
}
|
||||
|
||||
/// Request pre-fetch for the given view bounds if not already covered or if data is stale.
|
||||
/// Uses debouncing to avoid rapid requests while user is panning.
|
||||
void requestPreFetchIfNeeded({
|
||||
required LatLngBounds viewBounds,
|
||||
required List<NodeProfile> profiles,
|
||||
required UploadMode uploadMode,
|
||||
}) {
|
||||
// Skip if already in progress
|
||||
if (_preFetchInProgress) {
|
||||
debugPrint('[PrefetchAreaService] Pre-fetch already in progress, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check both spatial and temporal conditions
|
||||
final isWithinArea = isWithinPreFetchedArea(viewBounds, profiles, uploadMode);
|
||||
final isStale = isDataStale();
|
||||
|
||||
if (isWithinArea && !isStale) {
|
||||
debugPrint('[PrefetchAreaService] Current view within fresh pre-fetched area, no fetch needed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStale) {
|
||||
debugPrint('[PrefetchAreaService] Data is stale (>${kDataRefreshIntervalSeconds}s), refreshing');
|
||||
} else {
|
||||
debugPrint('[PrefetchAreaService] Current view outside pre-fetched area, fetching larger area');
|
||||
}
|
||||
|
||||
// Cancel any pending debounced request
|
||||
_debounceTimer?.cancel();
|
||||
|
||||
// Debounce to avoid rapid requests while user is still moving
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 800), () {
|
||||
_startPreFetch(
|
||||
viewBounds: viewBounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Start the actual pre-fetch operation.
|
||||
Future<void> _startPreFetch({
|
||||
required LatLngBounds viewBounds,
|
||||
required List<NodeProfile> profiles,
|
||||
required UploadMode uploadMode,
|
||||
}) async {
|
||||
if (_preFetchInProgress) return;
|
||||
|
||||
_preFetchInProgress = true;
|
||||
|
||||
try {
|
||||
// Calculate expanded area for pre-fetching
|
||||
final preFetchArea = _expandBounds(viewBounds, _areaExpansionMultiplier);
|
||||
|
||||
debugPrint('[PrefetchAreaService] Starting pre-fetch for area: ${preFetchArea.south},${preFetchArea.west} to ${preFetchArea.north},${preFetchArea.east}');
|
||||
|
||||
// Fetch nodes for the expanded area (unlimited - let splitting handle 50k limit)
|
||||
final nodes = await fetchOverpassNodes(
|
||||
bounds: preFetchArea,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: 0, // Unlimited - our splitting system handles the 50k limit gracefully
|
||||
);
|
||||
|
||||
debugPrint('[PrefetchAreaService] Pre-fetch completed: ${nodes.length} nodes retrieved');
|
||||
|
||||
// Update cache with new nodes (fresh data overwrites stale, but preserves underscore tags)
|
||||
if (nodes.isNotEmpty) {
|
||||
NodeCache.instance.addOrUpdate(nodes);
|
||||
}
|
||||
|
||||
// Store the pre-fetched area info and timestamp
|
||||
_preFetchedArea = preFetchArea;
|
||||
_preFetchedProfiles = List.from(profiles);
|
||||
_preFetchedUploadMode = uploadMode;
|
||||
_lastFetchTime = DateTime.now();
|
||||
|
||||
// The overpass module already reported success/failure during fetching
|
||||
// We just need to handle the successful result here
|
||||
|
||||
// Notify UI that cache has been updated with fresh data
|
||||
NodeProviderWithCache.instance.refreshDisplay();
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[PrefetchAreaService] Pre-fetch failed: $e');
|
||||
// The overpass module already reported the error status
|
||||
// Don't update pre-fetched area info on failure
|
||||
} finally {
|
||||
_preFetchInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand bounds by the given multiplier, maintaining center point.
|
||||
LatLngBounds _expandBounds(LatLngBounds bounds, double multiplier) {
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
final centerLng = (bounds.east + bounds.west) / 2;
|
||||
|
||||
final latSpan = (bounds.north - bounds.south) * multiplier / 2;
|
||||
final lngSpan = (bounds.east - bounds.west) * multiplier / 2;
|
||||
|
||||
return LatLngBounds(
|
||||
LatLng(centerLat - latSpan, centerLng - lngSpan), // Southwest
|
||||
LatLng(centerLat + latSpan, centerLng + lngSpan), // Northeast
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if two profile lists are equal by comparing IDs.
|
||||
bool _profileListsEqual(List<NodeProfile> list1, List<NodeProfile> list2) {
|
||||
if (list1.length != list2.length) return false;
|
||||
final ids1 = list1.map((p) => p.id).toSet();
|
||||
final ids2 = list2.map((p) => p.id).toSet();
|
||||
return ids1.length == ids2.length && ids1.containsAll(ids2);
|
||||
}
|
||||
|
||||
/// Clear the pre-fetched area (e.g., when profiles change significantly).
|
||||
void clearPreFetchedArea() {
|
||||
_preFetchedArea = null;
|
||||
_preFetchedProfiles = null;
|
||||
_preFetchedUploadMode = null;
|
||||
_lastFetchTime = null;
|
||||
debugPrint('[PrefetchAreaService] Pre-fetched area cleared');
|
||||
}
|
||||
|
||||
/// Dispose of resources.
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
}
|
||||
}
|
||||
120
lib/services/profile_import_service.dart
Normal file
120
lib/services/profile_import_service.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
|
||||
class ProfileImportService {
|
||||
// Maximum size for base64 encoded profile data (approx 50KB decoded)
|
||||
static const int maxBase64Length = 70000;
|
||||
|
||||
/// Parse and validate a profile from a base64-encoded JSON string
|
||||
/// Returns null if parsing/validation fails
|
||||
static NodeProfile? parseProfileFromBase64(String base64Data) {
|
||||
try {
|
||||
// Basic size validation before expensive decode
|
||||
if (base64Data.length > maxBase64Length) {
|
||||
debugPrint('[ProfileImportService] Base64 data too large: ${base64Data.length} characters');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decode base64
|
||||
final jsonBytes = base64Decode(base64Data);
|
||||
final jsonString = utf8.decode(jsonBytes);
|
||||
|
||||
// Parse JSON
|
||||
final jsonData = jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
|
||||
// Validate and sanitize the profile data
|
||||
final sanitizedProfile = _validateAndSanitizeProfile(jsonData);
|
||||
return sanitizedProfile;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[ProfileImportService] Failed to parse profile from base64: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate profile structure and sanitize all string values
|
||||
static NodeProfile? _validateAndSanitizeProfile(Map<String, dynamic> data) {
|
||||
try {
|
||||
// Extract and sanitize required fields
|
||||
final name = _sanitizeString(data['name']);
|
||||
if (name == null || name.isEmpty) {
|
||||
debugPrint('[ProfileImportService] Profile name is required');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract and sanitize tags
|
||||
final tagsData = data['tags'];
|
||||
if (tagsData is! Map<String, dynamic>) {
|
||||
debugPrint('[ProfileImportService] Profile tags must be a map');
|
||||
return null;
|
||||
}
|
||||
|
||||
final sanitizedTags = <String, String>{};
|
||||
for (final entry in tagsData.entries) {
|
||||
final key = _sanitizeString(entry.key);
|
||||
final value = _sanitizeString(entry.value);
|
||||
|
||||
if (key != null && key.isNotEmpty) {
|
||||
// Allow empty values for refinement purposes
|
||||
sanitizedTags[key] = value ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitizedTags.isEmpty) {
|
||||
debugPrint('[ProfileImportService] Profile must have at least one valid tag');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract optional fields with defaults
|
||||
final requiresDirection = data['requiresDirection'] ?? true;
|
||||
final submittable = data['submittable'] ?? true;
|
||||
|
||||
// Parse FOV if provided
|
||||
double? fov;
|
||||
if (data['fov'] != null) {
|
||||
if (data['fov'] is num) {
|
||||
final fovValue = (data['fov'] as num).toDouble();
|
||||
if (fovValue > 0 && fovValue <= 360) {
|
||||
fov = fovValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NodeProfile(
|
||||
id: const Uuid().v4(), // Always generate new ID for imported profiles
|
||||
name: name,
|
||||
tags: sanitizedTags,
|
||||
builtin: false, // Imported profiles are always custom
|
||||
requiresDirection: requiresDirection is bool ? requiresDirection : true,
|
||||
submittable: submittable is bool ? submittable : true,
|
||||
editable: true, // Imported profiles are always editable
|
||||
fov: fov,
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[ProfileImportService] Failed to validate profile: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize a string value by trimming and removing potentially harmful characters
|
||||
static String? _sanitizeString(dynamic value) {
|
||||
if (value == null) return null;
|
||||
|
||||
final str = value.toString().trim();
|
||||
|
||||
// Remove control characters and limit length
|
||||
final sanitized = str.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '');
|
||||
|
||||
// Limit length to prevent abuse
|
||||
const maxLength = 500;
|
||||
if (sanitized.length > maxLength) {
|
||||
return sanitized.substring(0, maxLength);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
@@ -12,19 +13,19 @@ class SearchService {
|
||||
static const Duration _timeout = Duration(seconds: 10);
|
||||
|
||||
/// Search for places using Nominatim geocoding service
|
||||
Future<List<SearchResult>> search(String query) async {
|
||||
Future<List<SearchResult>> search(String query, {LatLngBounds? viewbox}) async {
|
||||
if (query.trim().isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
// Check if query looks like coordinates first
|
||||
final coordResult = _tryParseCoordinates(query.trim());
|
||||
if (coordResult != null) {
|
||||
return [coordResult];
|
||||
}
|
||||
|
||||
|
||||
// Otherwise, use Nominatim API
|
||||
return await _searchNominatim(query.trim());
|
||||
return await _searchNominatim(query.trim(), viewbox: viewbox);
|
||||
}
|
||||
|
||||
/// Try to parse various coordinate formats
|
||||
@@ -52,14 +53,37 @@ class SearchService {
|
||||
}
|
||||
|
||||
/// Search using Nominatim API
|
||||
Future<List<SearchResult>> _searchNominatim(String query) async {
|
||||
final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: {
|
||||
Future<List<SearchResult>> _searchNominatim(String query, {LatLngBounds? viewbox}) async {
|
||||
final params = {
|
||||
'q': query,
|
||||
'format': 'json',
|
||||
'limit': _maxResults.toString(),
|
||||
'addressdetails': '1',
|
||||
'extratags': '1',
|
||||
});
|
||||
};
|
||||
|
||||
if (viewbox != null) {
|
||||
double round1(double v) => (v * 10).round() / 10;
|
||||
var west = round1(viewbox.west);
|
||||
var east = round1(viewbox.east);
|
||||
var south = round1(viewbox.south);
|
||||
var north = round1(viewbox.north);
|
||||
|
||||
if (east - west < 0.5) {
|
||||
final mid = (east + west) / 2;
|
||||
west = mid - 0.25;
|
||||
east = mid + 0.25;
|
||||
}
|
||||
if (north - south < 0.5) {
|
||||
final mid = (north + south) / 2;
|
||||
south = mid - 0.25;
|
||||
north = mid + 0.25;
|
||||
}
|
||||
|
||||
params['viewbox'] = '$west,$north,$east,$south';
|
||||
}
|
||||
|
||||
final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: params);
|
||||
|
||||
debugPrint('[SearchService] Searching Nominatim: $uri');
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/search_result.dart';
|
||||
@@ -31,7 +32,8 @@ class NavigationState extends ChangeNotifier {
|
||||
bool _isSearchLoading = false;
|
||||
List<SearchResult> _searchResults = [];
|
||||
String _lastQuery = '';
|
||||
|
||||
LatLngBounds? _searchViewbox;
|
||||
|
||||
// Location state
|
||||
LatLng? _provisionalPinLocation;
|
||||
String? _provisionalPinAddress;
|
||||
@@ -106,19 +108,20 @@ class NavigationState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// BRUTALIST: Single entry point to search mode
|
||||
void enterSearchMode(LatLng mapCenter) {
|
||||
void enterSearchMode(LatLng mapCenter, {LatLngBounds? viewbox}) {
|
||||
debugPrint('[NavigationState] enterSearchMode - current mode: $_mode');
|
||||
|
||||
|
||||
if (_mode != AppNavigationMode.normal) {
|
||||
debugPrint('[NavigationState] Cannot enter search mode - not in normal mode');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
_mode = AppNavigationMode.search;
|
||||
_provisionalPinLocation = mapCenter;
|
||||
_provisionalPinAddress = null;
|
||||
_searchViewbox = viewbox;
|
||||
_clearSearchResults();
|
||||
|
||||
|
||||
debugPrint('[NavigationState] Entered search mode');
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -149,7 +152,8 @@ class NavigationState extends ChangeNotifier {
|
||||
_showingOverview = false;
|
||||
_nextPointIsStart = false;
|
||||
_routingError = null;
|
||||
|
||||
_searchViewbox = null;
|
||||
|
||||
// Clear search
|
||||
_clearSearchResults();
|
||||
|
||||
@@ -336,21 +340,21 @@ class NavigationState extends ChangeNotifier {
|
||||
_clearSearchResults();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (query.trim() == _lastQuery.trim()) return;
|
||||
|
||||
|
||||
_setSearchLoading(true);
|
||||
_lastQuery = query.trim();
|
||||
|
||||
|
||||
try {
|
||||
final results = await _searchService.search(query.trim());
|
||||
final results = await _searchService.search(query.trim(), viewbox: _searchViewbox);
|
||||
_searchResults = results;
|
||||
debugPrint('[NavigationState] Found ${results.length} results');
|
||||
} catch (e) {
|
||||
debugPrint('[NavigationState] Search failed: $e');
|
||||
_searchResults = [];
|
||||
}
|
||||
|
||||
|
||||
_setSearchLoading(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../models/pending_upload.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../services/node_cache.dart';
|
||||
import '../services/map_data_provider.dart';
|
||||
import '../services/uploader.dart';
|
||||
import '../widgets/node_provider_with_cache.dart';
|
||||
import '../dev_config.dart';
|
||||
@@ -15,6 +15,8 @@ import 'settings_state.dart';
|
||||
import 'session_state.dart';
|
||||
|
||||
class UploadQueueState extends ChangeNotifier {
|
||||
/// Helper to access the map data provider instance
|
||||
MapDataProvider get _nodeCache => MapDataProvider();
|
||||
final List<PendingUpload> _queue = [];
|
||||
Timer? _uploadTimer;
|
||||
int _activeUploadCount = 0;
|
||||
@@ -48,7 +50,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
if (upload.isDeletion) {
|
||||
// For deletions: mark the original node as pending deletion if it exists in cache
|
||||
if (upload.originalNodeId != null) {
|
||||
final existingNode = NodeCache.instance.getNodeById(upload.originalNodeId!);
|
||||
final existingNode = _nodeCache.getNodeById(upload.originalNodeId!);
|
||||
if (existingNode != null) {
|
||||
final deletionTags = Map<String, String>.from(existingNode.tags);
|
||||
deletionTags['_pending_deletion'] = 'true';
|
||||
@@ -78,7 +80,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
if (upload.isEdit) {
|
||||
// For edits: also mark original with _pending_edit if it exists
|
||||
if (upload.originalNodeId != null) {
|
||||
final existingOriginal = NodeCache.instance.getNodeById(upload.originalNodeId!);
|
||||
final existingOriginal = _nodeCache.getNodeById(upload.originalNodeId!);
|
||||
if (existingOriginal != null) {
|
||||
final originalTags = Map<String, String>.from(existingOriginal.tags);
|
||||
originalTags['_pending_edit'] = 'true';
|
||||
@@ -109,7 +111,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
if (nodesToAdd.isNotEmpty) {
|
||||
NodeCache.instance.addOrUpdate(nodesToAdd);
|
||||
_nodeCache.addOrUpdate(nodesToAdd);
|
||||
print('[UploadQueue] Repopulated cache with ${nodesToAdd.length} pending nodes from queue');
|
||||
|
||||
// Save queue if we updated any temp IDs for backward compatibility
|
||||
@@ -152,7 +154,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
tags: tags,
|
||||
);
|
||||
|
||||
NodeCache.instance.addOrUpdate([tempNode]);
|
||||
_nodeCache.addOrUpdate([tempNode]);
|
||||
// Notify node provider to update the map
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
|
||||
@@ -211,7 +213,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
tags: extractedTags,
|
||||
);
|
||||
|
||||
NodeCache.instance.addOrUpdate([extractedNode]);
|
||||
_nodeCache.addOrUpdate([extractedNode]);
|
||||
} else {
|
||||
// For modify: mark original with grey ring and create new temp node
|
||||
// 1. Mark the original node with _pending_edit (grey ring) at original location
|
||||
@@ -240,7 +242,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
tags: editedTags,
|
||||
);
|
||||
|
||||
NodeCache.instance.addOrUpdate([originalNode, editedNode]);
|
||||
_nodeCache.addOrUpdate([originalNode, editedNode]);
|
||||
}
|
||||
// Notify node provider to update the map
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
@@ -272,7 +274,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
tags: deletionTags,
|
||||
);
|
||||
|
||||
NodeCache.instance.addOrUpdate([nodeWithDeletionTag]);
|
||||
_nodeCache.addOrUpdate([nodeWithDeletionTag]);
|
||||
// Notify node provider to update the map
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
|
||||
@@ -693,11 +695,11 @@ class UploadQueueState extends ChangeNotifier {
|
||||
);
|
||||
|
||||
// Add/update the cache with the real node
|
||||
NodeCache.instance.addOrUpdate([realNode]);
|
||||
_nodeCache.addOrUpdate([realNode]);
|
||||
|
||||
// Clean up the specific temp node for this upload
|
||||
if (item.tempNodeId != null) {
|
||||
NodeCache.instance.removeTempNodeById(item.tempNodeId!);
|
||||
_nodeCache.removeTempNodeById(item.tempNodeId!);
|
||||
}
|
||||
|
||||
// For modify operations, clean up the original node's _pending_edit marker
|
||||
@@ -705,7 +707,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
if (item.isEdit && item.originalNodeId != null) {
|
||||
// Remove the _pending_edit marker from the original node in cache
|
||||
// The next Overpass fetch will provide the authoritative data anyway
|
||||
NodeCache.instance.removePendingEditMarker(item.originalNodeId!);
|
||||
_nodeCache.removePendingEditMarker(item.originalNodeId!);
|
||||
}
|
||||
|
||||
// Notify node provider to update the map
|
||||
@@ -716,7 +718,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
void _handleSuccessfulDeletion(PendingUpload item) {
|
||||
if (item.originalNodeId != null) {
|
||||
// Remove the node from cache entirely
|
||||
NodeCache.instance.removeNodeById(item.originalNodeId!);
|
||||
_nodeCache.removeNodeById(item.originalNodeId!);
|
||||
|
||||
// Notify node provider to update the map
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
@@ -743,6 +745,11 @@ class UploadQueueState extends ChangeNotifier {
|
||||
|
||||
// Convert a center direction and FOV to range notation (e.g., 180° center with 90° FOV -> "135-225")
|
||||
String _formatDirectionWithFov(double center, double fov) {
|
||||
// Handle 360-degree FOV as special case
|
||||
if (fov >= 360) {
|
||||
return '0-360';
|
||||
}
|
||||
|
||||
final halfFov = fov / 2;
|
||||
final start = (center - halfFov + 360) % 360;
|
||||
final end = (center + halfFov) % 360;
|
||||
@@ -755,25 +762,25 @@ class UploadQueueState extends ChangeNotifier {
|
||||
if (upload.isDeletion) {
|
||||
// For deletions: remove the _pending_deletion marker from the original node
|
||||
if (upload.originalNodeId != null) {
|
||||
NodeCache.instance.removePendingDeletionMarker(upload.originalNodeId!);
|
||||
_nodeCache.removePendingDeletionMarker(upload.originalNodeId!);
|
||||
}
|
||||
} else if (upload.isEdit) {
|
||||
// For edits: remove the specific temp node and the _pending_edit marker from original
|
||||
if (upload.tempNodeId != null) {
|
||||
NodeCache.instance.removeTempNodeById(upload.tempNodeId!);
|
||||
_nodeCache.removeTempNodeById(upload.tempNodeId!);
|
||||
}
|
||||
if (upload.originalNodeId != null) {
|
||||
NodeCache.instance.removePendingEditMarker(upload.originalNodeId!);
|
||||
_nodeCache.removePendingEditMarker(upload.originalNodeId!);
|
||||
}
|
||||
} else if (upload.operation == UploadOperation.extract) {
|
||||
// For extracts: remove the specific temp node (leave original unchanged)
|
||||
if (upload.tempNodeId != null) {
|
||||
NodeCache.instance.removeTempNodeById(upload.tempNodeId!);
|
||||
_nodeCache.removeTempNodeById(upload.tempNodeId!);
|
||||
}
|
||||
} else {
|
||||
// For creates: remove the specific temp node
|
||||
if (upload.tempNodeId != null) {
|
||||
NodeCache.instance.removeTempNodeById(upload.tempNodeId!);
|
||||
_nodeCache.removeTempNodeById(upload.tempNodeId!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/node_cache.dart';
|
||||
import '../services/map_data_provider.dart';
|
||||
import '../services/node_data_manager.dart';
|
||||
import '../services/changelog_service.dart';
|
||||
import 'refine_tags_sheet.dart';
|
||||
import 'proximity_warning_dialog.dart';
|
||||
@@ -30,6 +34,13 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkTutorialStatus();
|
||||
// Listen to node data manager for cache updates
|
||||
NodeDataManager().addListener(_onCacheUpdated);
|
||||
}
|
||||
|
||||
void _onCacheUpdated() {
|
||||
// Rebuild when cache updates (e.g., when new data loads)
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _checkTutorialStatus() async {
|
||||
@@ -75,6 +86,9 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Remove listener
|
||||
NodeDataManager().removeListener(_onCacheUpdated);
|
||||
|
||||
// Clear tutorial callback when widget is disposed
|
||||
if (_showTutorial) {
|
||||
try {
|
||||
@@ -120,7 +134,7 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
|
||||
}
|
||||
|
||||
// Check for nearby nodes within the configured distance
|
||||
final nearbyNodes = NodeCache.instance.findNodesWithinDistance(
|
||||
final nearbyNodes = MapDataProvider().findNodesWithinDistance(
|
||||
widget.session.target!,
|
||||
kNodeProximityWarningDistance,
|
||||
);
|
||||
@@ -155,8 +169,111 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProfileDropdown(BuildContext context, AppState appState, AddNodeSession session, List<NodeProfile> submittableProfiles, LocalizationService locService) {
|
||||
return PopupMenuButton<String>(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
session.profile?.name ?? locService.t('addNode.selectProfile'),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: session.profile != null ? null : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.arrow_drop_down, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
itemBuilder: (context) => [
|
||||
// Regular profiles
|
||||
...submittableProfiles.map(
|
||||
(profile) => PopupMenuItem<String>(
|
||||
value: 'profile_${profile.id}',
|
||||
child: Text(profile.name),
|
||||
),
|
||||
),
|
||||
// Divider
|
||||
if (submittableProfiles.isNotEmpty) const PopupMenuDivider(),
|
||||
// Get more... option
|
||||
PopupMenuItem<String>(
|
||||
value: 'get_more',
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.language, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
locService.t('profiles.getMore'),
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'get_more') {
|
||||
_openIdentifyWebsite(context);
|
||||
} else if (value.startsWith('profile_')) {
|
||||
final profileId = value.substring(8); // Remove 'profile_' prefix
|
||||
final profile = submittableProfiles.firstWhere((p) => p.id == profileId);
|
||||
appState.updateSession(profile: profile);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _openIdentifyWebsite(BuildContext context) async {
|
||||
const url = 'https://deflock.me/identify';
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication, // Force external browser
|
||||
);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
_showErrorSnackBar(context, 'Unable to open website');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
_showErrorSnackBar(context, 'Error opening website: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showErrorSnackBar(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDirectionControls(BuildContext context, AppState appState, AddNodeSession session, LocalizationService locService) {
|
||||
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
|
||||
final is360Fov = session.profile?.fov == 360;
|
||||
final enableDirectionControls = requiresDirection && !is360Fov;
|
||||
|
||||
// Force direction to 0 when FOV is 360 (omnidirectional)
|
||||
if (is360Fov && session.directionDegrees != 0) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
appState.updateSession(directionDeg: 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Format direction display text with bold for current direction
|
||||
String directionsText = '';
|
||||
@@ -206,7 +323,7 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
|
||||
divisions: 359,
|
||||
value: session.directionDegrees,
|
||||
label: session.directionDegrees.round().toString(),
|
||||
onChanged: requiresDirection ? (v) => appState.updateSession(directionDeg: v) : null,
|
||||
onChanged: enableDirectionControls ? (v) => appState.updateSession(directionDeg: v) : null,
|
||||
),
|
||||
),
|
||||
// Direction control buttons - always show but grey out when direction not required
|
||||
@@ -216,9 +333,9 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
|
||||
icon: Icon(
|
||||
Icons.remove,
|
||||
size: 20,
|
||||
color: requiresDirection ? null : Theme.of(context).disabledColor,
|
||||
color: enableDirectionControls ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
onPressed: requiresDirection && session.directions.length > 1
|
||||
onPressed: enableDirectionControls && session.directions.length > 1
|
||||
? () => appState.removeDirection()
|
||||
: null,
|
||||
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
|
||||
@@ -230,9 +347,9 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
|
||||
icon: Icon(
|
||||
Icons.add,
|
||||
size: 20,
|
||||
color: requiresDirection && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
|
||||
color: enableDirectionControls && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
onPressed: requiresDirection && session.directions.length < 8 ? () => appState.addDirection() : null,
|
||||
onPressed: enableDirectionControls && session.directions.length < 8 ? () => appState.addDirection() : null,
|
||||
tooltip: requiresDirection
|
||||
? (session.directions.length >= 8 ? 'Maximum 8 directions allowed' : 'Add new direction')
|
||||
: 'Direction not required for this profile',
|
||||
@@ -244,9 +361,9 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
|
||||
icon: Icon(
|
||||
Icons.repeat,
|
||||
size: 20,
|
||||
color: requiresDirection ? null : Theme.of(context).disabledColor,
|
||||
color: enableDirectionControls ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
onPressed: requiresDirection && session.directions.length > 1
|
||||
onPressed: enableDirectionControls && session.directions.length > 1
|
||||
? () => appState.cycleDirection()
|
||||
: null,
|
||||
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',
|
||||
@@ -296,10 +413,34 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
|
||||
|
||||
final session = widget.session;
|
||||
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
|
||||
// Check if we have good cache coverage around the node position
|
||||
bool hasGoodCoverage = true;
|
||||
if (session.target != null) {
|
||||
// Create a small bounds around the target position to check coverage
|
||||
const double bufferDegrees = 0.001; // ~100m buffer
|
||||
final targetBounds = LatLngBounds(
|
||||
LatLng(session.target!.latitude - bufferDegrees, session.target!.longitude - bufferDegrees),
|
||||
LatLng(session.target!.latitude + bufferDegrees, session.target!.longitude + bufferDegrees),
|
||||
);
|
||||
hasGoodCoverage = MapDataProvider().hasGoodCoverageFor(targetBounds);
|
||||
|
||||
// If strict coverage check fails, fall back to checking if we have any nodes nearby
|
||||
// This handles the timing issue where cache might not be marked as "covered" yet
|
||||
if (!hasGoodCoverage) {
|
||||
final nearbyNodes = MapDataProvider().findNodesWithinDistance(
|
||||
session.target!,
|
||||
200.0, // 200m radius - if we have nodes nearby, we likely have good data
|
||||
);
|
||||
hasGoodCoverage = nearbyNodes.isNotEmpty;
|
||||
}
|
||||
}
|
||||
|
||||
final allowSubmit = appState.isLoggedIn &&
|
||||
submittableProfiles.isNotEmpty &&
|
||||
session.profile != null &&
|
||||
session.profile!.isSubmittable;
|
||||
session.profile!.isSubmittable &&
|
||||
hasGoodCoverage;
|
||||
|
||||
void _navigateToLogin() {
|
||||
Navigator.pushNamed(context, '/settings/osm-account');
|
||||
@@ -344,14 +485,7 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: Text(locService.t('addNode.profile')),
|
||||
trailing: DropdownButton<NodeProfile?>(
|
||||
value: session.profile,
|
||||
hint: Text(locService.t('addNode.selectProfile')),
|
||||
items: submittableProfiles
|
||||
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
|
||||
.toList(),
|
||||
onChanged: (p) => appState.updateSession(profile: p),
|
||||
),
|
||||
trailing: _buildProfileDropdown(context, appState, session, submittableProfiles, locService),
|
||||
),
|
||||
// Direction controls
|
||||
_buildDirectionControls(context, appState, session, locService),
|
||||
@@ -419,6 +553,22 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (!hasGoodCoverage)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.cloud_download, color: Colors.blue, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('addNode.loadingAreaData'),
|
||||
style: const TextStyle(color: Colors.blue, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/node_cache.dart';
|
||||
import '../services/map_data_provider.dart';
|
||||
import '../services/node_data_manager.dart';
|
||||
import '../services/changelog_service.dart';
|
||||
import '../state/settings_state.dart';
|
||||
import 'refine_tags_sheet.dart';
|
||||
@@ -32,6 +36,13 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkTutorialStatus();
|
||||
// Listen to node data manager for cache updates
|
||||
NodeDataManager().addListener(_onCacheUpdated);
|
||||
}
|
||||
|
||||
void _onCacheUpdated() {
|
||||
// Rebuild when cache updates (e.g., when new data loads)
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _checkTutorialStatus() async {
|
||||
@@ -58,8 +69,12 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@override
|
||||
void dispose() {
|
||||
// Remove listener
|
||||
NodeDataManager().removeListener(_onCacheUpdated);
|
||||
|
||||
// Clear tutorial callback when widget is disposed
|
||||
if (_showTutorial) {
|
||||
try {
|
||||
@@ -99,7 +114,7 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
|
||||
|
||||
void _checkProximityOnly(BuildContext context, AppState appState, LocalizationService locService) {
|
||||
// Check for nearby nodes within the configured distance, excluding the node being edited
|
||||
final nearbyNodes = NodeCache.instance.findNodesWithinDistance(
|
||||
final nearbyNodes = MapDataProvider().findNodesWithinDistance(
|
||||
widget.session.target,
|
||||
kNodeProximityWarningDistance,
|
||||
excludeNodeId: widget.session.originalNode.id,
|
||||
@@ -137,6 +152,15 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
|
||||
|
||||
Widget _buildDirectionControls(BuildContext context, AppState appState, EditNodeSession session, LocalizationService locService) {
|
||||
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
|
||||
final is360Fov = session.profile?.fov == 360;
|
||||
final enableDirectionControls = requiresDirection && !is360Fov;
|
||||
|
||||
// Force direction to 0 when FOV is 360 (omnidirectional)
|
||||
if (is360Fov && session.directionDegrees != 0) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
appState.updateEditSession(directionDeg: 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Format direction display text with bold for current direction
|
||||
String directionsText = '';
|
||||
@@ -186,7 +210,7 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
|
||||
divisions: 359,
|
||||
value: session.directionDegrees,
|
||||
label: session.directionDegrees.round().toString(),
|
||||
onChanged: requiresDirection ? (v) => appState.updateEditSession(directionDeg: v) : null,
|
||||
onChanged: enableDirectionControls ? (v) => appState.updateEditSession(directionDeg: v) : null,
|
||||
),
|
||||
),
|
||||
// Direction control buttons - always show but grey out when direction not required
|
||||
@@ -196,9 +220,9 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
|
||||
icon: Icon(
|
||||
Icons.remove,
|
||||
size: 20,
|
||||
color: requiresDirection ? null : Theme.of(context).disabledColor,
|
||||
color: enableDirectionControls ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
onPressed: requiresDirection && session.directions.length > 1
|
||||
onPressed: enableDirectionControls && session.directions.length > 1
|
||||
? () => appState.removeDirection()
|
||||
: null,
|
||||
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
|
||||
@@ -210,9 +234,9 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
|
||||
icon: Icon(
|
||||
Icons.add,
|
||||
size: 20,
|
||||
color: requiresDirection && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
|
||||
color: enableDirectionControls && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
onPressed: requiresDirection && session.directions.length < 8 ? () => appState.addDirection() : null,
|
||||
onPressed: enableDirectionControls && session.directions.length < 8 ? () => appState.addDirection() : null,
|
||||
tooltip: requiresDirection
|
||||
? (session.directions.length >= 8 ? 'Maximum 8 directions allowed' : 'Add new direction')
|
||||
: 'Direction not required for this profile',
|
||||
@@ -224,9 +248,9 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
|
||||
icon: Icon(
|
||||
Icons.repeat,
|
||||
size: 20,
|
||||
color: requiresDirection ? null : Theme.of(context).disabledColor,
|
||||
color: enableDirectionControls ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
onPressed: requiresDirection && session.directions.length > 1
|
||||
onPressed: enableDirectionControls && session.directions.length > 1
|
||||
? () => appState.cycleDirection()
|
||||
: null,
|
||||
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',
|
||||
@@ -277,11 +301,33 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
|
||||
final session = widget.session;
|
||||
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
final isSandboxMode = appState.uploadMode == UploadMode.sandbox;
|
||||
|
||||
// Check if we have good cache coverage around the node position
|
||||
bool hasGoodCoverage = true;
|
||||
final nodeCoord = session.originalNode.coord;
|
||||
const double bufferDegrees = 0.001; // ~100m buffer
|
||||
final targetBounds = LatLngBounds(
|
||||
LatLng(nodeCoord.latitude - bufferDegrees, nodeCoord.longitude - bufferDegrees),
|
||||
LatLng(nodeCoord.latitude + bufferDegrees, nodeCoord.longitude + bufferDegrees),
|
||||
);
|
||||
hasGoodCoverage = MapDataProvider().hasGoodCoverageFor(targetBounds);
|
||||
|
||||
// If strict coverage check fails, fall back to checking if we have any nodes nearby
|
||||
// This handles the timing issue where cache might not be marked as "covered" yet
|
||||
if (!hasGoodCoverage) {
|
||||
final nearbyNodes = MapDataProvider().findNodesWithinDistance(
|
||||
nodeCoord,
|
||||
200.0, // 200m radius - if we have nodes nearby, we likely have good data
|
||||
);
|
||||
hasGoodCoverage = nearbyNodes.isNotEmpty;
|
||||
}
|
||||
|
||||
final allowSubmit = kEnableNodeEdits &&
|
||||
appState.isLoggedIn &&
|
||||
submittableProfiles.isNotEmpty &&
|
||||
session.profile != null &&
|
||||
session.profile!.isSubmittable;
|
||||
session.profile!.isSubmittable &&
|
||||
hasGoodCoverage;
|
||||
|
||||
void _navigateToLogin() {
|
||||
Navigator.pushNamed(context, '/settings/osm-account');
|
||||
@@ -331,14 +377,7 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: Text(locService.t('editNode.profile')),
|
||||
trailing: DropdownButton<NodeProfile?>(
|
||||
value: session.profile,
|
||||
hint: Text(locService.t('editNode.selectProfile')),
|
||||
items: submittableProfiles
|
||||
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
|
||||
.toList(),
|
||||
onChanged: (p) => appState.updateEditSession(profile: p),
|
||||
),
|
||||
trailing: _buildProfileDropdown(context, appState, session, submittableProfiles, locService),
|
||||
),
|
||||
// Direction controls
|
||||
_buildDirectionControls(context, appState, session, locService),
|
||||
@@ -475,6 +514,22 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (!hasGoodCoverage)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.cloud_download, color: Colors.blue, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('editNode.loadingAreaData'),
|
||||
style: const TextStyle(color: Colors.blue, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
@@ -526,6 +581,100 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProfileDropdown(BuildContext context, AppState appState, EditNodeSession session, List<NodeProfile> submittableProfiles, LocalizationService locService) {
|
||||
return PopupMenuButton<String>(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
session.profile?.name ?? locService.t('editNode.selectProfile'),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: session.profile != null ? null : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.arrow_drop_down, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
itemBuilder: (context) => [
|
||||
// Regular profiles
|
||||
...submittableProfiles.map(
|
||||
(profile) => PopupMenuItem<String>(
|
||||
value: 'profile_${profile.id}',
|
||||
child: Text(profile.name),
|
||||
),
|
||||
),
|
||||
// Divider
|
||||
if (submittableProfiles.isNotEmpty) const PopupMenuDivider(),
|
||||
// Get more... option
|
||||
PopupMenuItem<String>(
|
||||
value: 'get_more',
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.language, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
locService.t('profiles.getMore'),
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'get_more') {
|
||||
_openIdentifyWebsite(context);
|
||||
} else if (value.startsWith('profile_')) {
|
||||
final profileId = value.substring(8); // Remove 'profile_' prefix
|
||||
final profile = submittableProfiles.firstWhere((p) => p.id == profileId);
|
||||
appState.updateEditSession(profile: profile);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _openIdentifyWebsite(BuildContext context) async {
|
||||
const url = 'https://deflock.me/identify';
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication, // Force external browser
|
||||
);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
_showErrorSnackBar(context, 'Unable to open website');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
_showErrorSnackBar(context, 'Error opening website: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showErrorSnackBar(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openAdvancedEdit(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
|
||||
@@ -170,7 +170,8 @@ class DirectionConesBuilder {
|
||||
bool isActiveDirection = true,
|
||||
}) {
|
||||
// Handle full circle case (360-degree FOV)
|
||||
if (halfAngleDeg >= 180) {
|
||||
// Use 179.5 threshold to account for floating point precision
|
||||
if (halfAngleDeg >= 179.5) {
|
||||
return _buildFullCircle(
|
||||
origin: origin,
|
||||
zoom: zoom,
|
||||
@@ -232,6 +233,7 @@ class DirectionConesBuilder {
|
||||
}
|
||||
|
||||
/// Build a full circle for 360-degree FOV cases
|
||||
/// Returns just the outer circle - we'll handle the donut effect differently
|
||||
static Polygon _buildFullCircle({
|
||||
required LatLng origin,
|
||||
required double zoom,
|
||||
@@ -239,17 +241,15 @@ class DirectionConesBuilder {
|
||||
bool isSession = false,
|
||||
bool isActiveDirection = true,
|
||||
}) {
|
||||
// Calculate pixel-based radii
|
||||
// Calculate pixel-based radii
|
||||
final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength);
|
||||
final innerRadiusPx = kNodeIconDiameter + (2 * getNodeRingThickness(context));
|
||||
|
||||
// Convert pixels to coordinate distances with zoom scaling
|
||||
final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom);
|
||||
final outerRadius = outerRadiusPx * pixelToCoordinate;
|
||||
final innerRadius = innerRadiusPx * pixelToCoordinate;
|
||||
|
||||
// Create full circle with many points for smooth rendering
|
||||
const int circlePoints = 36;
|
||||
// Create simple filled circle - no donut complexity
|
||||
const int circlePoints = 60;
|
||||
final points = <LatLng>[];
|
||||
|
||||
LatLng project(double deg, double distance) {
|
||||
@@ -260,17 +260,11 @@ class DirectionConesBuilder {
|
||||
return LatLng(origin.latitude + dLat, origin.longitude + dLon);
|
||||
}
|
||||
|
||||
// Add outer circle points
|
||||
for (int i = 0; i < circlePoints; i++) {
|
||||
final angle = i * 360.0 / circlePoints;
|
||||
// Add outer circle points - simple complete circle
|
||||
for (int i = 0; i <= circlePoints; i++) { // Note: <= to ensure closure
|
||||
final angle = (i * 360.0 / circlePoints) % 360.0;
|
||||
points.add(project(angle, outerRadius));
|
||||
}
|
||||
|
||||
// Add inner circle points in reverse order to create donut
|
||||
for (int i = circlePoints - 1; i >= 0; i--) {
|
||||
final angle = i * 360.0 / circlePoints;
|
||||
points.add(project(angle, innerRadius));
|
||||
}
|
||||
|
||||
// Adjust opacity based on direction state
|
||||
double opacity = kDirectionConeOpacity;
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../models/node_profile.dart';
|
||||
import '../../app_state.dart' show UploadMode;
|
||||
import '../../services/prefetch_area_service.dart';
|
||||
|
||||
import '../node_provider_with_cache.dart';
|
||||
import '../../dev_config.dart';
|
||||
|
||||
@@ -44,8 +44,6 @@ class NodeRefreshController {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Clear node cache to ensure fresh data for new profile combination
|
||||
_nodeProvider.clearCache();
|
||||
// Clear pre-fetch area since profiles changed
|
||||
PrefetchAreaService().clearPreFetchedArea();
|
||||
// Force display refresh first (for immediate UI update)
|
||||
_nodeProvider.refreshDisplay();
|
||||
// Notify that profiles changed (triggers node refresh)
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:provider/provider.dart';
|
||||
import '../app_state.dart' show AppState, FollowMeMode, UploadMode;
|
||||
import '../services/offline_area_service.dart';
|
||||
import '../services/network_status.dart';
|
||||
import '../services/prefetch_area_service.dart';
|
||||
|
||||
import '../models/osm_node.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/suspected_location.dart';
|
||||
@@ -214,7 +214,7 @@ class MapViewState extends State<MapView> {
|
||||
_nodeController.dispose();
|
||||
_tileManager.dispose();
|
||||
_gpsController.dispose();
|
||||
PrefetchAreaService().dispose();
|
||||
// PrefetchAreaService no longer used - replaced with NodeDataManager
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
import '../services/map_data_provider.dart';
|
||||
import '../services/node_cache.dart';
|
||||
import '../services/node_data_manager.dart';
|
||||
import '../services/node_spatial_cache.dart';
|
||||
import '../services/network_status.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/osm_node.dart';
|
||||
@@ -17,51 +18,47 @@ class NodeProviderWithCache extends ChangeNotifier {
|
||||
factory NodeProviderWithCache() => instance;
|
||||
NodeProviderWithCache._internal();
|
||||
|
||||
final NodeDataManager _nodeDataManager = NodeDataManager();
|
||||
Timer? _debounceTimer;
|
||||
|
||||
/// Call this to get (quickly) all cached overlays for the given view.
|
||||
/// Filters by currently enabled profiles only. Limiting is handled by MapView.
|
||||
/// Get cached nodes for the given bounds, filtered by enabled profiles
|
||||
List<OsmNode> getCachedNodesForBounds(LatLngBounds bounds) {
|
||||
final allNodes = NodeCache.instance.queryByBounds(bounds);
|
||||
// Use the same cache instance as NodeDataManager
|
||||
final allNodes = NodeSpatialCache().getNodesFor(bounds);
|
||||
final enabledProfiles = AppState.instance.enabledProfiles;
|
||||
|
||||
// If no profiles are enabled, show no nodes
|
||||
if (enabledProfiles.isEmpty) return [];
|
||||
|
||||
// Filter nodes to only show those matching enabled profiles
|
||||
// Note: This uses ALL enabled profiles for filtering, even though Overpass queries
|
||||
// may be deduplicated for efficiency (broader profiles capture nodes for specific ones)
|
||||
return allNodes.where((node) {
|
||||
return _matchesAnyProfile(node, enabledProfiles);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Call this when the map view changes (bounds/profiles), triggers async fetch
|
||||
/// and notifies listeners/UI when new data is available.
|
||||
/// Fetch and update nodes for the given view, with debouncing for rapid map movement
|
||||
void fetchAndUpdate({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
}) {
|
||||
// Fast: serve cached immediately
|
||||
// Serve cached immediately
|
||||
notifyListeners();
|
||||
|
||||
// Debounce rapid panning/zooming
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 400), () async {
|
||||
try {
|
||||
// Use MapSource.auto to handle both offline and online modes appropriately
|
||||
final fresh = await MapDataProvider().getNodes(
|
||||
await _nodeDataManager.getNodesFor(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
source: MapSource.auto,
|
||||
isUserInitiated: true,
|
||||
);
|
||||
if (fresh.isNotEmpty) {
|
||||
NodeCache.instance.addOrUpdate(fresh);
|
||||
// Clear waiting status when node data arrives
|
||||
NetworkStatus.instance.clearWaiting();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Notify UI of new data
|
||||
notifyListeners();
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[NodeProviderWithCache] Node fetch failed: $e');
|
||||
// Cache already holds whatever is available for the view
|
||||
@@ -71,7 +68,7 @@ class NodeProviderWithCache extends ChangeNotifier {
|
||||
|
||||
/// Clear the cache and repopulate with pending nodes from upload queue
|
||||
void clearCache() {
|
||||
NodeCache.instance.clear();
|
||||
_nodeDataManager.clearCache();
|
||||
// Repopulate with pending nodes from upload queue if available
|
||||
_repopulatePendingNodesAfterClear();
|
||||
notifyListeners();
|
||||
@@ -79,12 +76,7 @@ class NodeProviderWithCache extends ChangeNotifier {
|
||||
|
||||
/// Repopulate pending nodes after cache clear
|
||||
void _repopulatePendingNodesAfterClear() {
|
||||
// We need access to the upload queue state, but we don't have direct access here
|
||||
// Instead, we'll trigger a callback that the app state can handle
|
||||
// For now, let's use a more direct approach through a global service access
|
||||
// This could be refactored to use proper dependency injection later
|
||||
Future.microtask(() {
|
||||
// This will be called from app state when cache clears happen
|
||||
_onCacheCleared?.call();
|
||||
});
|
||||
}
|
||||
|
||||
89
lib/widgets/profile_add_choice_dialog.dart
Normal file
89
lib/widgets/profile_add_choice_dialog.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
/// Dialog offering users a choice between creating a custom profile or importing from website
|
||||
class ProfileAddChoiceDialog extends StatelessWidget {
|
||||
const ProfileAddChoiceDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(locService.t('profiles.addProfileChoice')),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(locService.t('profiles.addProfileChoiceMessage')),
|
||||
const SizedBox(height: 16),
|
||||
// Create custom profile option
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.add_circle_outline),
|
||||
title: Text(locService.t('profiles.createCustomProfile')),
|
||||
subtitle: Text(locService.t('profiles.createCustomProfileDescription')),
|
||||
onTap: () => Navigator.of(context).pop('create'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Import from website option
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.language),
|
||||
title: Text(locService.t('profiles.importFromWebsite')),
|
||||
subtitle: Text(locService.t('profiles.importFromWebsiteDescription')),
|
||||
onTap: () => _openWebsite(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(locService.cancel),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _openWebsite(BuildContext context) async {
|
||||
const url = 'https://deflock.me/identify';
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication, // Force external browser
|
||||
);
|
||||
// Close dialog after opening website
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
_showErrorSnackBar(context, 'Unable to open website');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
_showErrorSnackBar(context, 'Error opening website: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showErrorSnackBar(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
40
pubspec.lock
40
pubspec.lock
@@ -9,6 +9,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
app_links:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: app_links
|
||||
sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
app_links_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links_linux
|
||||
sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
app_links_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links_platform_interface
|
||||
sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
app_links_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links_web
|
||||
sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -347,6 +379,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.5"
|
||||
gtk:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: gtk
|
||||
sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: deflockapp
|
||||
description: Map public surveillance infrastructure with OpenStreetMap
|
||||
publish_to: "none"
|
||||
version: 2.3.1+38 # The thing after the + is the version code, incremented with each release
|
||||
version: 2.6.0+44 # The thing after the + is the version code, incremented with each release
|
||||
|
||||
environment:
|
||||
sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+
|
||||
@@ -22,6 +22,7 @@ dependencies:
|
||||
flutter_local_notifications: ^17.2.2
|
||||
url_launcher: ^6.3.0
|
||||
flutter_linkify: ^6.0.0
|
||||
app_links: ^6.1.4
|
||||
|
||||
# Auth, storage, prefs
|
||||
oauth2_client: ^4.2.0
|
||||
|
||||
91
test/models/osm_node_test.dart
Normal file
91
test/models/osm_node_test.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:deflockapp/models/osm_node.dart';
|
||||
|
||||
void main() {
|
||||
group('OsmNode Direction Parsing', () {
|
||||
test('should parse 360-degree FOV from X-X notation', () {
|
||||
final node = OsmNode(
|
||||
id: 1,
|
||||
coord: const LatLng(0, 0),
|
||||
tags: {'direction': '180-180'},
|
||||
);
|
||||
|
||||
final directionFovPairs = node.directionFovPairs;
|
||||
|
||||
expect(directionFovPairs, hasLength(1));
|
||||
expect(directionFovPairs[0].centerDegrees, equals(180.0));
|
||||
expect(directionFovPairs[0].fovDegrees, equals(360.0));
|
||||
});
|
||||
|
||||
test('should parse 360-degree FOV from 0-0 notation', () {
|
||||
final node = OsmNode(
|
||||
id: 1,
|
||||
coord: const LatLng(0, 0),
|
||||
tags: {'direction': '0-0'},
|
||||
);
|
||||
|
||||
final directionFovPairs = node.directionFovPairs;
|
||||
|
||||
expect(directionFovPairs, hasLength(1));
|
||||
expect(directionFovPairs[0].centerDegrees, equals(0.0));
|
||||
expect(directionFovPairs[0].fovDegrees, equals(360.0));
|
||||
});
|
||||
|
||||
test('should parse 360-degree FOV from 270-270 notation', () {
|
||||
final node = OsmNode(
|
||||
id: 1,
|
||||
coord: const LatLng(0, 0),
|
||||
tags: {'direction': '270-270'},
|
||||
);
|
||||
|
||||
final directionFovPairs = node.directionFovPairs;
|
||||
|
||||
expect(directionFovPairs, hasLength(1));
|
||||
expect(directionFovPairs[0].centerDegrees, equals(270.0));
|
||||
expect(directionFovPairs[0].fovDegrees, equals(360.0));
|
||||
});
|
||||
|
||||
test('should parse normal range notation correctly', () {
|
||||
final node = OsmNode(
|
||||
id: 1,
|
||||
coord: const LatLng(0, 0),
|
||||
tags: {'direction': '90-270'},
|
||||
);
|
||||
|
||||
final directionFovPairs = node.directionFovPairs;
|
||||
|
||||
expect(directionFovPairs, hasLength(1));
|
||||
expect(directionFovPairs[0].centerDegrees, equals(180.0));
|
||||
expect(directionFovPairs[0].fovDegrees, equals(180.0));
|
||||
});
|
||||
|
||||
test('should parse wrapping range notation correctly', () {
|
||||
final node = OsmNode(
|
||||
id: 1,
|
||||
coord: const LatLng(0, 0),
|
||||
tags: {'direction': '270-90'},
|
||||
);
|
||||
|
||||
final directionFovPairs = node.directionFovPairs;
|
||||
|
||||
expect(directionFovPairs, hasLength(1));
|
||||
expect(directionFovPairs[0].centerDegrees, equals(0.0));
|
||||
expect(directionFovPairs[0].fovDegrees, equals(180.0));
|
||||
});
|
||||
|
||||
test('should parse single direction correctly', () {
|
||||
final node = OsmNode(
|
||||
id: 1,
|
||||
coord: const LatLng(0, 0),
|
||||
tags: {'direction': '90'},
|
||||
);
|
||||
|
||||
final directionFovPairs = node.directionFovPairs;
|
||||
|
||||
expect(directionFovPairs, hasLength(1));
|
||||
expect(directionFovPairs[0].centerDegrees, equals(90.0));
|
||||
// Default FOV from dev_config (kDirectionConeHalfAngle * 2)
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user