Compare commits

..

18 Commits

Author SHA1 Message Date
stopflock
6707efebbe Fix color of "get more" link text in profile dropdown 2026-01-30 21:49:27 -06:00
stopflock
69ebd43e0d bump to 2.6 2026-01-30 21:37:12 -06:00
stopflock
79d2fe711d Monolithic reimplementation of node fetching from overpass/offline areas. Prevent submissions in areas without cache coverage. Also fixes offline node loading. 2026-01-30 21:34:55 -06:00
stopflock
4a36c52982 Node fetch rework 2026-01-30 19:11:00 -06:00
stopflock
f478a3eb2d Clean up FOV cone debug logging 2026-01-30 18:55:29 -06:00
stopflock
9621e5f35a "Get more" link in profile dropdown, suggest identify page when creating profile 2026-01-30 12:56:50 -06:00
stopflock
f048ebc7db Merge pull request #28 from heathdutton/issue-27-search-viewbox
Pass (rough) viewbox to search for location-biased results
2026-01-29 12:45:35 -06:00
Heath Dutton🕴️
33ae6473bb pass viewbox to nominatim search for location-biased results 2026-01-29 10:42:56 -05:00
stopflock
0957670a15 roadmap 2026-01-28 20:36:46 -06:00
stopflock
3fc3a72cde Fixes for 360-deg FOVs 2026-01-28 20:21:25 -06:00
stopflock
1d65d5ecca v2.4.1, adds profile import via deeplink, moves profile save button, fixes FOV clearing, disable direction slider while submitting with 360-fov profile 2026-01-28 18:13:49 -06:00
stopflock
1873d6e768 profile import from deeplinks 2026-01-28 15:20:25 -06:00
stopflock
4638a18887 roadmap 2026-01-28 15:20:25 -06:00
stopflock
6bfdfadd97 Merge pull request #29 from pbaehr/main
Update running instructions in DEVELOPER.md
2026-01-25 16:37:52 -06:00
Peter Baehr
72f3c9ee79 Update running instructions in DEVELOPER.md
Add script execution and client ID definition to run instructions
2026-01-21 16:29:03 -05:00
stopflock
05e2e4e7c6 roadmap 2026-01-14 12:23:28 -06:00
stopflock
2e679c9a7e roadmap 2026-01-13 15:06:55 -06:00
stopflock
3ef053126b fix changelog syntax issue - missing comma 2026-01-13 15:03:07 -06:00
40 changed files with 1877 additions and 913 deletions

View File

@@ -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

View File

@@ -53,6 +53,12 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
- **Queue management**: Review, edit, retry, or cancel pending uploads
- **Changeset tracking**: Automatic grouping and commenting for organized contributions
### Profile Import & Sharing
- **Deep link support**: Import custom profiles via `deflockapp://profiles/add?p=<base64>` URLs
- **Website integration**: Generate profile import links from [deflock.me](https://deflock.me)
- **Pre-filled editor**: Imported profiles open in the profile editor for review and modification
- **Seamless workflow**: Edit imported profiles like any custom profile before saving
### Offline Operations
- **Smart area downloads**: Automatically calculate tile counts and storage requirements
- **Device caching**: Offline areas include surveillance device data for complete functionality without network
@@ -98,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?

View File

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

View File

@@ -1,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"
]
},

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -14,10 +15,12 @@ import 'models/suspected_location.dart';
import 'models/tile_provider.dart';
import 'models/search_result.dart';
import 'services/offline_area_service.dart';
import 'services/node_cache.dart';
import 'services/map_data_provider.dart';
import 'services/node_data_manager.dart';
import 'services/tile_preview_service.dart';
import 'services/changelog_service.dart';
import 'services/operator_profile_service.dart';
import 'services/deep_link_service.dart';
import 'widgets/node_provider_with_cache.dart';
import 'services/profile_service.dart';
import 'widgets/proximity_warning_dialog.dart';
@@ -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);

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "地图瓦片",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -69,6 +69,12 @@ class _ProfileEditorState extends State<ProfileEditor> {
title: Text(!widget.profile.editable
? locService.t('profileEditor.viewProfile')
: (widget.profile.name.isEmpty ? locService.t('profileEditor.newProfile') : locService.t('profileEditor.editProfile'))),
actions: widget.profile.editable ? [
TextButton(
onPressed: _save,
child: Text(locService.t('profileEditor.saveProfile')),
),
] : null,
),
body: ListView(
padding: EdgeInsets.fromLTRB(
@@ -135,11 +141,6 @@ class _ProfileEditorState extends State<ProfileEditor> {
const SizedBox(height: 8),
..._buildTagRows(),
const SizedBox(height: 24),
if (widget.profile.editable)
ElevatedButton(
onPressed: _save,
child: Text(locService.t('profileEditor.saveProfile')),
),
],
),
);

View File

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

View File

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

View File

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

View File

@@ -1,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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,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!);
}
}
}

View File

@@ -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(

View File

@@ -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,

View File

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

View File

@@ -5,7 +5,7 @@ import 'package:latlong2/latlong.dart';
import '../../models/node_profile.dart';
import '../../app_state.dart' show UploadMode;
import '../../services/prefetch_area_service.dart';
import '../node_provider_with_cache.dart';
import '../../dev_config.dart';
@@ -44,8 +44,6 @@ class NodeRefreshController {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Clear node cache to ensure fresh data for new profile combination
_nodeProvider.clearCache();
// Clear pre-fetch area since profiles changed
PrefetchAreaService().clearPreFetchedArea();
// Force display refresh first (for immediate UI update)
_nodeProvider.refreshDisplay();
// Notify that profiles changed (triggers node refresh)

View File

@@ -7,7 +7,7 @@ import 'package:provider/provider.dart';
import '../app_state.dart' show AppState, FollowMeMode, UploadMode;
import '../services/offline_area_service.dart';
import '../services/network_status.dart';
import '../services/prefetch_area_service.dart';
import '../models/osm_node.dart';
import '../models/node_profile.dart';
import '../models/suspected_location.dart';
@@ -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();
}

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 2.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

View File

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