mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-05-15 21:48:18 +02:00
Merge pull request #9 from stopflock/offline-queue-and-camera-cache
Offline queue and camera cache
This commit is contained in:
+24
-5
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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
@@ -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 add‑mode 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: [
|
||||
|
||||
Reference in New Issue
Block a user