From 1272eb9409b0809465939ff206b8769bcd123df1 Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 21 Aug 2025 19:15:59 -0500 Subject: [PATCH] break up map_view --- lib/widgets/map/camera_markers.dart | 94 ++++++++++++ lib/widgets/map/direction_cones.dart | 76 +++++++++ lib/widgets/map/map_overlays.dart | 107 +++++++++++++ lib/widgets/map_view.dart | 220 +++------------------------ 4 files changed, 298 insertions(+), 199 deletions(-) create mode 100644 lib/widgets/map/camera_markers.dart create mode 100644 lib/widgets/map/direction_cones.dart create mode 100644 lib/widgets/map/map_overlays.dart diff --git a/lib/widgets/map/camera_markers.dart b/lib/widgets/map/camera_markers.dart new file mode 100644 index 0000000..d143ede --- /dev/null +++ b/lib/widgets/map/camera_markers.dart @@ -0,0 +1,94 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +import '../../dev_config.dart'; +import '../../models/osm_camera_node.dart'; +import '../camera_tag_sheet.dart'; + +/// Smart marker widget for camera with single/double tap distinction +class CameraMapMarker extends StatefulWidget { + final OsmCameraNode node; + final MapController mapController; + const CameraMapMarker({required this.node, required this.mapController, Key? key}) : super(key: key); + + @override + State createState() => _CameraMapMarkerState(); +} + +class _CameraMapMarkerState extends State { + Timer? _tapTimer; + // From dev_config.dart for build-time parameters + static const Duration tapTimeout = kMarkerTapTimeout; + + void _onTap() { + _tapTimer = Timer(tapTimeout, () { + showModalBottomSheet( + context: context, + builder: (_) => CameraTagSheet(node: widget.node), + showDragHandle: true, + ); + }); + } + + void _onDoubleTap() { + _tapTimer?.cancel(); + widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + 1); + } + + @override + void dispose() { + _tapTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Check if this is a pending upload + final isPending = widget.node.tags.containsKey('_pending_upload') && + widget.node.tags['_pending_upload'] == 'true'; + + return GestureDetector( + onTap: _onTap, + onDoubleTap: _onDoubleTap, + child: Icon( + Icons.videocam, + color: isPending ? Colors.purple : Colors.orange, + ), + ); + } +} + +/// Helper class to build marker layers for cameras and user location +class CameraMarkersBuilder { + static List buildCameraMarkers({ + required List cameras, + required MapController mapController, + LatLng? userLocation, + }) { + final markers = [ + // Camera markers + ...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( + point: n.coord, + width: 24, + height: 24, + child: CameraMapMarker(node: n, mapController: mapController), + )), + + // User location marker + if (userLocation != null) + Marker( + point: userLocation, + width: 16, + height: 16, + child: const Icon(Icons.my_location, color: Colors.blue), + ), + ]; + + return markers; + } +} \ No newline at end of file diff --git a/lib/widgets/map/direction_cones.dart b/lib/widgets/map/direction_cones.dart new file mode 100644 index 0000000..d53c5b7 --- /dev/null +++ b/lib/widgets/map/direction_cones.dart @@ -0,0 +1,76 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +import '../../app_state.dart'; +import '../../dev_config.dart'; +import '../../models/osm_camera_node.dart'; + +/// Helper class to build direction cone polygons for cameras +class DirectionConesBuilder { + static List buildDirectionCones({ + required List cameras, + required double zoom, + AddCameraSession? session, + }) { + final overlays = []; + + // Add session cone if in add-camera mode + if (session != null && session.target != null) { + overlays.add(_buildCone( + session.target!, + session.directionDegrees, + zoom, + )); + } + + // Add cones for cameras with direction + overlays.addAll( + 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) + .map((n) => _buildCone( + n.coord, + n.directionDeg!, + zoom, + isPending: n.tags.containsKey('_pending_upload') && + n.tags['_pending_upload'] == 'true', + )) + ); + + return overlays; + } + + static Polygon _buildCone( + LatLng origin, + double bearingDeg, + double zoom, { + bool isPending = false, + }) { + final halfAngle = kDirectionConeHalfAngle; + final length = kDirectionConeBaseLength * math.pow(2, 15 - zoom); + + 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); + + // Use purple color for pending uploads + final color = isPending ? Colors.purple : Colors.redAccent; + + return Polygon( + points: [origin, left, right, origin], + color: color.withOpacity(0.25), + borderColor: color, + borderStrokeWidth: 1, + ); + } +} \ No newline at end of file diff --git a/lib/widgets/map/map_overlays.dart b/lib/widgets/map/map_overlays.dart new file mode 100644 index 0000000..c05529e --- /dev/null +++ b/lib/widgets/map/map_overlays.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; + +import '../../app_state.dart'; +import '../../dev_config.dart'; + +/// Widget that renders all map overlay UI elements +class MapOverlays extends StatelessWidget { + final MapController mapController; + final UploadMode uploadMode; + final AddCameraSession? session; + + const MapOverlays({ + super.key, + required this.mapController, + required this.uploadMode, + this.session, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + // MODE INDICATOR badge (top-right) + if (uploadMode == UploadMode.sandbox || uploadMode == UploadMode.simulate) + Positioned( + top: 18, + right: 14, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: uploadMode == UploadMode.sandbox + ? Colors.orange.withOpacity(0.90) + : Colors.deepPurple.withOpacity(0.80), + borderRadius: BorderRadius.circular(8), + boxShadow: const [ + BoxShadow(color: Colors.black26, blurRadius: 5, offset: Offset(0,2)), + ], + ), + child: Text( + uploadMode == UploadMode.sandbox + ? 'SANDBOX MODE' + : 'SIMULATE', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 13, + letterSpacing: 1.1, + ), + ), + ), + ), + + // Zoom indicator, positioned above scale bar + Positioned( + left: 10, + bottom: kZoomIndicatorBottomOffset, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.52), + borderRadius: BorderRadius.circular(7), + ), + child: Builder( + builder: (context) { + final zoom = mapController.camera.zoom; + return Text( + 'Zoom: ${zoom.toStringAsFixed(2)}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ); + }, + ), + ), + ), + + // Attribution overlay + Positioned( + bottom: kAttributionBottomOffset, + left: 10, + child: Container( + color: Colors.white70, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: const Text( + '© OpenStreetMap and contributors', + style: TextStyle(fontSize: 11), + ), + ), + ), + + // Fixed pin when adding camera + if (session != null) + IgnorePointer( + child: Center( + child: Transform.translate( + offset: const Offset(0, kAddPinYOffset), + child: const Icon(Icons.place, size: 40, color: Colors.redAccent), + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index b74196b..0c58a01 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -1,14 +1,6 @@ import 'dart:async'; -import 'dart:math' as math; -import 'dart:io'; -import 'dart:typed_data'; -import 'dart:ui' as ui; - -import 'package:http/io_client.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart'; import 'package:geolocator/geolocator.dart'; import 'package:provider/provider.dart'; @@ -18,63 +10,12 @@ import '../services/map_data_provider.dart'; import '../services/offline_area_service.dart'; 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 -class _CameraMapMarker extends StatefulWidget { - final OsmCameraNode node; - final MapController mapController; - const _CameraMapMarker({required this.node, required this.mapController, Key? key}) : super(key: key); - - @override - State<_CameraMapMarker> createState() => _CameraMapMarkerState(); -} - -class _CameraMapMarkerState extends State<_CameraMapMarker> { - Timer? _tapTimer; - // From dev_config.dart for build-time parameters - static const Duration tapTimeout = kMarkerTapTimeout; - - void _onTap() { - _tapTimer = Timer(tapTimeout, () { - showModalBottomSheet( - context: context, - builder: (_) => CameraTagSheet(node: widget.node), - showDragHandle: true, - ); - }); - } - - void _onDoubleTap() { - _tapTimer?.cancel(); - widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + 1); - } - - @override - void dispose() { - _tapTimer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - // Check if this is a pending upload - final isPending = widget.node.tags.containsKey('_pending_upload') && - widget.node.tags['_pending_upload'] == 'true'; - - return GestureDetector( - onTap: _onTap, - onDoubleTap: _onDoubleTap, - child: Icon( - Icons.videocam, - color: isPending ? Colors.purple : Colors.orange, - ), - ); - } -} +import 'map/camera_markers.dart'; +import 'map/direction_cones.dart'; +import 'map/map_overlays.dart'; +import '../dev_config.dart'; class MapView extends StatefulWidget { final MapController controller; @@ -229,39 +170,19 @@ class _MapViewState extends State { final cameras = (mapBounds != null) ? cameraProvider.getCachedCamerasForBounds(mapBounds) : []; - final markers = [ - ...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( - point: n.coord, - width: 24, - height: 24, - child: _CameraMapMarker(node: n, mapController: _controller), - )), - if (_currentLatLng != null) - Marker( - point: _currentLatLng!, - width: 16, - height: 16, - child: const Icon(Icons.my_location, color: Colors.blue), - ), - ]; + + final markers = CameraMarkersBuilder.buildCameraMarkers( + cameras: cameras, + mapController: _controller, + userLocation: _currentLatLng, + ); + + final overlays = DirectionConesBuilder.buildDirectionCones( + cameras: cameras, + zoom: zoom, + session: session, + ); - final overlays = [ - if (session != null && session.target != null) - _buildCone(session.target!, session.directionDegrees, zoom), - ...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) - .map((n) => _buildCone( - n.coord, - n.directionDeg!, - zoom, - isPending: n.tags.containsKey('_pending_upload') && n.tags['_pending_upload'] == 'true', - )), - ]; return Stack( children: [ PolygonLayer(polygons: overlays), @@ -332,113 +253,14 @@ class _MapViewState extends State { ], ), - // MODE INDICATOR badge (top-right) - if (appState.uploadMode == UploadMode.sandbox || appState.uploadMode == UploadMode.simulate) - Positioned( - top: 18, - right: 14, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: appState.uploadMode == UploadMode.sandbox - ? Colors.orange.withOpacity(0.90) - : Colors.deepPurple.withOpacity(0.80), - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow(color: Colors.black26, blurRadius: 5, offset: Offset(0,2)), - ], - ), - child: Text( - appState.uploadMode == UploadMode.sandbox - ? 'SANDBOX MODE' - : 'SIMULATE', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 13, - letterSpacing: 1.1, - ), - ), - ), - ), - - // Zoom indicator, positioned above scale bar - Positioned( - left: 10, - bottom: kZoomIndicatorBottomOffset, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.52), - borderRadius: BorderRadius.circular(7), - ), - child: Builder( - builder: (context) { - final zoom = _controller.camera.zoom; - return Text( - 'Zoom: ${zoom.toStringAsFixed(2)}', - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ); - }, - ), - ), + // All map overlays (mode indicator, zoom, attribution, add pin) + MapOverlays( + mapController: _controller, + uploadMode: appState.uploadMode, + session: session, ), - // Attribution overlay - Positioned( - bottom: kAttributionBottomOffset, - left: 10, - child: Container( - color: Colors.white70, - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: const Text( - '© OpenStreetMap and contributors', - style: TextStyle(fontSize: 11), - ), - ), - ), - - // Fixed pin when adding camera - if (session != null) - IgnorePointer( - child: Center( - child: Transform.translate( - offset: Offset(0, kAddPinYOffset), - child: Icon(Icons.place, size: 40, color: Colors.redAccent), - ), - ), - ), ], ); } - - Polygon _buildCone(LatLng origin, double bearingDeg, double zoom, {bool isPending = false}) { - final halfAngle = kDirectionConeHalfAngle; - final length = kDirectionConeBaseLength * math.pow(2, 15 - zoom); - - 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); - - // Use purple color for pending uploads - final color = isPending ? Colors.purple : Colors.redAccent; - - return Polygon( - points: [origin, left, right, origin], - color: color.withOpacity(0.25), - borderColor: color, - borderStrokeWidth: 1, - ); - } }