Merge pull request #9 from stopflock/offline-queue-and-camera-cache

Offline queue and camera cache
This commit is contained in:
stopflock
2025-08-12 21:52:34 -05:00
committed by GitHub
7 changed files with 238 additions and 89 deletions
+24 -5
View File
@@ -43,6 +43,7 @@ class AppState extends ChangeNotifier {
if (wasOffline && !enabled) {
// Transitioning from offline to online: clear tile cache!
TileProviderWithCache.clearCache();
_startUploader(); // Resume upload queue processing as we leave offline mode
}
notifyListeners();
}
@@ -387,17 +388,23 @@ class AppState extends ChangeNotifier {
void _startUploader() {
_uploadTimer?.cancel();
// No uploads without auth or queue.
if (_queue.isEmpty) return;
// No uploads without auth or queue, or if offline mode is enabled.
if (_queue.isEmpty || _offlineMode) return;
_uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async {
if (_queue.isEmpty) return;
if (_queue.isEmpty || _offlineMode) {
_uploadTimer?.cancel();
return;
}
// Find the first queue item that is NOT in error state and act on that
final item = _queue.where((pu) => !pu.error).cast<PendingUpload?>().firstOrNull;
if (item == null) return;
// Retrieve access after every tick (accounts for re-login)
final access = await _auth.getAccessToken();
if (access == null) return; // not logged in
final item = _queue.first;
bool ok;
if (_uploadMode == UploadMode.simulate) {
// Simulate successful upload without calling real API
@@ -424,7 +431,10 @@ class AppState extends ChangeNotifier {
if (!ok) {
item.attempts++;
if (item.attempts >= 3) {
// give up until next launch
// Mark as error and stop the uploader. User can manually retry.
item.error = true;
_saveQueue();
notifyListeners();
_uploadTimer?.cancel();
} else {
await Future.delayed(const Duration(seconds: 20));
@@ -451,4 +461,13 @@ class AppState extends ChangeNotifier {
_saveQueue();
notifyListeners();
}
// Retry a failed upload (clear error and attempts, then try uploading again)
void retryUpload(PendingUpload upload) {
upload.error = false;
upload.attempts = 0;
_saveQueue();
notifyListeners();
_startUploader(); // resume uploader if not busy
}
}
+5 -1
View File
@@ -6,12 +6,14 @@ class PendingUpload {
final double direction;
final CameraProfile profile;
int attempts;
bool error;
PendingUpload({
required this.coord,
required this.direction,
required this.profile,
this.attempts = 0,
this.error = false,
});
Map<String, dynamic> toJson() => {
@@ -20,6 +22,7 @@ class PendingUpload {
'dir': direction,
'profile': profile.toJson(),
'attempts': attempts,
'error': error,
};
factory PendingUpload.fromJson(Map<String, dynamic> j) => PendingUpload(
@@ -27,8 +30,9 @@ class PendingUpload {
direction: j['dir'],
profile: j['profile'] is Map<String, dynamic>
? CameraProfile.fromJson(j['profile'])
: CameraProfile.alpr(), // fallback for legacy, more logic can be added
: CameraProfile.alpr(),
attempts: j['attempts'] ?? 0,
error: j['error'] ?? false,
);
}
@@ -71,22 +71,40 @@ class QueueSection extends StatelessWidget {
itemBuilder: (context, index) {
final upload = appState.pendingUploads[index];
return ListTile(
leading: const Icon(Icons.camera_alt),
title: Text('Camera ${index + 1}'),
leading: Icon(
upload.error ? Icons.error : Icons.camera_alt,
color: upload.error ? Colors.red : null,
),
title: Text('Camera ${index + 1}${upload.error ? " (Error)" : ""}'),
subtitle: Text(
'Lat: ${upload.coord.latitude.toStringAsFixed(6)}\n'
'Lon: ${upload.coord.longitude.toStringAsFixed(6)}\n'
'Direction: ${upload.direction.round()}°\n'
'Attempts: ${upload.attempts}'
'Attempts: ${upload.attempts}' +
(upload.error ? "\nUpload failed. Tap retry to try again." : "")
),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
appState.removeFromQueue(upload);
if (appState.pendingCount == 0) {
Navigator.pop(context);
}
},
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (upload.error)
IconButton(
icon: const Icon(Icons.refresh),
color: Colors.orange,
tooltip: 'Retry upload',
onPressed: () {
appState.retryUpload(upload);
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
appState.removeFromQueue(upload);
if (appState.pendingCount == 0) {
Navigator.pop(context);
}
},
),
],
),
);
},
+40
View File
@@ -0,0 +1,40 @@
import 'package:latlong2/latlong.dart';
import '../models/osm_camera_node.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
class CameraCache {
// Singleton instance
static final CameraCache instance = CameraCache._internal();
factory CameraCache() => instance;
CameraCache._internal();
final Map<int, OsmCameraNode> _nodes = {};
/// Add or update a batch of camera nodes in the cache.
void addOrUpdate(List<OsmCameraNode> nodes) {
for (var node in nodes) {
_nodes[node.id] = node;
}
}
/// Query for all cached cameras currently within the given LatLngBounds.
List<OsmCameraNode> queryByBounds(LatLngBounds bounds) {
return _nodes.values
.where((node) => _inBounds(node.coord, bounds))
.toList();
}
/// Retrieve all cached cameras.
List<OsmCameraNode> getAll() => _nodes.values.toList();
/// Optionally clear the cache (rarely needed)
void clear() => _nodes.clear();
/// Utility: point-in-bounds for coordinates
bool _inBounds(LatLng coord, LatLngBounds bounds) {
return coord.latitude >= bounds.southWest.latitude &&
coord.latitude <= bounds.northEast.latitude &&
coord.longitude >= bounds.southWest.longitude &&
coord.longitude <= bounds.northEast.longitude;
}
}
@@ -30,12 +30,13 @@ Future<List<OsmCameraNode>> camerasFromOverpass({
// Helper for one Overpass chunk fetch
Future<List<OsmCameraNode>> fetchChunk() async {
final outLine = fetchAllPages ? 'out body;' : 'out body $pageSize;';
final query = '''
[out:json][timeout:25];
(
$nodeClauses
);
out body $pageSize;
$outLine
''';
try {
print('[camerasFromOverpass] Querying Overpass...');
@@ -62,37 +63,6 @@ Future<List<OsmCameraNode>> camerasFromOverpass({
}
}
if (!fetchAllPages) {
// Just one page
return await fetchChunk();
} else {
// Fetch all possible data, paging with deduplication and backoff
final seenIds = <int>{};
final allCameras = <OsmCameraNode>[];
int page = 0;
while (true) {
page++;
List<OsmCameraNode> pageCameras = [];
int tries = 0;
while (tries < maxTries) {
try {
final cams = await fetchChunk();
pageCameras = cams.where((c) => !seenIds.contains(c.id)).toList();
break;
} catch (e) {
tries++;
final delayMs = 400 * (1 << tries);
print('[camerasFromOverpass][paged] Error on page $page try $tries: $e. Retrying in ${delayMs}ms.');
await Future.delayed(Duration(milliseconds: delayMs));
}
}
if (pageCameras.isEmpty) break;
print('[camerasFromOverpass][paged] Page $page: got ${pageCameras.length} new cameras.');
allCameras.addAll(pageCameras);
seenIds.addAll(pageCameras.map((c) => c.id));
if (pageCameras.length < pageSize) break;
}
print('[camerasFromOverpass][paged] DONE. Found ${allCameras.length} cameras for download.');
return allCameras;
}
// All paths just use a single fetch now; paging logic no longer required.
return await fetchChunk();
}
@@ -0,0 +1,64 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import '../services/map_data_provider.dart';
import '../services/camera_cache.dart';
import '../models/camera_profile.dart';
import '../models/osm_camera_node.dart';
import '../app_state.dart';
/// Provides cameras for a map view, using an in-memory cache and optionally
/// merging in new results from Overpass via MapDataProvider when not offline.
class CameraProviderWithCache extends ChangeNotifier {
static final CameraProviderWithCache instance = CameraProviderWithCache._internal();
factory CameraProviderWithCache() => instance;
CameraProviderWithCache._internal();
Timer? _debounceTimer;
/// Call this to get (quickly) all cached overlays for the given view.
List<OsmCameraNode> getCachedCamerasForBounds(LatLngBounds bounds) {
return CameraCache.instance.queryByBounds(bounds);
}
/// Call this when the map view changes (bounds/profiles), triggers async fetch
/// and notifies listeners/UI when new data is available.
void fetchAndUpdate({
required LatLngBounds bounds,
required List<CameraProfile> profiles,
UploadMode uploadMode = UploadMode.production,
}) {
// Fast: serve cached immediately
notifyListeners();
// Debounce rapid panning/zooming
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 400), () async {
final isOffline = AppState.instance.offlineMode;
if (!isOffline) {
try {
final fresh = await MapDataProvider().getCameras(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
source: MapSource.remote,
);
if (fresh.isNotEmpty) {
CameraCache.instance.addOrUpdate(fresh);
notifyListeners();
}
} catch (e) {
debugPrint('[CameraProviderWithCache] Overpass fetch failed: $e');
// Cache already holds whatever is available for the view
}
} // else, only cache is used
});
}
/// Optionally: clear the cache (could be used for testing/dev)
void clearCache() {
CameraCache.instance.clear();
notifyListeners();
}
}
+72 -38
View File
@@ -20,6 +20,7 @@ import '../models/osm_camera_node.dart';
import 'debouncer.dart';
import 'camera_tag_sheet.dart';
import 'tile_provider_with_cache.dart';
import 'camera_provider_with_cache.dart';
import 'package:flock_map_app/dev_config.dart';
// --- Smart marker widget for camera with single/double tap distinction
@@ -93,42 +94,63 @@ class _MapViewState extends State<MapView> {
StreamSubscription<Position>? _positionSub;
LatLng? _currentLatLng;
List<OsmCameraNode> _cameras = [];
List<String> _lastProfileIds = [];
UploadMode? _lastUploadMode;
void _maybeRefreshCameras() {
final appState = context.read<AppState>();
final currProfileIds = appState.enabledProfiles.map((p) => p.id).toList();
final currMode = appState.uploadMode;
if (_lastProfileIds.isEmpty ||
currProfileIds.length != _lastProfileIds.length ||
!_lastProfileIds.asMap().entries.every((entry) => currProfileIds[entry.key] == entry.value) ||
_lastUploadMode != currMode) {
// If this is first load, or list/ids/mode changed, refetch
_debounce(_refreshCameras);
_lastProfileIds = List.from(currProfileIds);
_lastUploadMode = currMode;
}
}
late final CameraProviderWithCache _cameraProvider;
@override
void initState() {
super.initState();
_debounceTileLayerUpdate = Debouncer(kDebounceTileLayerUpdate);
// Kick off offline area loading as soon as map loads
OfflineAreaService();
_controller = widget.controller;
_initLocation();
// Set up camera overlay caching
_cameraProvider = CameraProviderWithCache.instance;
_cameraProvider.addListener(_onCamerasUpdated);
}
@override
void dispose() {
_positionSub?.cancel();
_debounce.dispose();
_cameraProvider.removeListener(_onCamerasUpdated);
super.dispose();
}
void _onCamerasUpdated() {
if (mounted) setState(() {});
}
void _refreshCamerasFromProvider() {
final appState = context.read<AppState>();
LatLngBounds? bounds;
try {
bounds = _controller.camera.visibleBounds;
} catch (_) {
return;
}
final zoom = _controller.camera.zoom;
if (zoom < 10) {
// Show a snackbar-style bubble, if desired
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cameras not drawn below zoom level 10'),
duration: Duration(seconds: 2),
),
);
}
return;
}
_cameraProvider.fetchAndUpdate(
bounds: bounds,
profiles: appState.enabledProfiles,
uploadMode: appState.uploadMode,
);
}
// Duplicate dispose in _MapViewState removed. Only one dispose() remains with all proper teardown.
@override
void didUpdateWidget(covariant MapView oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -147,7 +169,15 @@ class _MapViewState extends State<MapView> {
final latLng = LatLng(position.latitude, position.longitude);
setState(() => _currentLatLng = latLng);
if (widget.followMe) {
_controller.move(latLng, _controller.camera.zoom);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_controller.move(latLng, _controller.camera.zoom);
} catch (e) {
debugPrint('MapController not ready yet: $e');
}
}
});
}
});
}
@@ -163,7 +193,7 @@ class _MapViewState extends State<MapView> {
// If too zoomed out, do NOT fetch cameras; show info
final zoom = _controller.camera.zoom;
if (zoom < 10) {
if (mounted) setState(() => _cameras = []);
// No-op: camera overlays handled via provider and cache, no local _cameras assignment needed.
// Show a snackbar-style bubble, if desired
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -176,16 +206,10 @@ class _MapViewState extends State<MapView> {
return;
}
try {
final cams = await _mapDataProvider.getCameras(
bounds: bounds,
profiles: appState.enabledProfiles,
uploadMode: appState.uploadMode,
// MapSource.auto (default) will prefer Overpass for now
);
if (mounted) setState(() => _cameras = cams);
// (Legacy _cameras assignment removed—now handled via provider and cache updates)
} on OfflineModeException catch (_) {
// Swallow the error in offline mode
if (mounted) setState(() => _cameras = []);
// (Legacy _cameras assignment removed—handled via provider)
}
}
@@ -202,10 +226,8 @@ class _MapViewState extends State<MapView> {
final appState = context.watch<AppState>();
final session = appState.session;
// Refetch only if profiles or mode changed
// This avoids repeated fetches on every build
// We track last seen values (local to the State class)
_maybeRefreshCameras();
// Only update cameras when map moves or profiles/mode actually change (not every build!)
// _refreshCamerasFromProvider() is now only called from map movement and relevant change handlers.
// Seed addmode target once, after first controller center is available.
if (session != null && session.target == null) {
@@ -218,10 +240,19 @@ class _MapViewState extends State<MapView> {
}
final zoom = _safeZoom();
// Fetch cached cameras for current map bounds, but only if controller is ready
LatLngBounds? mapBounds;
try {
mapBounds = _controller.camera.visibleBounds;
} catch (_) {
mapBounds = null;
}
final cameras = (mapBounds != null)
? _cameraProvider.getCachedCamerasForBounds(mapBounds)
: <OsmCameraNode>[];
// Camera markers first, then GPS dot, so blue dot is always on top
final markers = <Marker>[
..._cameras
final markers = <Marker>[
...cameras
.where((n) => n.coord.latitude != 0 || n.coord.longitude != 0)
.where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180)
.map((n) => Marker(
@@ -242,7 +273,7 @@ class _MapViewState extends State<MapView> {
final overlays = <Polygon>[
if (session != null && session.target != null)
_buildCone(session.target!, session.directionDegrees, zoom),
..._cameras
...cameras
.where((n) => n.hasDirection && n.directionDeg != null)
.where((n) => n.coord.latitude != 0 || n.coord.longitude != 0)
.where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180)
@@ -264,7 +295,10 @@ class _MapViewState extends State<MapView> {
if (session != null) {
appState.updateSession(target: pos.center);
}
_debounce(_refreshCameras);
// Only request more cameras if the user navigated the map (and at valid zoom)
if (gesture && pos.zoom >= 10) {
_debounce(_refreshCamerasFromProvider);
}
},
),
children: [