fixes tile re-rendering after long rate limit periods without having to zoom/pan

This commit is contained in:
stopflock
2025-08-22 23:44:49 -05:00
parent 01f73322c7
commit f6adffc84e
6 changed files with 221 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ import 'package:flutter_map/flutter_map.dart';
import '../../models/camera_profile.dart';
import '../../models/osm_camera_node.dart';
import '../../app_state.dart';
import '../network_status.dart';
/// Fetches cameras from the Overpass OSM API for the given bounds and profiles.
/// If fetchAllPages is true, returns all possible cameras using multiple API calls (paging with pageSize).
@@ -45,11 +46,13 @@ Future<List<OsmCameraNode>> camerasFromOverpass({
print('[camerasFromOverpass] Status: ${resp.statusCode}, Length: ${resp.body.length}');
if (resp.statusCode != 200) {
print('[camerasFromOverpass] Overpass failed: ${resp.body}');
NetworkStatus.instance.reportOverpassIssue();
return [];
}
final data = jsonDecode(resp.body) as Map<String, dynamic>;
final elements = data['elements'] as List<dynamic>;
print('[camerasFromOverpass] Retrieved elements: ${elements.length}');
NetworkStatus.instance.reportOverpassSuccess();
return elements.whereType<Map<String, dynamic>>().map((e) {
return OsmCameraNode(
id: e['id'],
@@ -59,6 +62,14 @@ Future<List<OsmCameraNode>> camerasFromOverpass({
}).toList();
} catch (e) {
print('[camerasFromOverpass] Overpass exception: $e');
// Report network issues on connection errors
if (e.toString().contains('Connection refused') ||
e.toString().contains('Connection timed out') ||
e.toString().contains('Connection reset')) {
NetworkStatus.instance.reportOverpassIssue();
}
return [];
}
}

View File

@@ -4,6 +4,7 @@ import 'dart:async';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:flock_map_app/dev_config.dart';
import '../network_status.dart';
/// Global semaphore to limit simultaneous tile fetches
final _tileFetchSemaphore = _SimpleSemaphore(4); // Max 4 concurrent
@@ -33,13 +34,23 @@ Future<List<int>> fetchOSMTile({
print('[fetchOSMTile] HTTP ${resp.statusCode} for $z/$x/$y, length=${resp.bodyBytes.length}');
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
print('[fetchOSMTile] SUCCESS $z/$x/$y');
NetworkStatus.instance.reportOsmTileSuccess();
return resp.bodyBytes;
} else {
print('[fetchOSMTile] FAIL $z/$x/$y: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
NetworkStatus.instance.reportOsmTileIssue();
throw HttpException('Failed to fetch tile $z/$x/$y: status ${resp.statusCode}');
}
} catch (e) {
print('[fetchOSMTile] Exception $z/$x/$y: $e');
// Report network issues on connection errors
if (e.toString().contains('Connection refused') ||
e.toString().contains('Connection timed out') ||
e.toString().contains('Connection reset')) {
NetworkStatus.instance.reportOsmTileIssue();
}
if (attempt >= maxAttempts) {
print("[fetchOSMTile] Failed for $z/$x/$y after $attempt attempts: $e");
rethrow;

View File

@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'dart:async';
enum NetworkIssueType { osmTiles, overpassApi, both }
class NetworkStatus extends ChangeNotifier {
static final NetworkStatus instance = NetworkStatus._();
NetworkStatus._();
bool _osmTilesHaveIssues = false;
bool _overpassHasIssues = false;
Timer? _osmRecoveryTimer;
Timer? _overpassRecoveryTimer;
// Getters
bool get hasAnyIssues => _osmTilesHaveIssues || _overpassHasIssues;
bool get osmTilesHaveIssues => _osmTilesHaveIssues;
bool get overpassHasIssues => _overpassHasIssues;
NetworkIssueType? get currentIssueType {
if (_osmTilesHaveIssues && _overpassHasIssues) return NetworkIssueType.both;
if (_osmTilesHaveIssues) return NetworkIssueType.osmTiles;
if (_overpassHasIssues) return NetworkIssueType.overpassApi;
return null;
}
/// Report OSM tile server issues
void reportOsmTileIssue() {
if (!_osmTilesHaveIssues) {
_osmTilesHaveIssues = true;
notifyListeners();
debugPrint('[NetworkStatus] OSM tile server issues detected');
}
// Reset recovery timer - if we keep getting errors, keep showing indicator
_osmRecoveryTimer?.cancel();
_osmRecoveryTimer = Timer(const Duration(minutes: 2), () {
_osmTilesHaveIssues = false;
notifyListeners();
debugPrint('[NetworkStatus] OSM tile server issues cleared');
});
}
/// Report Overpass API issues
void reportOverpassIssue() {
if (!_overpassHasIssues) {
_overpassHasIssues = true;
notifyListeners();
debugPrint('[NetworkStatus] Overpass API issues detected');
}
// Reset recovery timer
_overpassRecoveryTimer?.cancel();
_overpassRecoveryTimer = Timer(const Duration(minutes: 2), () {
_overpassHasIssues = false;
notifyListeners();
debugPrint('[NetworkStatus] Overpass API issues cleared');
});
}
/// Report successful operations to potentially clear issues faster
void reportOsmTileSuccess() {
// Don't immediately clear on single success, but reduce recovery time
if (_osmTilesHaveIssues) {
_osmRecoveryTimer?.cancel();
_osmRecoveryTimer = Timer(const Duration(seconds: 30), () {
_osmTilesHaveIssues = false;
notifyListeners();
debugPrint('[NetworkStatus] OSM tile server issues cleared after success');
});
}
}
void reportOverpassSuccess() {
if (_overpassHasIssues) {
_overpassRecoveryTimer?.cancel();
_overpassRecoveryTimer = Timer(const Duration(seconds: 30), () {
_overpassHasIssues = false;
notifyListeners();
debugPrint('[NetworkStatus] Overpass API issues cleared after success');
});
}
}
@override
void dispose() {
_osmRecoveryTimer?.cancel();
_overpassRecoveryTimer?.cancel();
super.dispose();
}
}

View File

@@ -63,6 +63,10 @@ class _MapViewState extends State<MapView> {
// Ensure initial overlays are fetched
WidgetsBinding.instance.addPostFrameCallback((_) {
// Set up tile refresh callback
final tileProvider = Provider.of<TileProviderWithCache>(context, listen: false);
tileProvider.setOnTilesCachedCallback(_onTilesCached);
_refreshCamerasFromProvider();
});
}
@@ -72,6 +76,15 @@ class _MapViewState extends State<MapView> {
_positionSub?.cancel();
_debounce.dispose();
_cameraProvider.removeListener(_onCamerasUpdated);
// Clean up tile refresh callback
try {
final tileProvider = Provider.of<TileProviderWithCache>(context, listen: false);
tileProvider.setOnTilesCachedCallback(null);
} catch (e) {
// Context might be disposed already - that's okay
}
super.dispose();
}
@@ -79,6 +92,14 @@ class _MapViewState extends State<MapView> {
if (mounted) setState(() {});
}
void _onTilesCached() {
// When new tiles are cached, just trigger a widget rebuild
// This should cause the TileLayer to re-render with cached tiles
if (mounted) {
setState(() {});
}
}
void _refreshCamerasFromProvider() {
final appState = context.read<AppState>();
LatLngBounds? bounds;
@@ -159,6 +180,8 @@ class _MapViewState extends State<MapView> {
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();

View File

@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/network_status.dart';
class NetworkStatusIndicator extends StatelessWidget {
const NetworkStatusIndicator({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: NetworkStatus.instance,
child: Consumer<NetworkStatus>(
builder: (context, networkStatus, child) {
if (!networkStatus.hasAnyIssues) {
return const SizedBox.shrink();
}
String message;
IconData icon;
Color color;
switch (networkStatus.currentIssueType) {
case NetworkIssueType.osmTiles:
message = 'OSM tiles slow';
icon = Icons.map_outlined;
color = Colors.orange;
break;
case NetworkIssueType.overpassApi:
message = 'Camera data slow';
icon = Icons.camera_alt_outlined;
color = Colors.orange;
break;
case NetworkIssueType.both:
message = 'Network issues';
icon = Icons.cloud_off_outlined;
color = Colors.red;
break;
default:
return const SizedBox.shrink();
}
return Positioned(
top: MediaQuery.of(context).padding.top + 8,
left: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: color,
),
const SizedBox(width: 4),
Text(
message,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
},
),
);
}
}

View File

@@ -10,9 +10,15 @@ class TileProviderWithCache extends TileProvider with ChangeNotifier {
bool _disposed = false;
int _disposeCount = 0;
VoidCallback? _onTilesCachedCallback;
TileProviderWithCache();
/// Set a callback to be called when tiles are cached (used by MapView for refresh)
void setOnTilesCachedCallback(VoidCallback? callback) {
_onTilesCachedCallback = callback;
}
@override
void dispose() {
_disposeCount++;
@@ -67,6 +73,8 @@ class TileProviderWithCache extends TileProvider with ChangeNotifier {
if (!_disposed && hasListeners) {
notifyListeners(); // This updates any listening widgets
}
// Trigger map refresh callback to force tile re-rendering
_onTilesCachedCallback?.call();
}
// If bytes were empty, don't cache (will re-attempt next time)
} catch (e) {