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 '../app_state.dart'; import '../services/overpass_service.dart'; import '../models/osm_camera_node.dart'; import 'debouncer.dart'; class MapView extends StatefulWidget { const MapView({ super.key, required this.followMe, required this.onUserGesture, }); final bool followMe; final VoidCallback onUserGesture; @override State createState() => _MapViewState(); } class _MapViewState extends State { final MapController _controller = MapController(); final OverpassService _overpass = OverpassService(); final Debouncer _debounce = Debouncer(const Duration(milliseconds: 500)); StreamSubscription? _positionSub; LatLng? _currentLatLng; List _cameras = []; @override void initState() { super.initState(); _initLocation(); } @override void dispose() { _positionSub?.cancel(); _debounce.dispose(); super.dispose(); } @override void didUpdateWidget(covariant MapView oldWidget) { super.didUpdateWidget(oldWidget); if (widget.followMe && !oldWidget.followMe && _currentLatLng != null) { _controller.move(_currentLatLng!, _controller.camera.zoom); } } Future _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) { _controller.move(latLng, _controller.camera.zoom); } }); } Future _refreshCameras(AppState appState) async { final bounds = _controller.camera.visibleBounds; final cams = await _overpass.fetchCameras(bounds, appState.enabledProfiles); if (mounted) setState(() => _cameras = cams); } @override Widget build(BuildContext context) { final appState = context.watch(); final session = appState.session; final markers = [ if (_currentLatLng != null) Marker( point: _currentLatLng!, width: 16, height: 16, child: const Icon(Icons.my_location, color: Colors.blue), ), ..._cameras.map( (n) => Marker( point: n.coord, width: 24, height: 24, child: const Icon(Icons.videocam, color: Colors.orange), ), ), ]; final overlays = [ if (session != null && session.target != null) _buildCone(session.target!, session.directionDegrees), ..._cameras .where((n) => n.hasDirection) .map( (n) => _buildCone(n.coord, n.directionDeg!), ), ]; return Stack( children: [ FlutterMap( mapController: _controller, options: MapOptions( center: _currentLatLng ?? LatLng(37.7749, -122.4194), zoom: 15, maxZoom: 19, onPositionChanged: (pos, gesture) { if (gesture) widget.onUserGesture(); if (session != null) { appState.updateSession(target: pos.center); } _debounce(() => _refreshCameras(appState)); }, ), children: [ TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.example.flock_map_app', ), PolygonLayer(polygons: overlays), MarkerLayer(markers: markers), ], ), if (session != null) const IgnorePointer( child: Center( child: Icon(Icons.place, size: 40, color: Colors.redAccent), ), ), ], ); } Polygon _buildCone(LatLng origin, double bearingDeg) { const halfAngle = 15.0; const length = 0.08; // ~80 m LatLng _project(double deg) { final rad = deg * math.pi / 180; final dLat = length * math.cos(rad); final dLon = length * math.sin(rad) / math.cos(origin.latitude * math.pi / 180); return LatLng(origin.latitude + dLat, origin.longitude + dLon); } final left = _project(bearingDeg - halfAngle); final right = _project(bearingDeg + halfAngle); return Polygon( points: [origin, left, right], color: Colors.redAccent.withOpacity(0.25), borderStrokeWidth: 0, ); } }