improve data indicator, offline fetching, offline area loading

This commit is contained in:
stopflock
2025-09-28 17:00:34 -05:00
parent 68289135bd
commit 0cbcec7017
12 changed files with 275 additions and 62 deletions

View File

@@ -60,7 +60,7 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
}
subtitle += '\n${locService.t('offlineAreas.size')}: $diskStr';
if (!area.isPermanent) {
subtitle += '\n${locService.t('offlineAreas.cameras')}: ${area.cameras.length}';
subtitle += '\n${locService.t('offlineAreas.cameras')}: ${area.nodes.length}';
}
return Card(
child: ListTile(

View File

@@ -9,6 +9,7 @@ import 'map_data_submodules/nodes_from_overpass.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';
enum MapSource { local, remote, auto } // For future use
@@ -39,6 +40,7 @@ class MapDataProvider {
UploadMode uploadMode = UploadMode.production,
MapSource source = MapSource.auto,
}) async {
try {
final offline = AppState.instance.offlineMode;
// Explicit remote request: error if offline, else always remote
@@ -62,7 +64,7 @@ class MapDataProvider {
);
}
// AUTO: default = remote first, fallback to local only if offline
// AUTO: In offline mode, only fetch local. In online mode, fetch both remote and local, then merge.
if (offline) {
return fetchLocalNodes(
bounds: bounds,
@@ -70,22 +72,52 @@ class MapDataProvider {
maxNodes: AppState.instance.maxCameras,
);
} else {
// Try remote, fallback to local ONLY if remote throws (optional, could be removed for stricter behavior)
try {
return await fetchOverpassNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: AppState.instance.maxCameras,
);
} catch (e) {
debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Falling back to local.');
return fetchLocalNodes(
// Online mode: fetch both remote and local, then merge with deduplication
final List<Future<List<OsmCameraNode>>> futures = [];
// Always try to get local nodes (fast, cached)
futures.add(fetchLocalNodes(
bounds: bounds,
profiles: profiles,
maxNodes: AppState.instance.maxCameras,
);
));
// Always try to get remote nodes (slower, fresh data)
futures.add(fetchOverpassNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: AppState.instance.maxCameras,
).catchError((e) {
debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Continuing with local only.');
return <OsmCameraNode>[]; // Return empty list on remote failure
}));
// Wait for both, then merge with deduplication by node ID
final results = await Future.wait(futures);
final localNodes = results[0];
final remoteNodes = results[1];
// Merge with deduplication - prefer remote data over local for same node ID
final Map<int, OsmCameraNode> mergedNodes = {};
// Add local nodes first
for (final node in localNodes) {
mergedNodes[node.id] = node;
}
// Add remote nodes, overwriting any local duplicates
for (final node in remoteNodes) {
mergedNodes[node.id] = node;
}
// Apply maxCameras limit to the merged result
final finalNodes = mergedNodes.values.take(AppState.instance.maxCameras).toList();
return finalNodes;
}
} finally {
// Always report node completion, regardless of success or failure
NetworkStatus.instance.reportNodeComplete();
}
}
@@ -95,7 +127,7 @@ class MapDataProvider {
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
int pageSize = 500,
int maxResults = 0, // 0 = no limit for offline downloads
int maxTries = 3,
}) async {
final offline = AppState.instance.offlineMode;
@@ -106,7 +138,7 @@ class MapDataProvider {
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: pageSize,
maxResults: maxResults, // Pass 0 for unlimited
);
}

View File

@@ -1,5 +1,6 @@
import 'dart:io';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import '../../models/osm_camera_node.dart';
@@ -16,38 +17,99 @@ Future<List<OsmCameraNode>> fetchLocalNodes({
final areas = OfflineAreaService().offlineAreas;
final Map<int, OsmCameraNode> deduped = {};
debugPrint('[fetchLocalNodes] Checking ${areas.length} offline areas for nodes');
debugPrint('[fetchLocalNodes] Requested bounds: ${bounds.southWest.latitude},${bounds.southWest.longitude} to ${bounds.northEast.latitude},${bounds.northEast.longitude}');
debugPrint('[fetchLocalNodes] Using ${profiles.length} profiles: ${profiles.map((p) => p.name).join(', ')}');
for (final area in areas) {
if (area.status != OfflineAreaStatus.complete) continue;
if (!area.bounds.isOverlapping(bounds)) continue;
debugPrint('[fetchLocalNodes] Area ${area.name} (${area.id}): status=${area.status}');
if (area.status != OfflineAreaStatus.complete) {
debugPrint('[fetchLocalNodes] Skipping area ${area.name} - status is ${area.status}');
continue;
}
debugPrint('[fetchLocalNodes] Area ${area.name} bounds: ${area.bounds.southWest.latitude},${area.bounds.southWest.longitude} to ${area.bounds.northEast.latitude},${area.bounds.northEast.longitude}');
if (!area.bounds.isOverlapping(bounds)) {
debugPrint('[fetchLocalNodes] Skipping area ${area.name} - bounds do not overlap');
continue;
}
final nodes = await _loadAreaNodes(area);
debugPrint('[fetchLocalNodes] Area ${area.name} loaded ${nodes.length} nodes from storage');
int nodesBefore = deduped.length;
int dedupFiltered = 0;
int boundsFiltered = 0;
int profileFiltered = 0;
for (final node in nodes) {
// Deduplicate by node ID, preferring the first occurrence
if (deduped.containsKey(node.id)) continue;
if (deduped.containsKey(node.id)) {
dedupFiltered++;
continue;
}
// Within view bounds?
if (!_pointInBounds(node.coord, bounds)) continue;
if (!_pointInBounds(node.coord, bounds)) {
boundsFiltered++;
if (boundsFiltered <= 3) { // Log first few for debugging
debugPrint('[fetchLocalNodes] Node ${node.id} at ${node.coord.latitude},${node.coord.longitude} outside bounds ${bounds.southWest.latitude},${bounds.southWest.longitude} to ${bounds.northEast.latitude},${bounds.northEast.longitude}');
}
continue;
}
// Profile filter if used
if (profiles.isNotEmpty && !_matchesAnyProfile(node, profiles)) continue;
if (profiles.isNotEmpty && !_matchesAnyProfile(node, profiles)) {
profileFiltered++;
if (profileFiltered <= 3) { // Log first few for debugging
debugPrint('[fetchLocalNodes] Node ${node.id} tags ${node.tags} don\'t match any of ${profiles.length} profiles');
}
continue;
}
deduped[node.id] = node;
}
int nodesAdded = deduped.length - nodesBefore;
debugPrint('[fetchLocalNodes] Area ${area.name}: dedup filtered: $dedupFiltered, bounds filtered: $boundsFiltered, profile filtered: $profileFiltered');
debugPrint('[fetchLocalNodes] Area ${area.name} contributed ${nodesAdded} nodes after filtering');
}
final out = deduped.values.take(maxNodes ?? deduped.length).toList();
debugPrint('[fetchLocalNodes] Returning ${out.length} nodes total');
return out;
}
// Try in-memory first, else load from disk
Future<List<OsmCameraNode>> _loadAreaNodes(OfflineArea area) async {
if (area.cameras.isNotEmpty) {
return area.cameras;
if (area.nodes.isNotEmpty) {
debugPrint('[_loadAreaNodes] Area ${area.name} has ${area.nodes.length} nodes in memory');
return area.nodes;
}
final file = File('${area.directory}/cameras.json');
if (await file.exists()) {
final str = await file.readAsString();
// Try new nodes.json first, fall back to legacy cameras.json for backward compatibility
final nodeFile = File('${area.directory}/nodes.json');
final legacyCameraFile = File('${area.directory}/cameras.json');
File fileToLoad;
if (await nodeFile.exists()) {
fileToLoad = nodeFile;
debugPrint('[_loadAreaNodes] Found new node file: ${fileToLoad.path}');
} else if (await legacyCameraFile.exists()) {
fileToLoad = legacyCameraFile;
debugPrint('[_loadAreaNodes] Found legacy camera file: ${fileToLoad.path}');
} else {
debugPrint('[_loadAreaNodes] No node file exists for area ${area.name}');
debugPrint('[_loadAreaNodes] Checked: ${nodeFile.path}');
debugPrint('[_loadAreaNodes] Checked: ${legacyCameraFile.path}');
return [];
}
try {
final str = await fileToLoad.readAsString();
final jsonList = jsonDecode(str) as List;
return jsonList.map((e) => OsmCameraNode.fromJson(e)).toList();
final nodes = jsonList.map((e) => OsmCameraNode.fromJson(e)).toList();
debugPrint('[_loadAreaNodes] Loaded ${nodes.length} nodes from ${fileToLoad.path}');
return nodes;
} catch (e) {
debugPrint('[_loadAreaNodes] Error loading nodes from ${fileToLoad.path}: $e');
return [];
}
return [];
}
bool _pointInBounds(LatLng pt, LatLngBounds bounds) {

View File

@@ -82,11 +82,14 @@ String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
}).join('\n ');
// Use unlimited output if maxResults is 0
final outputClause = maxResults > 0 ? 'out body $maxResults;' : 'out body;';
return '''
[out:json][timeout:25];
(
$nodeClauses
);
out body $maxResults;
$outputClause
''';
}

View File

@@ -6,6 +6,9 @@ import '../app_state.dart';
enum NetworkIssueType { osmTiles, overpassApi, both }
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success }
/// Simple loading state for dual-source async operations (brutalist approach)
enum LoadingState { ready, waiting, success, timeout }
class NetworkStatus extends ChangeNotifier {
static final NetworkStatus instance = NetworkStatus._();
NetworkStatus._();
@@ -23,6 +26,13 @@ class NetworkStatus extends ChangeNotifier {
Timer? _noDataResetTimer;
Timer? _successResetTimer;
// New dual-source loading state (brutalist approach)
LoadingState _tileLoadingState = LoadingState.ready;
LoadingState _nodeLoadingState = LoadingState.ready;
Timer? _tileTimeoutTimer;
Timer? _nodeTimeoutTimer;
Timer? _successDisplayTimer;
// Getters
bool get hasAnyIssues => _osmTilesHaveIssues || _overpassHasIssues;
bool get osmTilesHaveIssues => _osmTilesHaveIssues;
@@ -32,7 +42,22 @@ class NetworkStatus extends ChangeNotifier {
bool get hasNoData => _hasNoData;
bool get hasSuccess => _hasSuccess;
// New dual-source getters (brutalist approach)
LoadingState get tileLoadingState => _tileLoadingState;
LoadingState get nodeLoadingState => _nodeLoadingState;
/// Derive overall loading status from dual sources
bool get isDualSourceLoading => _tileLoadingState == LoadingState.waiting || _nodeLoadingState == LoadingState.waiting;
bool get isDualSourceTimeout => _tileLoadingState == LoadingState.timeout || _nodeLoadingState == LoadingState.timeout;
bool get isDualSourceSuccess => _tileLoadingState == LoadingState.success && _nodeLoadingState == LoadingState.success;
NetworkStatusType get currentStatus {
// Check new dual-source states first
if (isDualSourceTimeout) return NetworkStatusType.timedOut;
if (isDualSourceLoading) return NetworkStatusType.waiting;
if (isDualSourceSuccess) return NetworkStatusType.success;
// Fall back to legacy states for compatibility
if (hasAnyIssues) return NetworkStatusType.issues;
if (_isWaitingForData) return NetworkStatusType.waiting;
if (_isTimedOut) return NetworkStatusType.timedOut;
@@ -206,12 +231,91 @@ class NetworkStatus extends ChangeNotifier {
});
}
// New dual-source loading methods (brutalist approach)
/// Start waiting for both tiles and nodes
void setDualSourceWaiting() {
_tileLoadingState = LoadingState.waiting;
_nodeLoadingState = LoadingState.waiting;
// Set timeout timers for both
_tileTimeoutTimer?.cancel();
_tileTimeoutTimer = Timer(const Duration(seconds: 8), () {
if (_tileLoadingState == LoadingState.waiting) {
_tileLoadingState = LoadingState.timeout;
debugPrint('[NetworkStatus] Tile loading timed out');
notifyListeners();
}
});
_nodeTimeoutTimer?.cancel();
_nodeTimeoutTimer = Timer(const Duration(seconds: 8), () {
if (_nodeLoadingState == LoadingState.waiting) {
_nodeLoadingState = LoadingState.timeout;
debugPrint('[NetworkStatus] Node loading timed out');
notifyListeners();
}
});
notifyListeners();
}
/// Report tile loading completion
void reportTileComplete() {
if (_tileLoadingState == LoadingState.waiting) {
_tileLoadingState = LoadingState.success;
_tileTimeoutTimer?.cancel();
_checkDualSourceComplete();
}
}
/// Report node loading completion
void reportNodeComplete() {
if (_nodeLoadingState == LoadingState.waiting) {
_nodeLoadingState = LoadingState.success;
_nodeTimeoutTimer?.cancel();
_checkDualSourceComplete();
}
}
/// Check if both sources are complete and show success briefly
void _checkDualSourceComplete() {
if (_tileLoadingState == LoadingState.success && _nodeLoadingState == LoadingState.success) {
debugPrint('[NetworkStatus] Both tiles and nodes loaded successfully');
notifyListeners();
// Auto-reset to ready after showing success briefly
_successDisplayTimer?.cancel();
_successDisplayTimer = Timer(const Duration(seconds: 2), () {
_tileLoadingState = LoadingState.ready;
_nodeLoadingState = LoadingState.ready;
notifyListeners();
});
} else {
// Just notify if one completed but not both yet
notifyListeners();
}
}
/// Reset dual-source state to ready
void resetDualSourceState() {
_tileLoadingState = LoadingState.ready;
_nodeLoadingState = LoadingState.ready;
_tileTimeoutTimer?.cancel();
_nodeTimeoutTimer?.cancel();
_successDisplayTimer?.cancel();
notifyListeners();
}
@override
void dispose() {
_osmRecoveryTimer?.cancel();
_overpassRecoveryTimer?.cancel();
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_tileTimeoutTimer?.cancel();
_nodeTimeoutTimer?.cancel();
_successDisplayTimer?.cancel();
super.dispose();
}
}

View File

@@ -45,16 +45,16 @@ class OfflineAreaDownloader {
getAreaSizeBytes: getAreaSizeBytes,
);
// Download cameras for non-permanent areas
// Download nodes for non-permanent areas
if (!area.isPermanent) {
await _downloadCameras(
await _downloadNodes(
area: area,
bounds: bounds,
minZoom: minZoom,
directory: directory,
);
} else {
area.cameras = [];
area.nodes = [];
}
return success;
@@ -138,26 +138,29 @@ class OfflineAreaDownloader {
return missingTiles;
}
/// Download cameras for the area with expanded bounds
static Future<void> _downloadCameras({
/// Download nodes for the area with modest expansion (one zoom level lower)
static Future<void> _downloadNodes({
required OfflineArea area,
required LatLngBounds bounds,
required int minZoom,
required String directory,
}) async {
// Calculate expanded camera bounds that cover the entire tile area at minimum zoom
final cameraBounds = _calculateCameraBounds(bounds, minZoom);
final cameras = await MapDataProvider().getAllNodesForDownload(
bounds: cameraBounds,
// Modest expansion: use tiles at minZoom + 1 instead of minZoom
// This gives a reasonable buffer without capturing entire states
final nodeZoom = (minZoom + 1).clamp(8, 16); // Reasonable bounds for node fetching
final expandedNodeBounds = _calculateNodeBounds(bounds, nodeZoom);
final nodes = await MapDataProvider().getAllNodesForDownload(
bounds: expandedNodeBounds,
profiles: AppState.instance.profiles, // Use ALL profiles, not just enabled ones
);
area.cameras = cameras;
await OfflineAreaDownloader.saveCameras(cameras, directory);
debugPrint('Area ${area.id}: Downloaded ${cameras.length} cameras from expanded bounds (all profiles)');
area.nodes = nodes;
await OfflineAreaDownloader.saveNodes(nodes, directory);
debugPrint('Area ${area.id}: Downloaded ${nodes.length} nodes from modestly expanded bounds (zoom $nodeZoom vs tile minZoom $minZoom)');
}
/// Calculate expanded bounds that cover the entire tile area at minimum zoom
static LatLngBounds _calculateCameraBounds(LatLngBounds visibleBounds, int minZoom) {
static LatLngBounds _calculateNodeBounds(LatLngBounds visibleBounds, int minZoom) {
final tiles = computeTileList(visibleBounds, minZoom, minZoom);
if (tiles.isEmpty) return visibleBounds;
@@ -188,9 +191,9 @@ class OfflineAreaDownloader {
await file.writeAsBytes(bytes);
}
/// Save cameras to disk as JSON
static Future<void> saveCameras(List<OsmCameraNode> cams, String dir) async {
final file = File('$dir/cameras.json');
await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList()));
/// Save nodes to disk as JSON
static Future<void> saveNodes(List<OsmCameraNode> nodes, String dir) async {
final file = File('$dir/nodes.json');
await file.writeAsString(jsonEncode(nodes.map((n) => n.toJson()).toList()));
}
}

View File

@@ -17,7 +17,7 @@ class OfflineArea {
double progress; // 0.0 - 1.0
int tilesDownloaded;
int tilesTotal;
List<OsmCameraNode> cameras;
List<OsmCameraNode> nodes;
int sizeBytes; // Disk size in bytes
final bool isPermanent; // Not user-deletable if true
@@ -38,7 +38,7 @@ class OfflineArea {
this.progress = 0,
this.tilesDownloaded = 0,
this.tilesTotal = 0,
this.cameras = const [],
this.nodes = const [],
this.sizeBytes = 0,
this.isPermanent = false,
this.tileProviderId,
@@ -61,7 +61,7 @@ class OfflineArea {
'progress': progress,
'tilesDownloaded': tilesDownloaded,
'tilesTotal': tilesTotal,
'cameras': cameras.map((c) => c.toJson()).toList(),
'nodes': nodes.map((n) => n.toJson()).toList(),
'sizeBytes': sizeBytes,
'isPermanent': isPermanent,
'tileProviderId': tileProviderId,
@@ -87,7 +87,7 @@ class OfflineArea {
progress: (json['progress'] ?? 0).toDouble(),
tilesDownloaded: json['tilesDownloaded'] ?? 0,
tilesTotal: json['tilesTotal'] ?? 0,
cameras: (json['cameras'] as List? ?? [])
nodes: (json['nodes'] as List? ?? json['cameras'] as List? ?? [])
.map((e) => OsmCameraNode.fromJson(e)).toList(),
sizeBytes: json['sizeBytes'] ?? 0,
isPermanent: json['isPermanent'] ?? false,

View File

@@ -71,7 +71,7 @@ class WorldAreaManager {
progress: world.progress,
tilesDownloaded: world.tilesDownloaded,
tilesTotal: world.tilesTotal,
cameras: world.cameras,
nodes: world.nodes,
sizeBytes: world.sizeBytes,
isPermanent: world.isPermanent,
// Add missing provider metadata

View File

@@ -10,6 +10,9 @@ import 'network_status.dart';
class SimpleTileHttpClient extends http.BaseClient {
final http.Client _inner = http.Client();
final MapDataProvider _mapDataProvider = MapDataProvider();
// Tile completion tracking (brutalist approach)
int _pendingTileRequests = 0;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
@@ -48,14 +51,14 @@ class SimpleTileHttpClient extends http.BaseClient {
}
Future<http.StreamedResponse> _handleTileRequest(int z, int x, int y) async {
// Increment pending counter (brutalist completion detection)
_pendingTileRequests++;
try {
// Always go through MapDataProvider - it handles offline/online routing
// MapDataProvider will get current provider from AppState
final tileBytes = await _mapDataProvider.getTile(z: z, x: x, y: y, source: MapSource.auto);
// Show success status briefly
NetworkStatus.instance.setSuccess();
// Serve tile with proper cache headers
return http.StreamedResponse(
Stream.value(tileBytes),
@@ -71,15 +74,18 @@ class SimpleTileHttpClient extends http.BaseClient {
} catch (e) {
debugPrint('[SimpleTileService] Could not get tile $z/$x/$y: $e');
// 404 means no tiles available - show "no data" status briefly
NetworkStatus.instance.setNoData();
// Return 404 and let flutter_map handle it gracefully
return http.StreamedResponse(
Stream.value(<int>[]),
404,
reasonPhrase: 'Tile unavailable: $e',
);
} finally {
// Decrement pending counter and report completion when all done
_pendingTileRequests--;
if (_pendingTileRequests == 0) {
NetworkStatus.instance.reportTileComplete();
}
}
}

View File

@@ -55,7 +55,7 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
);
}
final minZoom = kWorldMaxZoom + 1;
final minZoom = 1; // Always start from zoom 1 to show area overview when zoomed out
final maxZoom = _zoom.toInt();
// Calculate maximum possible zoom based on tile count limit

View File

@@ -300,8 +300,8 @@ class MapViewState extends State<MapView> {
appState.updateEditSession(target: pos.center);
}
// Show waiting indicator when map moves (user is expecting new content)
NetworkStatus.instance.setWaiting();
// Start dual-source waiting when map moves (user is expecting new tiles AND nodes)
NetworkStatus.instance.setDualSourceWaiting();
// Only clear tile queue on significant ZOOM changes (not panning)
final currentZoom = pos.zoom;
@@ -323,6 +323,9 @@ class MapViewState extends State<MapView> {
// Request more cameras on any map movement/zoom at valid zoom level (slower debounce)
if (pos.zoom >= 10) {
_cameraDebounce(_refreshCamerasFromProvider);
} else {
// Skip nodes at low zoom - report immediate completion (brutalist approach)
NetworkStatus.instance.reportNodeComplete();
}
},
),

View File

@@ -35,7 +35,7 @@ class NetworkStatusIndicator extends StatelessWidget {
break;
case NetworkStatusType.success:
message = 'Tiles loaded';
message = 'Done';
icon = Icons.check_circle;
color = Colors.green;
break;
@@ -43,7 +43,7 @@ class NetworkStatusIndicator extends StatelessWidget {
case NetworkStatusType.issues:
switch (networkStatus.currentIssueType) {
case NetworkIssueType.osmTiles:
message = 'OSM tiles slow';
message = 'Tile provider slow';
icon = Icons.map_outlined;
color = Colors.orange;
break;