Files
deflock-app/lib/widgets/map_view.dart

321 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart';
import 'package:http/http.dart' as http;
import '../app_state.dart';
import '../services/offline_area_service.dart';
import '../services/simple_tile_service.dart';
import '../models/osm_camera_node.dart';
import '../models/camera_profile.dart';
import 'debouncer.dart';
import 'camera_provider_with_cache.dart';
import 'map/camera_markers.dart';
import 'map/direction_cones.dart';
import 'map/map_overlays.dart';
import 'network_status_indicator.dart';
import '../dev_config.dart';
class MapView extends StatefulWidget {
final MapController controller;
const MapView({
super.key,
required this.controller,
required this.followMe,
required this.onUserGesture,
});
final bool followMe;
final VoidCallback onUserGesture;
@override
State<MapView> createState() => _MapViewState();
}
class _MapViewState extends State<MapView> {
late final MapController _controller;
final Debouncer _cameraDebounce = Debouncer(kDebounceCameraRefresh);
final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150));
StreamSubscription<Position>? _positionSub;
LatLng? _currentLatLng;
late final CameraProviderWithCache _cameraProvider;
late final SimpleTileHttpClient _tileHttpClient;
// Track profile changes to trigger camera refresh
List<CameraProfile>? _lastEnabledProfiles;
// Track map position to only clear queue on significant changes
double? _lastZoom;
LatLng? _lastCenter;
@override
void initState() {
super.initState();
OfflineAreaService();
_controller = widget.controller;
_tileHttpClient = SimpleTileHttpClient();
_initLocation();
// Set up camera overlay caching
_cameraProvider = CameraProviderWithCache.instance;
_cameraProvider.addListener(_onCamerasUpdated);
// Fetch initial cameras
WidgetsBinding.instance.addPostFrameCallback((_) {
_refreshCamerasFromProvider();
});
}
@override
void dispose() {
_positionSub?.cancel();
_cameraDebounce.dispose();
_tileDebounce.dispose();
_cameraProvider.removeListener(_onCamerasUpdated);
_tileHttpClient.close();
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 < kCameraMinZoomLevel) {
// Show a snackbar-style bubble, if desired
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Cameras not drawn below zoom level $kCameraMinZoomLevel'),
duration: const Duration(seconds: 2),
),
);
}
return;
}
_cameraProvider.fetchAndUpdate(
bounds: bounds,
profiles: appState.enabledProfiles,
uploadMode: appState.uploadMode,
);
}
@override
void didUpdateWidget(covariant MapView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.followMe && !oldWidget.followMe && _currentLatLng != null) {
_controller.move(_currentLatLng!, _controller.camera.zoom);
}
}
Future<void> _initLocation() async {
final perm = await Geolocator.requestPermission();
if (perm == LocationPermission.denied ||
perm == LocationPermission.deniedForever) return;
_positionSub =
Geolocator.getPositionStream().listen((Position position) {
final latLng = LatLng(position.latitude, position.longitude);
setState(() => _currentLatLng = latLng);
if (widget.followMe) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_controller.move(latLng, _controller.camera.zoom);
} catch (e) {
debugPrint('MapController not ready yet: $e');
}
}
});
}
});
}
double _safeZoom() {
try {
return _controller.camera.zoom;
} catch (_) {
return 15.0;
}
}
/// Helper to check if two profile lists are equal
bool _profileListsEqual(List<CameraProfile> list1, List<CameraProfile> list2) {
if (list1.length != list2.length) return false;
// Compare by profile IDs since profiles are value objects
final ids1 = list1.map((p) => p.id).toSet();
final ids2 = list2.map((p) => p.id).toSet();
return ids1.length == ids2.length && ids1.containsAll(ids2);
}
/// Calculate approximate distance between two LatLng points in meters
double _distanceInMeters(LatLng point1, LatLng point2) {
const double earthRadius = 6371000; // Earth radius in meters
final dLat = (point2.latitude - point1.latitude) * (3.14159 / 180);
final dLon = (point2.longitude - point1.longitude) * (3.14159 / 180);
final a = math.sin(dLat / 2) * math.sin(dLat / 2) +
math.cos(point1.latitude * (3.14159 / 180)) *
math.cos(point2.latitude * (3.14159 / 180)) *
math.sin(dLon / 2) * math.sin(dLon / 2);
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
return earthRadius * c;
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final session = appState.session;
// Check if enabled profiles changed and refresh cameras if needed
final currentEnabledProfiles = appState.enabledProfiles;
if (_lastEnabledProfiles == null ||
!_profileListsEqual(_lastEnabledProfiles!, currentEnabledProfiles)) {
_lastEnabledProfiles = List.from(currentEnabledProfiles);
// Refresh cameras when profiles change
WidgetsBinding.instance.addPostFrameCallback((_) {
// Force display refresh first (for immediate UI update)
_cameraProvider.refreshDisplay();
// Then fetch new cameras for newly enabled profiles
_refreshCamerasFromProvider();
});
}
// Seed addmode target once, after first controller center is available.
if (session != null && session.target == null) {
try {
final center = _controller.camera.center;
WidgetsBinding.instance.addPostFrameCallback(
(_) => appState.updateSession(target: center),
);
} catch (_) {/* controller not ready yet */}
}
final zoom = _safeZoom();
// Fetch cached cameras for current map bounds (using Consumer so overlays redraw instantly)
Widget cameraLayers = Consumer<CameraProviderWithCache>(
builder: (context, cameraProvider, child) {
LatLngBounds? mapBounds;
try {
mapBounds = _controller.camera.visibleBounds;
} catch (_) {
mapBounds = null;
}
final cameras = (mapBounds != null)
? cameraProvider.getCachedCamerasForBounds(mapBounds)
: <OsmCameraNode>[];
final markers = CameraMarkersBuilder.buildCameraMarkers(
cameras: cameras,
mapController: _controller,
userLocation: _currentLatLng,
);
final overlays = DirectionConesBuilder.buildDirectionCones(
cameras: cameras,
zoom: zoom,
session: session,
);
return Stack(
children: [
PolygonLayer(polygons: overlays),
MarkerLayer(markers: markers),
],
);
}
);
return Stack(
children: [
FlutterMap(
key: ValueKey('map_offline_${appState.offlineMode}'),
mapController: _controller,
options: MapOptions(
initialCenter: _currentLatLng ?? LatLng(37.7749, -122.4194),
initialZoom: 15,
maxZoom: 19,
onPositionChanged: (pos, gesture) {
setState(() {}); // Instant UI update for zoom, etc.
if (gesture) widget.onUserGesture();
if (session != null) {
appState.updateSession(target: pos.center);
}
// TODO: Re-enable smart queue clearing once we debug tile loading issues
// For now, let flutter_map handle its own request management
/*
// Only clear tile queue on significant movement changes
final currentZoom = pos.zoom;
final currentCenter = pos.center;
final zoomChanged = _lastZoom == null || (currentZoom - _lastZoom!).abs() > 0.1;
final centerChanged = _lastCenter == null ||
_distanceInMeters(_lastCenter!, currentCenter) > 100; // 100m threshold
if (zoomChanged || centerChanged) {
_tileDebounce(() {
debugPrint('[MapView] Significant map change - clearing stale tile requests');
_tileHttpClient.clearTileQueue();
});
_lastZoom = currentZoom;
_lastCenter = currentCenter;
}
*/
// Request more cameras on any map movement/zoom at valid zoom level (slower debounce)
if (pos.zoom >= 10) {
_cameraDebounce(_refreshCamerasFromProvider);
}
},
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.stopflock.flock_map_app',
tileProvider: NetworkTileProvider(
httpClient: _tileHttpClient,
),
),
cameraLayers,
// Built-in scale bar from flutter_map
Scalebar(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(left: 8, bottom: kScaleBarBottomOffset), // from dev_config
textStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
lineColor: Colors.black,
strokeWidth: 3,
// backgroundColor removed in flutter_map >=8 (wrap in Container if needed)
),
],
),
// All map overlays (mode indicator, zoom, attribution, add pin)
MapOverlays(
mapController: _controller,
uploadMode: appState.uploadMode,
session: session,
),
// Network status indicator (top-left)
const NetworkStatusIndicator(),
],
);
}
}