From 4ed099119378c485aac709bbdb37c93a59effa81 Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 14 Oct 2025 00:29:08 +0200 Subject: [PATCH] Add filter options for SAR markers in detailed compass dialog; implement zoom sensitivity and improve marker display --- lib/screens/map_tab.dart | 439 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 435 insertions(+), 4 deletions(-) diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index e15b302..6269956 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -993,8 +993,16 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { StreamSubscription? _compassSubscription; StreamSubscription? _positionSubscription; double _zoomLevel = 1.0; // 1.0 = default, 0.5 = zoomed out 2x, 2.0 = zoomed in 2x + double _previousScale = 1.0; // Track previous scale for smoother zooming static const double _minZoom = 0.25; static const double _maxZoom = 4.0; + static const double _zoomSensitivity = 0.5; // Lower = less sensitive (0.5 = half speed) + + // Visibility toggles + bool _showContacts = true; + bool _showFoundPerson = true; + bool _showFire = true; + bool _showStagingArea = true; @override void initState() { @@ -1045,6 +1053,128 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { return null; } + // Filter SAR markers based on visibility settings + List _getFilteredSarMarkers() { + return widget.sarMarkers.where((marker) { + switch (marker.type) { + case SarMarkerType.foundPerson: + return _showFoundPerson; + case SarMarkerType.fire: + return _showFire; + case SarMarkerType.stagingArea: + return _showStagingArea; + case SarMarkerType.unknown: + return true; // Always show unknown markers + } + }).toList(); + } + + void _showFilterDialog() { + showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.filter_list, size: 20), + SizedBox(width: 8), + Text('Filter Markers'), + ], + ), + contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Contacts filter + _CompactFilterItem( + icon: Icons.person, + color: Colors.blue, + label: 'Contacts', + value: _showContacts, + onChanged: (value) { + setState(() { + _showContacts = value; + }); + setDialogState(() {}); + }, + ), + const SizedBox(height: 4), + const Divider(height: 8), + const SizedBox(height: 4), + // SAR Markers section + Padding( + padding: const EdgeInsets.only(left: 8, bottom: 8, top: 4), + child: Text( + 'SAR Markers', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + _CompactFilterItem( + icon: Icons.person_pin, + color: Colors.green, + label: 'Found Person', + value: _showFoundPerson, + onChanged: (value) { + setState(() { + _showFoundPerson = value; + }); + setDialogState(() {}); + }, + ), + const SizedBox(height: 4), + _CompactFilterItem( + icon: Icons.local_fire_department, + color: Colors.red, + label: 'Fire', + value: _showFire, + onChanged: (value) { + setState(() { + _showFire = value; + }); + setDialogState(() {}); + }, + ), + const SizedBox(height: 4), + _CompactFilterItem( + icon: Icons.home_work, + color: Colors.orange, + label: 'Staging Area', + value: _showStagingArea, + onChanged: (value) { + setState(() { + _showStagingArea = value; + }); + setDialogState(() {}); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + setState(() { + _showContacts = true; + _showFoundPerson = true; + _showFire = true; + _showStagingArea = true; + }); + setDialogState(() {}); + }, + child: const Text('Show All'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { final heading = currentHeading; @@ -1080,7 +1210,11 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { ], ), ), - const SizedBox(width: 48), // Balance for back button + IconButton( + icon: const Icon(Icons.filter_list), + tooltip: 'Filter markers', + onPressed: () => _showFilterDialog(), + ), ], ), ), @@ -1099,11 +1233,27 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { const SizedBox(height: 12), // Large compass with zoom controls GestureDetector( + onScaleStart: (details) { + _previousScale = 1.0; + }, onScaleUpdate: (details) { setState(() { - _zoomLevel = (_zoomLevel * details.scale).clamp(_minZoom, _maxZoom); + // Calculate scale delta from previous scale + final scaleDelta = details.scale - _previousScale; + + // Apply sensitivity factor to make it more coarse + final adjustedDelta = scaleDelta * _zoomSensitivity; + + // Apply the delta to current zoom level + _zoomLevel = (_zoomLevel * (1.0 + adjustedDelta)).clamp(_minZoom, _maxZoom); + + // Update previous scale + _previousScale = details.scale; }); }, + onScaleEnd: (details) { + _previousScale = 1.0; + }, child: SizedBox( width: 300, height: 300, @@ -1111,14 +1261,17 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { heading: heading ?? 0, hasHeading: heading != null, currentPosition: position, - contacts: widget.contacts, + contacts: _showContacts ? widget.contacts : [], + sarMarkers: _getFilteredSarMarkers(), zoomLevel: _zoomLevel, ), ), ), const SizedBox(height: 12), // Contacts list - if (widget.contacts.isNotEmpty) _buildContactsList(context, heading, position), + if (_showContacts && widget.contacts.isNotEmpty) _buildContactsList(context, heading, position), + // SAR Markers list + if (_getFilteredSarMarkers().isNotEmpty) _buildSarMarkersList(context, heading, position), ], ), ), @@ -1279,6 +1432,111 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { ); } + Widget _buildSarMarkersList(BuildContext context, double? heading, Position? position) { + if (position == null) { + return const Text('Location unavailable'); + } + + // Use filtered SAR markers + final filteredMarkers = _getFilteredSarMarkers(); + + // Calculate bearings and distances for SAR markers + final markersWithBearing = filteredMarkers.map((marker) { + final bearing = _calculateBearing( + position.latitude, + position.longitude, + marker.location.latitude, + marker.location.longitude, + ); + + final distance = _calculateDistance( + position.latitude, + position.longitude, + marker.location.latitude, + marker.location.longitude, + ); + + return { + 'marker': marker, + 'bearing': bearing, + 'distance': distance, + }; + }).toList(); + + // Sort by distance + markersWithBearing.sort((a, b) => + (a['distance'] as double).compareTo(b['distance'] as double)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16, top: 16, bottom: 8), + child: Text( + 'SAR Markers', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ...markersWithBearing.map((item) { + final marker = item['marker'] as SarMarker; + final bearing = item['bearing'] as double; + final distance = item['distance'] as double; + + // Determine color and icon based on marker type + Color markerColor; + IconData markerIcon; + switch (marker.type) { + case SarMarkerType.foundPerson: + markerColor = Colors.green; + markerIcon = Icons.person_pin; + break; + case SarMarkerType.fire: + markerColor = Colors.red; + markerIcon = Icons.local_fire_department; + break; + case SarMarkerType.stagingArea: + markerColor = Colors.orange; + markerIcon = Icons.home_work; + break; + case SarMarkerType.unknown: + markerColor = Colors.grey; + markerIcon = Icons.help_outline; + break; + } + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: ListTile( + dense: true, + leading: Icon( + markerIcon, + color: markerColor, + size: 24, + ), + title: Text(marker.type.displayName), + subtitle: Text( + '${_bearingToCardinal(bearing)} • ${_formatDistance(distance)} • ${marker.timeAgo}', + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: Text( + '${bearing.round()}°', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ); + }), + ], + ); + } + // Calculate bearing between two points (in degrees) double _calculateBearing( double lat1, double lon1, double lat2, double lon2) { @@ -1332,6 +1590,7 @@ class _DetailedCompassPainter extends StatelessWidget { final bool hasHeading; final Position? currentPosition; final List contacts; + final List sarMarkers; final double zoomLevel; const _DetailedCompassPainter({ @@ -1339,6 +1598,7 @@ class _DetailedCompassPainter extends StatelessWidget { required this.hasHeading, required this.currentPosition, required this.contacts, + required this.sarMarkers, this.zoomLevel = 1.0, }); @@ -1350,6 +1610,7 @@ class _DetailedCompassPainter extends StatelessWidget { hasHeading: hasHeading, currentPosition: currentPosition, contacts: contacts, + sarMarkers: sarMarkers, zoomLevel: zoomLevel, ), child: Container(), @@ -1362,6 +1623,7 @@ class _LargeCompassPainter extends CustomPainter { final bool hasHeading; final Position? currentPosition; final List contacts; + final List sarMarkers; final double zoomLevel; _LargeCompassPainter({ @@ -1369,6 +1631,7 @@ class _LargeCompassPainter extends CustomPainter { required this.hasHeading, required this.currentPosition, required this.contacts, + required this.sarMarkers, this.zoomLevel = 1.0, }); @@ -1546,6 +1809,128 @@ class _LargeCompassPainter extends CustomPainter { } } + // Draw SAR markers as colored dots relative to distance, scaled by zoom level + if (currentPosition != null && sarMarkers.isNotEmpty) { + // Calculate distances for all SAR markers + final markersWithDistance = sarMarkers.map((marker) { + final bearing = _calculateBearing( + currentPosition!.latitude, + currentPosition!.longitude, + marker.location.latitude, + marker.location.longitude, + ); + final distance = _calculateDistance( + currentPosition!.latitude, + currentPosition!.longitude, + marker.location.latitude, + marker.location.longitude, + ); + return {'marker': marker, 'bearing': bearing, 'distance': distance}; + }).toList(); + + // Base distance for zoom level 1.0 (in meters) + final baseDistance = 1000.0 / zoomLevel; + + for (final item in markersWithDistance) { + final marker = item['marker'] as SarMarker; + final bearing = item['bearing'] as double; + final distance = item['distance'] as double; + + // Adjust bearing relative to current heading + final relativeBearing = (bearing - heading + 360) % 360; + final angle = relativeBearing * pi / 180 - pi / 2; + + // Calculate normalized distance (0 to 1, where 1 is at the rim) + double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0); + + // Calculate marker position radius (from center to rim based on distance) + final markerRadius = radius * normalizedDistance * 0.85; + + // Position of marker dot + final dotX = center.dx + markerRadius * cos(angle); + final dotY = center.dy + markerRadius * sin(angle); + + // Determine color based on SAR marker type + Color markerColor; + switch (marker.type) { + case SarMarkerType.foundPerson: + markerColor = Colors.green; + break; + case SarMarkerType.fire: + markerColor = Colors.red; + break; + case SarMarkerType.stagingArea: + markerColor = Colors.orange; + break; + case SarMarkerType.unknown: + markerColor = Colors.grey; + break; + } + + // Draw line from center to SAR marker + final linePaint = Paint() + ..color = markerColor.withValues(alpha: 0.3) + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + canvas.drawLine( + center, + Offset(dotX, dotY), + linePaint, + ); + + // Draw SAR marker dot (slightly larger than contacts) + final dotSize = (8.0 * (1.0 + zoomLevel * 0.3)).clamp(6.0, 14.0); + final dotPaint = Paint() + ..color = markerColor + ..style = PaintingStyle.fill; + canvas.drawCircle(Offset(dotX, dotY), dotSize, dotPaint); + + // Draw white border + final borderPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5; + canvas.drawCircle(Offset(dotX, dotY), dotSize, borderPaint); + + // Draw distance label near the SAR marker + if (zoomLevel >= 0.75) { + final distanceText = _formatDistance(distance); + final labelOffset = dotSize + 14; + final labelX = center.dx + (markerRadius + labelOffset) * cos(angle); + final labelY = center.dy + (markerRadius + labelOffset) * sin(angle); + + textPainter.text = TextSpan( + text: distanceText, + style: TextStyle( + color: markerColor, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ); + textPainter.layout(); + + // Draw background for readability + final bgRect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(labelX, labelY), + width: textPainter.width + 4, + height: textPainter.height + 2, + ), + const Radius.circular(3), + ); + final bgPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.9) + ..style = PaintingStyle.fill; + canvas.drawRRect(bgRect, bgPaint); + + textPainter.paint( + canvas, + Offset(labelX - textPainter.width / 2, labelY - textPainter.height / 2), + ); + } + } + } + // Draw center heading indicator (fixed pointing up) final indicatorPaint = Paint() ..color = hasHeading ? Colors.red : Colors.grey @@ -1602,6 +1987,52 @@ class _LargeCompassPainter extends CustomPainter { bool shouldRepaint(covariant CustomPainter oldDelegate) => true; } +// Compact filter item widget +class _CompactFilterItem extends StatelessWidget { + final IconData icon; + final Color color; + final String label; + final bool value; + final ValueChanged onChanged; + + const _CompactFilterItem({ + required this.icon, + required this.color, + required this.label, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => onChanged(!value), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + Icon(icon, size: 20, color: color), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + Checkbox( + value: value, + onChanged: (val) => onChanged(val ?? false), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + ); + } +} + // Location format toggle widget class _LocationFormatToggle extends StatefulWidget { final Position position;