mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
improve data indicator, offline fetching, offline area loading
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
''';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user