mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-13 09:12:56 +00:00
builds, has camera cache
This commit is contained in:
40
lib/services/camera_cache.dart
Normal file
40
lib/services/camera_cache.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
64
lib/widgets/camera_provider_with_cache.dart
Normal file
64
lib/widgets/camera_provider_with_cache.dart
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
// Always update cameras on profile/mode change and map move
|
||||
_refreshCamerasFromProvider();
|
||||
|
||||
// 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)
|
||||
|
||||
Reference in New Issue
Block a user