Files
deflock-app/lib/services/node_data_manager.dart

403 lines
14 KiB
Dart

import 'dart:async';
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();
// Track ongoing user-initiated requests for status reporting
final Set<String> _userInitiatedRequests = <String>{};
/// 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.clear();
}
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 with data
if (isUserInitiated && offlineNodes.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
NetworkStatus.instance.setSuccess();
});
} else if (isUserInitiated && offlineNodes.isEmpty) {
// Show no data briefly for offline areas with no surveillance devices
WidgetsBinding.instance.addPostFrameCallback((_) {
NetworkStatus.instance.setNoData();
});
}
return offlineNodes;
}
}
// Handle sandbox mode (always fetch from OSM API, but integrate with cache system for UI)
if (uploadMode == UploadMode.sandbox) {
debugPrint('[NodeDataManager] Sandbox mode: fetching from OSM API');
// Track user-initiated requests for status reporting
final requestKey = '${bounds.hashCode}_${profiles.map((p) => p.id).join('_')}_$uploadMode';
if (isUserInitiated && _userInitiatedRequests.contains(requestKey)) {
debugPrint('[NodeDataManager] Sandbox request already in progress for this area');
return _cache.getNodesFor(bounds);
}
// Start status tracking for user-initiated requests
if (isUserInitiated) {
_userInitiatedRequests.add(requestKey);
NetworkStatus.instance.setLoading();
debugPrint('[NodeDataManager] Starting user-initiated sandbox request');
} else {
debugPrint('[NodeDataManager] Starting background sandbox request (no status reporting)');
}
try {
final nodes = await fetchOsmApiNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: 0,
);
// Add nodes to cache for UI integration (even though we don't rely on cache for subsequent fetches)
if (nodes.isNotEmpty) {
_cache.addOrUpdateNodes(nodes);
_cache.markAreaAsFetched(bounds, nodes);
} else {
// Mark area as fetched even with no nodes so UI knows we've checked this area
_cache.markAreaAsFetched(bounds, []);
}
// Update UI
notifyListeners();
// Set success after the next frame renders, but only for user-initiated requests
if (isUserInitiated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
NetworkStatus.instance.setSuccess();
});
debugPrint('[NodeDataManager] User-initiated sandbox request completed successfully: ${nodes.length} nodes');
}
return nodes;
} catch (e) {
debugPrint('[NodeDataManager] Sandbox fetch failed: $e');
// Only report errors for user-initiated requests
if (isUserInitiated) {
if (e is RateLimitError) {
NetworkStatus.instance.setRateLimited();
} else if (e.toString().contains('timeout')) {
NetworkStatus.instance.setTimeout();
} else {
NetworkStatus.instance.setError();
}
debugPrint('[NodeDataManager] User-initiated sandbox request failed: $e');
}
// Return whatever we have in cache for this area (likely empty for sandbox)
return _cache.getNodesFor(bounds);
} finally {
if (isUserInitiated) {
_userInitiatedRequests.remove(requestKey);
}
}
}
// 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
final requestKey = '${bounds.hashCode}_${profiles.map((p) => p.id).join('_')}_$uploadMode';
// Only allow one user-initiated request per area at a time
if (isUserInitiated && _userInitiatedRequests.contains(requestKey)) {
debugPrint('[NodeDataManager] User request already in progress for this area');
return _cache.getNodesFor(bounds);
}
// Start status tracking for user-initiated requests only
if (isUserInitiated) {
_userInitiatedRequests.add(requestKey);
NetworkStatus.instance.setLoading();
debugPrint('[NodeDataManager] Starting user-initiated request');
} else {
debugPrint('[NodeDataManager] Starting background request (no status reporting)');
}
try {
final nodes = await fetchWithSplitting(bounds, profiles, isUserInitiated: isUserInitiated);
// Update cache and notify listeners
notifyListeners();
// Set success after the next frame renders, but only for user-initiated requests
if (isUserInitiated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
NetworkStatus.instance.setSuccess();
});
debugPrint('[NodeDataManager] User-initiated request completed successfully');
}
return nodes;
} catch (e) {
debugPrint('[NodeDataManager] Fetch failed: $e');
// Only report errors for user-initiated requests
if (isUserInitiated) {
if (e is RateLimitError) {
NetworkStatus.instance.setRateLimited();
} else if (e.toString().contains('timeout')) {
NetworkStatus.instance.setTimeout();
} else {
NetworkStatus.instance.setError();
}
debugPrint('[NodeDataManager] User-initiated request failed: $e');
}
// Return whatever we have in cache for this area
return _cache.getNodesFor(bounds);
} finally {
if (isUserInitiated) {
_userInitiatedRequests.remove(requestKey);
}
}
}
/// Fetch nodes with automatic area splitting if needed
Future<List<OsmNode>> fetchWithSplitting(
LatLngBounds bounds,
List<NodeProfile> profiles, {
int splitDepth = 0,
bool isUserInitiated = false,
}) 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)');
// Only report splitting status for user-initiated requests
if (isUserInitiated && splitDepth == 0) {
NetworkStatus.instance.setSplitting();
}
return _fetchSplitAreas(bounds, profiles, splitDepth + 1, isUserInitiated: isUserInitiated);
} 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, {
bool isUserInitiated = false,
}) async {
final quadrants = _splitBounds(bounds);
final allNodes = <OsmNode>[];
for (final quadrant in quadrants) {
try {
final nodes = await fetchWithSplitting(
quadrant,
profiles,
splitDepth: splitDepth,
isUserInitiated: isUserInitiated,
);
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();
// Re-fetch as user-initiated request
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();
}