From dbaf09e70116b58d5fd0e11458176b07c976861d Mon Sep 17 00:00:00 2001 From: Janez T Date: Wed, 15 Oct 2025 10:39:14 +0200 Subject: [PATCH] feat: Add LocationDisplay widget for reusable location display with modal formats feat: Enhance CompassContactList and CompassSarList with relative bearing display refactor: Update DetailedCompassDialog to integrate LocationDisplay and improve layout --- lib/widgets/common/location_display.dart | 268 ++++++++++++++++++ .../map/compass/compass_contact_list.dart | 46 ++- lib/widgets/map/compass/compass_sar_list.dart | 46 ++- lib/widgets/map/detailed_compass_dialog.dart | 95 ++++--- 4 files changed, 410 insertions(+), 45 deletions(-) create mode 100644 lib/widgets/common/location_display.dart diff --git a/lib/widgets/common/location_display.dart b/lib/widgets/common/location_display.dart new file mode 100644 index 0000000..e6012ad --- /dev/null +++ b/lib/widgets/common/location_display.dart @@ -0,0 +1,268 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:latlong2/latlong.dart'; + +/// Reusable location display widget with tap-to-show modal +/// Shows coordinates in a compact format with ability to view all formats +class LocationDisplay extends StatelessWidget { + final LatLng location; + final bool compact; + + const LocationDisplay({ + super.key, + required this.location, + this.compact = true, + }); + + @override + Widget build(BuildContext context) { + if (compact) { + return GestureDetector( + onTap: () => _showLocationFormats(context), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.location_on, size: 16), + const SizedBox(width: 6), + Text( + '${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.open_in_new, + size: 14, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + ); + } + + // Non-compact version (just text) + return Text( + '${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + ), + ); + } + + void _showLocationFormats(BuildContext context) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Location Formats', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + // Decimal Degrees (DD) + _buildFormatRow( + context, + 'DD (Decimal Degrees)', + '${location.latitude.toStringAsFixed(6)}, ${location.longitude.toStringAsFixed(6)}', + ), + // Degrees Minutes Seconds (DMS) + _buildFormatRow( + context, + 'DMS (Degrees Minutes Seconds)', + _convertToDMS(location.latitude, location.longitude), + ), + // Degrees Decimal Minutes (DDM) + _buildFormatRow( + context, + 'DDM (Degrees Decimal Minutes)', + _convertToDDM(location.latitude, location.longitude), + ), + // MGRS (Military Grid Reference System) + _buildFormatRow( + context, + 'MGRS (Military Grid)', + _convertToMGRS(location.latitude, location.longitude), + ), + // Google Plus Code + _buildFormatRow( + context, + 'Plus Code', + _convertToPlusCode(location.latitude, location.longitude), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + Widget _buildFormatRow(BuildContext context, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Colors.grey, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: value)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$label copied to clipboard'), + duration: const Duration(seconds: 2), + ), + ); + }, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded( + child: Text( + value, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w500, + ), + ), + ), + Icon( + Icons.copy, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + ), + ], + ), + ); + } + + /// Convert to Degrees Minutes Seconds (DMS) format + String _convertToDMS(double lat, double lon) { + String latDir = lat >= 0 ? 'N' : 'S'; + String lonDir = lon >= 0 ? 'E' : 'W'; + + lat = lat.abs(); + lon = lon.abs(); + + int latDeg = lat.floor(); + double latMinDec = (lat - latDeg) * 60; + int latMin = latMinDec.floor(); + double latSec = (latMinDec - latMin) * 60; + + int lonDeg = lon.floor(); + double lonMinDec = (lon - lonDeg) * 60; + int lonMin = lonMinDec.floor(); + double lonSec = (lonMinDec - lonMin) * 60; + + return '$latDeg°$latMin\'${latSec.toStringAsFixed(2)}"$latDir, $lonDeg°$lonMin\'${lonSec.toStringAsFixed(2)}"$lonDir'; + } + + /// Convert to Degrees Decimal Minutes (DDM) format + String _convertToDDM(double lat, double lon) { + String latDir = lat >= 0 ? 'N' : 'S'; + String lonDir = lon >= 0 ? 'E' : 'W'; + + lat = lat.abs(); + lon = lon.abs(); + + int latDeg = lat.floor(); + double latMin = (lat - latDeg) * 60; + + int lonDeg = lon.floor(); + double lonMin = (lon - lonDeg) * 60; + + return '$latDeg° ${latMin.toStringAsFixed(4)}\'$latDir, $lonDeg° ${lonMin.toStringAsFixed(4)}\'$lonDir'; + } + + /// Convert to MGRS (Military Grid Reference System) format + /// Simplified implementation - returns approximate grid zone + String _convertToMGRS(double lat, double lon) { + // Zone number (1-60) + int zone = ((lon + 180) / 6).floor() + 1; + + // Zone letter (C-X, excluding I and O) + const letters = 'CDEFGHJKLMNPQRSTUVWX'; + int letterIndex = ((lat + 80) / 8).floor(); + if (letterIndex < 0) letterIndex = 0; + if (letterIndex >= letters.length) letterIndex = letters.length - 1; + String letter = letters[letterIndex]; + + // Simplified - just show zone designation + // Full MGRS would require UTM conversion library + return '$zone$letter (approximate)'; + } + + /// Convert to Google Plus Code format + /// Simplified implementation - returns approximate code + String _convertToPlusCode(double lat, double lon) { + // This is a simplified version - full Plus Code requires the open_location_code package + const base = '23456789CFGHJMPQRVWX'; + + // Normalize coordinates + lat = (lat + 90) / 180; // 0 to 1 + lon = (lon + 180) / 360; // 0 to 1 + + String code = ''; + for (int i = 0; i < 8; i++) { + if (i == 4) code += '+'; + + int latDigit = (lat * 20).floor() % 20; + int lonDigit = (lon * 20).floor() % 20; + + code += base[latDigit]; + code += base[lonDigit]; + + lat = (lat * 20) % 1; + lon = (lon * 20) % 1; + } + + return code; + } +} diff --git a/lib/widgets/map/compass/compass_contact_list.dart b/lib/widgets/map/compass/compass_contact_list.dart index adf4832..e5fc769 100644 --- a/lib/widgets/map/compass/compass_contact_list.dart +++ b/lib/widgets/map/compass/compass_contact_list.dart @@ -8,6 +8,7 @@ import '../../../models/contact.dart'; class CompassContactList extends StatelessWidget { final List contacts; final Position? position; + final double? heading; final Contact? selectedContact; final ValueChanged onContactTap; @@ -15,6 +16,7 @@ class CompassContactList extends StatelessWidget { super.key, required this.contacts, required this.position, + this.heading, required this.selectedContact, required this.onContactTap, }); @@ -106,11 +108,24 @@ class CompassContactList extends StatelessWidget { '${_bearingToCardinal(bearing)} • ${_formatDistance(distance)}', style: Theme.of(context).textTheme.bodySmall, ), - trailing: Text( - '${bearing.round()}°', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.bold, + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${bearing.round()}°', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (heading != null) + Text( + _formatRelativeBearing(bearing, heading!), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.grey, + ), ), + ], ), onTap: () { if (selectedContact == contact) { @@ -173,4 +188,27 @@ class CompassContactList extends StatelessWidget { return '${(meters / 1000).toStringAsFixed(1)}km'; } } + + String _formatRelativeBearing(double bearing, double heading) { + // Calculate relative bearing (how much to turn from current heading) + double relative = bearing - heading; + + // Normalize to -180 to +180 + while (relative > 180) { + relative -= 360; + } + while (relative < -180) { + relative += 360; + } + + final absRelative = relative.abs().round(); + + if (absRelative < 10) { + return 'ahead'; + } else if (relative > 0) { + return '$absRelative° right'; + } else { + return '$absRelative° left'; + } + } } diff --git a/lib/widgets/map/compass/compass_sar_list.dart b/lib/widgets/map/compass/compass_sar_list.dart index ea4013e..f3d9dbf 100644 --- a/lib/widgets/map/compass/compass_sar_list.dart +++ b/lib/widgets/map/compass/compass_sar_list.dart @@ -8,6 +8,7 @@ import '../../../models/sar_marker.dart'; class CompassSarList extends StatelessWidget { final List sarMarkers; final Position? position; + final double? heading; final SarMarker? selectedSarMarker; final ValueChanged onSarMarkerTap; @@ -15,6 +16,7 @@ class CompassSarList extends StatelessWidget { super.key, required this.sarMarkers, required this.position, + this.heading, required this.selectedSarMarker, required this.onSarMarkerTap, }); @@ -125,11 +127,24 @@ class CompassSarList extends StatelessWidget { '${_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, + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${bearing.round()}°', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (heading != null) + Text( + _formatRelativeBearing(bearing, heading!), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.grey, + ), ), + ], ), onTap: () { if (selectedSarMarker == marker) { @@ -192,4 +207,27 @@ class CompassSarList extends StatelessWidget { return '${(meters / 1000).toStringAsFixed(1)}km'; } } + + String _formatRelativeBearing(double bearing, double heading) { + // Calculate relative bearing (how much to turn from current heading) + double relative = bearing - heading; + + // Normalize to -180 to +180 + while (relative > 180) { + relative -= 360; + } + while (relative < -180) { + relative += 360; + } + + final absRelative = relative.abs().round(); + + if (absRelative < 10) { + return 'ahead'; + } else if (relative > 0) { + return '$absRelative° right'; + } else { + return '$absRelative° left'; + } + } } diff --git a/lib/widgets/map/detailed_compass_dialog.dart b/lib/widgets/map/detailed_compass_dialog.dart index 1420c15..6b051be 100644 --- a/lib/widgets/map/detailed_compass_dialog.dart +++ b/lib/widgets/map/detailed_compass_dialog.dart @@ -6,6 +6,7 @@ import 'package:flutter_compass/flutter_compass.dart'; import 'package:latlong2/latlong.dart'; import '../../models/contact.dart'; import '../../models/sar_marker.dart'; +import '../common/location_display.dart'; import 'compass/compass_header.dart'; import 'compass/compass_filters.dart'; import 'compass/compass_sar_list.dart'; @@ -147,6 +148,39 @@ class _DetailedCompassDialogState extends State { _previousScale = 1.0; } + // Calculate appropriate zoom level for selected item + void _autoZoomForSelection() { + if (_currentPosition == null) return; + + double? targetDistance; + + if (_selectedContact != null && _selectedContact!.displayLocation != null) { + targetDistance = _calculateDistance( + _currentPosition!.latitude, + _currentPosition!.longitude, + _selectedContact!.displayLocation!.latitude, + _selectedContact!.displayLocation!.longitude, + ); + } else if (_selectedSarMarker != null) { + targetDistance = _calculateDistance( + _currentPosition!.latitude, + _currentPosition!.longitude, + _selectedSarMarker!.location.latitude, + _selectedSarMarker!.location.longitude, + ); + } + + if (targetDistance != null) { + // Calculate zoom level to fit target within 70% of compass radius + // Base distance at 1x zoom is 1000m + // We want target at 70% of radius, so: targetDistance / zoomLevel = 700m + final targetZoom = (targetDistance / 700.0).clamp(_minZoom, _maxZoom); + setState(() { + _zoomLevel = targetZoom; + }); + } + } + @override Widget build(BuildContext context) { final heading = currentHeading; @@ -258,6 +292,7 @@ class _DetailedCompassDialogState extends State { CompassContactList( contacts: widget.contacts, position: position, + heading: heading, selectedContact: _selectedContact, onContactTap: (contact) { setState(() { @@ -266,6 +301,7 @@ class _DetailedCompassDialogState extends State { _selectedSarMarker = null; } }); + _autoZoomForSelection(); }, ), // SAR Markers list @@ -273,6 +309,7 @@ class _DetailedCompassDialogState extends State { CompassSarList( sarMarkers: _getFilteredSarMarkers(), position: position, + heading: heading, selectedSarMarker: _selectedSarMarker, onSarMarkerTap: (marker) { setState(() { @@ -281,6 +318,7 @@ class _DetailedCompassDialogState extends State { _selectedContact = null; } }); + _autoZoomForSelection(); }, ), ], @@ -384,14 +422,14 @@ class _DetailedCompassDialogState extends State { margin: const EdgeInsets.symmetric(horizontal: 16), elevation: 4, child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), child: Column( children: [ // Header with icon and title Row( children: [ Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: color.withValues(alpha: 0.2), shape: BoxShape.circle, @@ -399,25 +437,25 @@ class _DetailedCompassDialogState extends State { child: _selectedContact != null && _selectedContact!.roleEmoji != null ? Text( _selectedContact!.roleEmoji!, - style: const TextStyle(fontSize: 32), + style: const TextStyle(fontSize: 24), ) - : Icon(icon, size: 32, color: color), + : Icon(icon, size: 24, color: color), ), - const SizedBox(width: 16), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, - style: Theme.of(context).textTheme.titleLarge?.copyWith( + style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), if (additionalInfo != null) Text( additionalInfo, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( + style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey, ), ), @@ -425,7 +463,9 @@ class _DetailedCompassDialogState extends State { ), ), IconButton( - icon: const Icon(Icons.close), + icon: const Icon(Icons.close, size: 20), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), onPressed: () { setState(() { _selectedContact = null; @@ -436,9 +476,9 @@ class _DetailedCompassDialogState extends State { ], ), if (bearing != null && distance != null) ...[ - const SizedBox(height: 16), - const Divider(), - const SizedBox(height: 16), + const SizedBox(height: 12), + const Divider(height: 1), + const SizedBox(height: 12), // Distance and bearing info Row( mainAxisAlignment: MainAxisAlignment.spaceAround, @@ -466,29 +506,10 @@ class _DetailedCompassDialogState extends State { ), ], ), - const SizedBox(height: 12), - // Coordinates + const SizedBox(height: 8), + // Coordinates with modal if (targetLocation != null) - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.location_on, size: 16), - const SizedBox(width: 8), - Text( - '${targetLocation.latitude.toStringAsFixed(5)}, ${targetLocation.longitude.toStringAsFixed(5)}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), - ), - ], - ), - ), + LocationDisplay(location: targetLocation), ], ], ), @@ -505,18 +526,18 @@ class _DetailedCompassDialogState extends State { ) { return Column( children: [ - Icon(icon, size: 28, color: color), - const SizedBox(height: 8), + Icon(icon, size: 20, color: color), + const SizedBox(height: 4), Text( value, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( + style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, color: color, ), ), Text( label, - style: Theme.of(context).textTheme.bodySmall, + style: Theme.of(context).textTheme.labelSmall, ), ], );