mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-08-14 23:20:46 +02:00
builds, has camera cache
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
-37
@@ -20,6 +20,7 @@ import '../models/osm_camera_node.dart';
|
|||||||
import 'debouncer.dart';
|
import 'debouncer.dart';
|
||||||
import 'camera_tag_sheet.dart';
|
import 'camera_tag_sheet.dart';
|
||||||
import 'tile_provider_with_cache.dart';
|
import 'tile_provider_with_cache.dart';
|
||||||
|
import 'camera_provider_with_cache.dart';
|
||||||
import 'package:flock_map_app/dev_config.dart';
|
import 'package:flock_map_app/dev_config.dart';
|
||||||
|
|
||||||
// --- Smart marker widget for camera with single/double tap distinction
|
// --- Smart marker widget for camera with single/double tap distinction
|
||||||
@@ -93,42 +94,63 @@ class _MapViewState extends State<MapView> {
|
|||||||
StreamSubscription<Position>? _positionSub;
|
StreamSubscription<Position>? _positionSub;
|
||||||
LatLng? _currentLatLng;
|
LatLng? _currentLatLng;
|
||||||
|
|
||||||
List<OsmCameraNode> _cameras = [];
|
late final CameraProviderWithCache _cameraProvider;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_debounceTileLayerUpdate = Debouncer(kDebounceTileLayerUpdate);
|
_debounceTileLayerUpdate = Debouncer(kDebounceTileLayerUpdate);
|
||||||
// Kick off offline area loading as soon as map loads
|
|
||||||
OfflineAreaService();
|
OfflineAreaService();
|
||||||
_controller = widget.controller;
|
_controller = widget.controller;
|
||||||
_initLocation();
|
_initLocation();
|
||||||
|
|
||||||
|
// Set up camera overlay caching
|
||||||
|
_cameraProvider = CameraProviderWithCache.instance;
|
||||||
|
_cameraProvider.addListener(_onCamerasUpdated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_positionSub?.cancel();
|
_positionSub?.cancel();
|
||||||
_debounce.dispose();
|
_debounce.dispose();
|
||||||
|
_cameraProvider.removeListener(_onCamerasUpdated);
|
||||||
super.dispose();
|
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
|
@override
|
||||||
void didUpdateWidget(covariant MapView oldWidget) {
|
void didUpdateWidget(covariant MapView oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
@@ -147,7 +169,15 @@ class _MapViewState extends State<MapView> {
|
|||||||
final latLng = LatLng(position.latitude, position.longitude);
|
final latLng = LatLng(position.latitude, position.longitude);
|
||||||
setState(() => _currentLatLng = latLng);
|
setState(() => _currentLatLng = latLng);
|
||||||
if (widget.followMe) {
|
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
|
// If too zoomed out, do NOT fetch cameras; show info
|
||||||
final zoom = _controller.camera.zoom;
|
final zoom = _controller.camera.zoom;
|
||||||
if (zoom < 10) {
|
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
|
// Show a snackbar-style bubble, if desired
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -176,16 +206,10 @@ class _MapViewState extends State<MapView> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final cams = await _mapDataProvider.getCameras(
|
// (Legacy _cameras assignment removed—now handled via provider and cache updates)
|
||||||
bounds: bounds,
|
|
||||||
profiles: appState.enabledProfiles,
|
|
||||||
uploadMode: appState.uploadMode,
|
|
||||||
// MapSource.auto (default) will prefer Overpass for now
|
|
||||||
);
|
|
||||||
if (mounted) setState(() => _cameras = cams);
|
|
||||||
} on OfflineModeException catch (_) {
|
} on OfflineModeException catch (_) {
|
||||||
// Swallow the error in offline mode
|
// 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 appState = context.watch<AppState>();
|
||||||
final session = appState.session;
|
final session = appState.session;
|
||||||
|
|
||||||
// Refetch only if profiles or mode changed
|
// Always update cameras on profile/mode change and map move
|
||||||
// This avoids repeated fetches on every build
|
_refreshCamerasFromProvider();
|
||||||
// We track last seen values (local to the State class)
|
|
||||||
_maybeRefreshCameras();
|
|
||||||
|
|
||||||
// Seed add‑mode target once, after first controller center is available.
|
// Seed add‑mode target once, after first controller center is available.
|
||||||
if (session != null && session.target == null) {
|
if (session != null && session.target == null) {
|
||||||
@@ -218,10 +240,19 @@ class _MapViewState extends State<MapView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final zoom = _safeZoom();
|
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
|
// Camera markers first, then GPS dot, so blue dot is always on top
|
||||||
final markers = <Marker>[
|
final markers = <Marker>[
|
||||||
..._cameras
|
...cameras
|
||||||
.where((n) => n.coord.latitude != 0 || n.coord.longitude != 0)
|
.where((n) => n.coord.latitude != 0 || n.coord.longitude != 0)
|
||||||
.where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180)
|
.where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180)
|
||||||
.map((n) => Marker(
|
.map((n) => Marker(
|
||||||
@@ -242,7 +273,7 @@ class _MapViewState extends State<MapView> {
|
|||||||
final overlays = <Polygon>[
|
final overlays = <Polygon>[
|
||||||
if (session != null && session.target != null)
|
if (session != null && session.target != null)
|
||||||
_buildCone(session.target!, session.directionDegrees, zoom),
|
_buildCone(session.target!, session.directionDegrees, zoom),
|
||||||
..._cameras
|
...cameras
|
||||||
.where((n) => n.hasDirection && n.directionDeg != null)
|
.where((n) => n.hasDirection && n.directionDeg != null)
|
||||||
.where((n) => n.coord.latitude != 0 || n.coord.longitude != 0)
|
.where((n) => n.coord.latitude != 0 || n.coord.longitude != 0)
|
||||||
.where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180)
|
.where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180)
|
||||||
|
|||||||
Reference in New Issue
Block a user