diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index 90c9dac..9fbcafd 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -1,17 +1,10 @@ -import 'dart:typed_data'; import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:geolocator/geolocator.dart'; -import 'package:latlong2/latlong.dart'; import '../providers/contacts_provider.dart'; -import '../providers/connection_provider.dart'; import '../providers/app_provider.dart'; -import '../providers/map_provider.dart'; -import '../models/contact.dart'; -import '../models/room_login_state.dart'; +import '../widgets/contacts/contact_tile.dart'; class ContactsTab extends StatefulWidget { const ContactsTab({super.key}); @@ -124,7 +117,7 @@ class _ContactsTabState extends State { count: chatContacts.length, icon: Icons.people, ), - ...chatContacts.map((contact) => _ContactTile( + ...chatContacts.map((contact) => ContactTile( contact: contact, currentPosition: _currentPosition, calculateDistance: _calculateDistanceInMeters, @@ -140,7 +133,7 @@ class _ContactsTabState extends State { count: repeaters.length, icon: Icons.router, ), - ...repeaters.map((contact) => _ContactTile( + ...repeaters.map((contact) => ContactTile( contact: contact, currentPosition: _currentPosition, calculateDistance: _calculateDistanceInMeters, @@ -156,7 +149,7 @@ class _ContactsTabState extends State { count: rooms.length, icon: Icons.tag, ), - ...rooms.map((contact) => _ContactTile( + ...rooms.map((contact) => ContactTile( contact: contact, currentPosition: _currentPosition, calculateDistance: _calculateDistanceInMeters, @@ -213,1506 +206,3 @@ class _SectionHeader extends StatelessWidget { ); } } - -class _ContactTile extends StatelessWidget { - final Contact contact; - final Position? currentPosition; - final double Function(double, double, double, double)? calculateDistance; - final String Function(double)? formatDistance; - - const _ContactTile({ - required this.contact, - this.currentPosition, - this.calculateDistance, - this.formatDistance, - }); - - @override - Widget build(BuildContext context) { - final hasTelemetry = contact.telemetry != null && contact.telemetry!.isRecent; - final battery = contact.displayBattery; - final location = contact.displayLocation; - - // Calculate distance if both positions are available - String? distanceText; - if (location != null && currentPosition != null && calculateDistance != null && formatDistance != null) { - final distanceMeters = calculateDistance!( - currentPosition!.latitude, - currentPosition!.longitude, - location.latitude, - location.longitude, - ); - distanceText = formatDistance!(distanceMeters); - } - - // Get room login state if this is a room - final connectionProvider = context.watch(); - final roomLoginState = contact.type == ContactType.room - ? connectionProvider.getRoomLoginState(contact.publicKeyPrefix) - : null; - - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - leading: Stack( - children: [ - CircleAvatar( - backgroundColor: _getTypeColor(contact.type, context), - child: contact.roleEmoji != null - ? Text( - contact.roleEmoji!, - style: const TextStyle(fontSize: 24), - ) - : Icon( - _getTypeIcon(contact.type), - color: Colors.white, - ), - ), - // Room login status indicator badge - if (contact.type == ContactType.room && roomLoginState != null) - Positioned( - bottom: 0, - right: 0, - child: Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: _getRoomStatusColor(roomLoginState), - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - ), - child: Icon( - _getRoomStatusIcon(roomLoginState), - size: 12, - color: Colors.white, - ), - ), - ), - ], - ), - title: Row( - children: [ - Expanded( - child: Text( - contact.displayName, - style: const TextStyle(fontWeight: FontWeight.bold), - overflow: TextOverflow.ellipsis, - ), - ), - // Battery indicator on the right - if (battery != null) ...[ - Icon( - _getBatteryIcon(battery), - size: 16, - color: _getBatteryColor(battery), - ), - const SizedBox(width: 4), - Text( - '${battery.round()}%', - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: _getBatteryColor(battery), - fontWeight: FontWeight.w600, - ), - ), - ], - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - // Room login status badges - if (roomLoginState != null && roomLoginState.isLoggedIn) ...[ - Row( - children: [ - if (roomLoginState.isAdmin) - Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - decoration: BoxDecoration( - color: Colors.red.withOpacity(0.2), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.red, width: 1), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.admin_panel_settings, size: 10, color: Colors.red), - const SizedBox(width: 2), - Text( - 'Admin', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Colors.red, - fontWeight: FontWeight.bold, - fontSize: 10, - ), - ), - ], - ), - ), - if (roomLoginState.isAdmin) const SizedBox(width: 4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - decoration: BoxDecoration( - color: Colors.green.withOpacity(0.2), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.green, width: 1), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.check_circle, size: 10, color: Colors.green), - const SizedBox(width: 2), - Text( - 'Logged In', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Colors.green, - fontWeight: FontWeight.bold, - fontSize: 10, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 4), - ], - // Type label (only for rooms - hide for chat and repeater) - if (contact.type == ContactType.room) ...[ - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: _getTypeColor(contact.type, context).withOpacity(0.2), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - contact.type.displayName, - style: Theme.of(context).textTheme.labelSmall, - ), - ), - ], - ), - const SizedBox(height: 4), - ], - // Last seen + GPS info combined - Row( - children: [ - Icon( - Icons.access_time, - size: 12, - color: contact.isRecentlySeen ? Colors.green : Colors.grey, - ), - const SizedBox(width: 4), - Text( - contact.timeSinceLastSeen, - style: Theme.of(context).textTheme.labelSmall, - ), - if (location != null) ...[ - const SizedBox(width: 8), - const Text('•', style: TextStyle(color: Colors.grey)), - const SizedBox(width: 8), - if (hasTelemetry) - const Icon(Icons.sensors, size: 12, color: Colors.green) - else - const Icon(Icons.sensors_off, size: 12, color: Colors.grey), - const SizedBox(width: 4), - Expanded( - child: Text( - 'GPS: ${location.latitude.toStringAsFixed(4)}, ${location.longitude.toStringAsFixed(4)}', - style: Theme.of(context).textTheme.labelSmall, - overflow: TextOverflow.ellipsis, - ), - ), - ] else ...[ - const SizedBox(width: 8), - const Text('•', style: TextStyle(color: Colors.grey)), - const SizedBox(width: 8), - const Icon(Icons.sensors_off, size: 12, color: Colors.grey), - const SizedBox(width: 4), - Text( - 'No GPS data', - style: Theme.of(context).textTheme.labelSmall, - ), - ], - ], - ), - // Distance info (new row) - if (distanceText != null) ...[ - const SizedBox(height: 4), - Row( - children: [ - const Icon(Icons.straighten, size: 12, color: Colors.blue), - const SizedBox(width: 4), - Text( - 'Distance: $distanceText', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Colors.blue, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ], - ], - ), - trailing: null, - onTap: () => _showContactDetails(context, contact), - onLongPress: () { - final connectionProvider = context.read(); - connectionProvider.requestTelemetry(contact.publicKey, zeroHop: true); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Pinging ${contact.displayName} (direct connection)...'), - duration: const Duration(seconds: 2), - ), - ); - }, - ), - ); - } - - void _showDirectMessageDialog(BuildContext context, Contact contact) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => _DirectMessageSheet(contact: contact), - ); - } - - void _showRoomLoginDialog(BuildContext context, Contact contact) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => _RoomLoginSheet(contact: contact), - ); - } - - void _showContactDetails(BuildContext context, Contact contact) { - // Get room login state - final connectionProvider = context.read(); - final roomLoginState = contact.type == ContactType.room - ? connectionProvider.getRoomLoginState(contact.publicKeyPrefix) - : null; - - showModalBottomSheet( - context: context, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) => DraggableScrollableSheet( - initialChildSize: 0.6, - minChildSize: 0.4, - maxChildSize: 0.9, - expand: false, - builder: (context, scrollController) => Column( - children: [ - // Handle bar - Container( - margin: const EdgeInsets.only(top: 8, bottom: 16), - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(2), - ), - ), - // Header - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - CircleAvatar( - backgroundColor: _getTypeColor(contact.type, context), - child: contact.roleEmoji != null - ? Text( - contact.roleEmoji!, - style: const TextStyle(fontSize: 24), - ) - : Icon( - _getTypeIcon(contact.type), - color: Colors.white, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - contact.displayName, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - ), - const Divider(), - // Content - Expanded( - child: ListView( - controller: scrollController, - padding: const EdgeInsets.all(16), - children: [ - _DetailRow('Type', contact.type.displayName), - // Public Key with copy button - Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox( - width: 100, - child: Text( - 'Public Key:', - style: TextStyle(fontWeight: FontWeight.w500), - ), - ), - Expanded( - child: Text(contact.publicKeyShort), - ), - const SizedBox(width: 8), - InkWell( - onTap: () { - Clipboard.setData(ClipboardData(text: contact.publicKeyHex)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Public key copied to clipboard'), - duration: Duration(seconds: 2), - ), - ); - }, - borderRadius: BorderRadius.circular(4), - child: Padding( - padding: const EdgeInsets.all(4), - child: Icon( - Icons.copy, - size: 16, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ], - ), - ), - _DetailRow('Last Seen', contact.timeSinceLastSeen), - const SizedBox(height: 16), - // Room Login Status - if (roomLoginState != null) ...[ - const Text( - 'Room Status:', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - const SizedBox(height: 8), - _DetailRow( - 'Login Status', - roomLoginState.isLoggedIn ? 'Logged In' : 'Not Logged In', - ), - if (roomLoginState.isLoggedIn) ...[ - _DetailRow( - 'Admin Access', - roomLoginState.isAdmin ? 'Yes' : 'No', - ), - _DetailRow( - 'Permissions', - roomLoginState.permissions.toString(), - ), - if (roomLoginState.loginDurationFormatted != null) - _DetailRow( - 'Logged In', - roomLoginState.loginDurationFormatted!, - ), - ], - _DetailRow( - 'Password Saved', - roomLoginState.hasPassword ? 'Yes' : 'No', - ), - const SizedBox(height: 16), - ], - if (contact.displayLocation != null) ...[ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Location:', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - TextButton.icon( - onPressed: () { - // Navigate to map and close modal - final mapProvider = context.read(); - mapProvider.navigateToLocation( - location: LatLng( - contact.displayLocation!.latitude, - contact.displayLocation!.longitude, - ), - ); - Navigator.pop(context); - - // Switch to map tab (assuming it's index 2) - DefaultTabController.of(context).animateTo(2); - }, - icon: const Icon(Icons.map, size: 18), - label: const Text('View on Map'), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - ), - ), - ], - ), - const SizedBox(height: 8), - // Decimal Degrees (DD) - _DetailRowWithCopy( - context, - 'DD', - '${contact.displayLocation!.latitude.toStringAsFixed(6)}, ${contact.displayLocation!.longitude.toStringAsFixed(6)}', - ), - // Degrees Minutes Seconds (DMS) - _DetailRowWithCopy( - context, - 'DMS', - _convertToDMS(contact.displayLocation!.latitude, contact.displayLocation!.longitude), - ), - // Degrees Decimal Minutes (DDM) - _DetailRowWithCopy( - context, - 'DDM', - _convertToDDM(contact.displayLocation!.latitude, contact.displayLocation!.longitude), - ), - // MGRS (Military Grid Reference System) - _DetailRowWithCopy( - context, - 'MGRS', - _convertToMGRS(contact.displayLocation!.latitude, contact.displayLocation!.longitude), - ), - // Google Plus Code - _DetailRowWithCopy( - context, - 'Plus Code', - _convertToPlusCode(contact.displayLocation!.latitude, contact.displayLocation!.longitude), - ), - const SizedBox(height: 16), - ], - if (contact.telemetry != null) ...[ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Telemetry:', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - TextButton.icon( - onPressed: () { - final connectionProvider = context.read(); - connectionProvider.requestTelemetry(contact.publicKey, zeroHop: true); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Requesting telemetry from ${contact.displayName}...'), - duration: const Duration(seconds: 2), - ), - ); - }, - icon: const Icon(Icons.refresh, size: 18), - label: const Text('Refresh'), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - ), - ), - ], - ), - const SizedBox(height: 8), - if (contact.telemetry!.batteryMilliVolts != null) - _DetailRow( - 'Voltage', - '${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V' - '${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}', - ) - else if (contact.telemetry!.batteryPercentage != null) - _DetailRow('Battery', '${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%'), - if (contact.telemetry!.temperature != null) - _DetailRow('Temperature', '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'), - if (contact.telemetry!.humidity != null) - _DetailRow('Humidity', '${contact.telemetry!.humidity!.toStringAsFixed(1)}%'), - if (contact.telemetry!.pressure != null) - _DetailRow('Pressure', '${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa'), - if (contact.telemetry!.gpsLocation != null) - _DetailRow( - 'GPS (Telemetry)', - '${contact.telemetry!.gpsLocation!.latitude.toStringAsFixed(6)}, ${contact.telemetry!.gpsLocation!.longitude.toStringAsFixed(6)}', - ), - _DetailRow( - 'Updated', - '${_formatTimestamp(contact.telemetry!.timestamp)} (${_formatTimeAgo(contact.telemetry!.timestamp)})', - ), - ], - // Direct Message button for chat contacts - if (contact.type == ContactType.chat) ...[ - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () { - Navigator.pop(context); // Close details first - _showDirectMessageDialog(context, contact); - }, - icon: const Icon(Icons.message), - label: const Text('Send Direct Message'), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - backgroundColor: _getTypeColor(contact.type, context), - foregroundColor: Colors.white, - ), - ), - ), - ], - // Room Login button for room contacts (except Public Channel) - if (contact.type == ContactType.room && contact.advName != 'Public Channel') ...[ - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () { - Navigator.pop(context); // Close details first - _showRoomLoginDialog(context, contact); - }, - icon: const Icon(Icons.login), - label: Text(roomLoginState?.isLoggedIn == true ? 'Re-Login to Room' : 'Login to Room'), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - backgroundColor: _getTypeColor(contact.type, context), - foregroundColor: Colors.white, - ), - ), - ), - ], - ], - ), - ), - ], - ), - ), - ); - } - - Widget _DetailRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 100, - child: Text( - '$label:', - style: const TextStyle(fontWeight: FontWeight.w500), - ), - ), - Expanded( - child: Text(value), - ), - ], - ), - ); - } - - Widget _DetailRowWithCopy(BuildContext context, String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: 100, - child: Text( - '$label:', - style: const TextStyle(fontWeight: FontWeight.w500), - ), - ), - Expanded( - child: Text(value), - ), - const SizedBox(width: 8), - 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(4), - child: Padding( - padding: const EdgeInsets.all(4), - child: Icon( - Icons.copy, - size: 16, - 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 - // For now, return a placeholder that shows it's not fully implemented - 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; - } - - IconData _getTypeIcon(ContactType type) { - switch (type) { - case ContactType.chat: - return Icons.person; - case ContactType.repeater: - return Icons.router; - case ContactType.room: - return Icons.tag; - default: - return Icons.help; - } - } - - Color _getTypeColor(ContactType type, BuildContext context) { - switch (type) { - case ContactType.chat: - return Theme.of(context).colorScheme.primary; - case ContactType.repeater: - return Colors.green; - case ContactType.room: - return Colors.orange; - default: - return Colors.grey; - } - } - - IconData _getBatteryIcon(double percentage) { - if (percentage > 80) return Icons.battery_full; - if (percentage > 50) return Icons.battery_5_bar; - if (percentage > 20) return Icons.battery_3_bar; - return Icons.battery_1_bar; - } - - Color _getBatteryColor(double percentage) { - if (percentage > 50) return Colors.green; - if (percentage > 20) return Colors.orange; - return Colors.red; - } - - /// Get room login status color - Color _getRoomStatusColor(RoomLoginState state) { - if (!state.isLoggedIn) { - return Colors.grey; // Grey for not logged in - } - if (state.isAdmin) { - return Colors.red; // Red for admin - } - return Colors.green; // Green for logged in (non-admin) - } - - /// Get room login status icon - IconData _getRoomStatusIcon(RoomLoginState state) { - if (!state.isLoggedIn) { - return Icons.lock; // Lock for not logged in - } - if (state.isAdmin) { - return Icons.admin_panel_settings; // Admin icon for admin - } - return Icons.check; // Check for logged in (non-admin) - } - - String _formatTimestamp(DateTime timestamp) { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - final timestampDate = DateTime(timestamp.year, timestamp.month, timestamp.day); - - if (timestampDate == today) { - // Today - show time only - return '${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}:${timestamp.second.toString().padLeft(2, '0')}'; - } else { - // Another day - show date and time - return '${timestamp.year}-${timestamp.month.toString().padLeft(2, '0')}-${timestamp.day.toString().padLeft(2, '0')} ${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}'; - } - } - - String _formatTimeAgo(DateTime timestamp) { - final now = DateTime.now(); - final diff = now.difference(timestamp); - - if (diff.inSeconds < 60) { - return '${diff.inSeconds}s ago'; - } else if (diff.inMinutes < 60) { - return '${diff.inMinutes}m ago'; - } else if (diff.inHours < 24) { - return '${diff.inHours}h ago'; - } else if (diff.inDays == 1) { - return 'yesterday'; - } else { - return '${diff.inDays}d ago'; - } - } -} - -// Direct Message Sheet Widget -class _DirectMessageSheet extends StatefulWidget { - final Contact contact; - - const _DirectMessageSheet({required this.contact}); - - @override - State<_DirectMessageSheet> createState() => _DirectMessageSheetState(); -} - -class _DirectMessageSheetState extends State<_DirectMessageSheet> { - final TextEditingController _textController = TextEditingController(); - final FocusNode _focusNode = FocusNode(); - int _characterCount = 0; - static const int _maxCharacters = 160; - - @override - void initState() { - super.initState(); - _textController.addListener(_updateCharacterCount); - } - - @override - void dispose() { - _textController.dispose(); - _focusNode.dispose(); - super.dispose(); - } - - void _updateCharacterCount() { - setState(() { - _characterCount = _textController.text.length; - }); - } - - Future _sendDirectMessage() async { - final text = _textController.text.trim(); - if (text.isEmpty) return; - - final connectionProvider = context.read(); - - if (!connectionProvider.deviceInfo.isConnected) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Not connected to device'), - backgroundColor: Colors.red, - ), - ); - return; - } - - try { - // Send direct message to contact - await connectionProvider.sendTextMessage( - contactPublicKey: widget.contact.publicKey, - text: text, - ); - - _textController.clear(); - _focusNode.unfocus(); - - if (!mounted) return; - Navigator.pop(context); // Close the dialog - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Direct message sent to ${widget.contact.displayName}'), - backgroundColor: Colors.green, - duration: const Duration(seconds: 2), - ), - ); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to send: $e'), - backgroundColor: Colors.red, - ), - ); - } - } - - @override - Widget build(BuildContext context) { - return Container( - height: MediaQuery.of(context).size.height * 0.9, - decoration: const BoxDecoration( - color: Color(0xFF1E1E1E), - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Column( - children: [ - // Header - Container( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - Expanded( - child: Column( - children: [ - const Text( - 'Direct Message', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - Text( - widget.contact.displayName, - style: const TextStyle( - color: Colors.grey, - fontSize: 14, - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.more_vert, color: Colors.white), - onPressed: () {}, - ), - ], - ), - ), - - // Info banner - Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon(Icons.info_outline, color: Theme.of(context).colorScheme.onPrimaryContainer), - const SizedBox(width: 12), - Expanded( - child: Text( - 'This message will be sent directly to ${widget.contact.displayName}. It will also appear in the main messages feed.', - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryContainer, - fontSize: 13, - ), - ), - ), - ], - ), - ), - - const SizedBox(height: 16), - - const Spacer(), - - // Message input - Container( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: 16 + MediaQuery.of(context).viewInsets.bottom, - ), - decoration: const BoxDecoration( - color: Color(0xFF2D2D2D), - ), - child: Column( - children: [ - TextField( - controller: _textController, - focusNode: _focusNode, - maxLength: _maxCharacters, - maxLines: 3, - autofocus: true, - maxLengthEnforcement: MaxLengthEnforcement.enforced, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - hintText: 'Type your message...', - hintStyle: const TextStyle(color: Colors.grey), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Colors.grey), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Colors.grey), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Colors.white), - ), - contentPadding: const EdgeInsets.all(16), - counterText: _characterCount >= 150 - ? '$_characterCount/$_maxCharacters' - : '', - counterStyle: TextStyle( - fontSize: 11, - color: _characterCount > _maxCharacters * 0.9 - ? Colors.orange - : Colors.grey, - ), - ), - textInputAction: TextInputAction.send, - onSubmitted: (_) => _sendDirectMessage(), - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _textController.text.trim().isEmpty - ? null - : _sendDirectMessage, - icon: const Icon(Icons.send), - label: const Text('Send Direct Message'), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - ), - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -// Room Login Sheet Widget -class _RoomLoginSheet extends StatefulWidget { - final Contact contact; - - const _RoomLoginSheet({required this.contact}); - - @override - State<_RoomLoginSheet> createState() => _RoomLoginSheetState(); -} - -class _RoomLoginSheetState extends State<_RoomLoginSheet> { - final TextEditingController _passwordController = TextEditingController(); - final FocusNode _focusNode = FocusNode(); - bool _isLoggingIn = false; - bool _obscurePassword = true; - - @override - void initState() { - super.initState(); - _loadSavedPassword(); - } - - @override - void dispose() { - _passwordController.dispose(); - _focusNode.dispose(); - super.dispose(); - } - - /// Load saved password for this room, or use default "hello" - Future _loadSavedPassword() async { - final prefs = await SharedPreferences.getInstance(); - final roomKey = 'room_password_${widget.contact.publicKeyHex}'; - final savedPassword = prefs.getString(roomKey) ?? 'hello'; - _passwordController.text = savedPassword; - } - - /// Save password for this room - Future _savePassword(String password) async { - final prefs = await SharedPreferences.getInstance(); - final roomKey = 'room_password_${widget.contact.publicKeyHex}'; - await prefs.setString(roomKey, password); - } - - Future _loginToRoom() async { - final password = _passwordController.text.trim().isEmpty - ? 'hello' - : _passwordController.text.trim(); - - final connectionProvider = context.read(); - final contactsProvider = context.read(); - - if (!connectionProvider.deviceInfo.isConnected) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Not connected to device'), - backgroundColor: Colors.red, - ), - ); - return; - } - - setState(() { - _isLoggingIn = true; - }); - - // 🕐 CLOCK DRIFT CHECK: Get device time to detect synchronization issues - print('🕐 [RoomLogin] Checking for clock drift between app and radio...'); - try { - await connectionProvider.getDeviceTime(); - // Give time for response to be logged - await Future.delayed(const Duration(milliseconds: 300)); - } catch (e) { - print('⚠️ [RoomLogin] Failed to get device time: $e'); - // Don't fail login - this is just a diagnostic check - } - - // 🔍 PRE-LOGIN CHECK: Ensure room contact exists in device - print('🔍 [RoomLogin] Checking if room "${widget.contact.advName}" exists in contacts...'); - print(' Target public key prefix: ${widget.contact.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); - - // Check if the room exists in our local contacts - bool roomExists = contactsProvider.rooms.any( - (room) => room.publicKeyHex == widget.contact.publicKeyHex, - ); - - print(' Local contact list: ${roomExists ? "✅ Found" : "❌ Not found"}'); - - if (!roomExists) { - print('⚠️ [RoomLogin] Room not in local contacts - syncing with device...'); - - try { - // Sync contacts from device - await connectionProvider.getContacts(); - - // Give time for contacts to be processed - await Future.delayed(const Duration(milliseconds: 800)); - - // Check again after sync - roomExists = contactsProvider.rooms.any( - (room) => room.publicKeyHex == widget.contact.publicKeyHex, - ); - - print(' After sync: ${roomExists ? "✅ Found" : "❌ Still not found"}'); - - if (!roomExists) { - // Room still doesn't exist on the device - try to add it manually - print('❌ [RoomLogin] Room still not found after sync'); - print('🔧 [RoomLogin] Attempting to add room contact to companion radio...'); - - try { - // Manually add the room contact to the radio's flash storage - await connectionProvider.addOrUpdateContact(widget.contact); - - print('✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT'); - print(' Waiting 500ms for radio to save to flash...'); - - // Give the radio time to save the contact to flash - await Future.delayed(const Duration(milliseconds: 500)); - - print('✅ [RoomLogin] Room contact should now be available - proceeding with login'); - } catch (e) { - print('❌ [RoomLogin] Failed to add room contact: $e'); - - if (!mounted) return; - - setState(() { - _isLoggingIn = false; - }); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Failed to add room to device: $e\n\n' - 'The room may not have advertised yet.\n' - 'Try waiting for the room to broadcast.', - ), - backgroundColor: Colors.red, - duration: const Duration(seconds: 7), - ), - ); - - // Log available rooms for debugging - final availableRooms = contactsProvider.rooms; - print('📋 [RoomLogin] Available rooms on device (${availableRooms.length}):'); - for (final room in availableRooms) { - print(' - ${room.advName} (${room.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')})'); - } - - return; - } - } - - print('✅ [RoomLogin] Room contact found after sync - proceeding with login'); - } catch (e) { - print('❌ [RoomLogin] Contact sync failed: $e'); - - if (!mounted) return; - - setState(() { - _isLoggingIn = false; - }); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to sync contacts: $e'), - backgroundColor: Colors.red, - ), - ); - return; - } - } else { - print('✅ [RoomLogin] Room contact found in local contacts - proceeding with login'); - } - - // Save password before sending - await _savePassword(password); - - // Set up login callbacks - Function(Uint8List, int, bool, int)? originalOnSuccess; - Function(Uint8List)? originalOnFail; - - originalOnSuccess = connectionProvider.onLoginSuccess; - originalOnFail = connectionProvider.onLoginFail; - - connectionProvider.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async { - // Restore original callback - connectionProvider.onLoginSuccess = originalOnSuccess; - connectionProvider.onLoginFail = originalOnFail; - - print('✅ [RoomLogin] Login successful! Tag: $tag, Permissions: $permissions, Admin: $isAdmin'); - print('📡 [RoomLogin] Room server will now push messages automatically via PUSH_CODE_MSG_WAITING'); - print(' Messages will be fetched when onMessageWaiting callback is triggered'); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Logged in successfully! Waiting for room messages...'), - backgroundColor: Colors.green, - duration: Duration(seconds: 3), - ), - ); - } - }; - - connectionProvider.onLoginFail = (publicKeyPrefix) { - // Restore original callback - connectionProvider.onLoginSuccess = originalOnSuccess; - connectionProvider.onLoginFail = originalOnFail; - - print('❌ [RoomLogin] Login failed - incorrect password'); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Login failed - incorrect password'), - backgroundColor: Colors.red, - duration: Duration(seconds: 3), - ), - ); - } - }; - - try { - // Send login request to room - await connectionProvider.loginToRoom( - roomPublicKey: widget.contact.publicKey, - password: password, - ); - - _focusNode.unfocus(); - - if (!mounted) return; - Navigator.pop(context); // Close the dialog - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Logging in to ${widget.contact.displayName}...'), - backgroundColor: Colors.blue, - duration: const Duration(seconds: 2), - ), - ); - } catch (e) { - // Restore original callbacks on error - connectionProvider.onLoginSuccess = originalOnSuccess; - connectionProvider.onLoginFail = originalOnFail; - - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to send login: $e'), - backgroundColor: Colors.red, - ), - ); - } finally { - if (mounted) { - setState(() { - _isLoggingIn = false; - }); - } - } - } - - @override - Widget build(BuildContext context) { - return Container( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.75, - ), - decoration: const BoxDecoration( - color: Color(0xFF1E1E1E), - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Header - Container( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'Login to Room', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - Text( - widget.contact.displayName, - style: const TextStyle( - color: Colors.grey, - fontSize: 14, - ), - ), - ], - ), - ), - const SizedBox(width: 48), // Balance the back button - ], - ), - ), - - // Scrollable content area - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - children: [ - // Info banner - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.info_outline, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 20), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Enter the password to access this room. Password defaults to "hello" and will be saved for future use.', - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryContainer, - fontSize: 12, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 16), - ], - ), - ), - ), - - // Password input (fixed at bottom) - Container( - padding: const EdgeInsets.all(16), - decoration: const BoxDecoration( - color: Color(0xFF2D2D2D), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: _passwordController, - focusNode: _focusNode, - maxLength: 15, // Max password length from protocol - obscureText: _obscurePassword, - autofocus: true, - maxLengthEnforcement: MaxLengthEnforcement.enforced, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - labelText: 'Password', - labelStyle: const TextStyle(color: Colors.grey), - hintText: 'Enter room password (default: hello)', - hintStyle: const TextStyle(color: Colors.grey), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Colors.grey), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Colors.grey), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Colors.white), - ), - contentPadding: const EdgeInsets.all(16), - suffixIcon: IconButton( - icon: Icon( - _obscurePassword ? Icons.visibility : Icons.visibility_off, - color: Colors.grey, - ), - onPressed: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - }, - ), - ), - textInputAction: TextInputAction.done, - onSubmitted: (_) => _loginToRoom(), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _isLoggingIn ? null : _loginToRoom, - icon: _isLoggingIn - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.login), - label: Text(_isLoggingIn ? 'Logging in...' : 'Login'), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - ), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index ed38e8b..413a4b4 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:math'; import 'dart:typed_data'; -import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; @@ -22,8 +21,11 @@ import '../services/tile_cache_service.dart'; import '../services/background_location_service.dart'; import '../widgets/map_markers.dart'; import '../widgets/map_debug_info.dart'; +import '../widgets/map/map_legend.dart'; +import '../widgets/map/compass_widget.dart'; +import '../widgets/map/detailed_compass_dialog.dart'; +import '../widgets/messages/sar_update_sheet.dart'; import 'map_management_screen.dart'; -import 'messages_tab.dart'; class MapTab extends StatefulWidget { const MapTab({super.key}); @@ -553,7 +555,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { color: Theme.of(context).colorScheme.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), - child: _DetailedCompassDialog( + child: DetailedCompassDialog( initialPosition: _currentPosition, initialHeading: _currentHeading, contacts: contacts, @@ -579,7 +581,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { color: Theme.of(context).colorScheme.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), - child: _DetailedCompassDialog( + child: DetailedCompassDialog( initialPosition: _currentPosition, initialHeading: _currentHeading, contacts: contacts, @@ -606,7 +608,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { color: Theme.of(context).colorScheme.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), - child: _DetailedCompassDialog( + child: DetailedCompassDialog( initialPosition: _currentPosition, initialHeading: _currentHeading, contacts: contacts, @@ -1121,7 +1123,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { contactsProvider.contactsWithLocation, messagesProvider.sarMarkers, ), - child: _CompassWidget( + child: CompassWidget( heading: _currentHeading ?? 0, hasHeading: _currentHeading != null, ), @@ -1132,7 +1134,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { Positioned( top: 80, // Position below compass (which is always visible now) left: 16, - child: _MapLegend( + child: MapLegend( teamMemberCount: contactsWithLocation.length, foundPersonCount: messagesProvider.foundPersonMarkers.length, fireCount: messagesProvider.fireMarkers.length, @@ -1213,1659 +1215,3 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } } -class _MapLegend extends StatelessWidget { - final int teamMemberCount; - final int foundPersonCount; - final int fireCount; - final int stagingAreaCount; - final int objectCount; - - const _MapLegend({ - required this.teamMemberCount, - required this.foundPersonCount, - required this.fireCount, - required this.stagingAreaCount, - required this.objectCount, - }); - - @override - Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Legend', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - _LegendItem( - icon: Icons.person, - color: Theme.of(context).colorScheme.primary, - label: 'Team', - count: teamMemberCount, - ), - _LegendItem( - icon: Icons.person_pin, - color: Colors.green, - label: 'Found', - count: foundPersonCount, - ), - _LegendItem( - icon: Icons.local_fire_department, - color: Colors.red, - label: 'Fire', - count: fireCount, - ), - _LegendItem( - icon: Icons.home_work, - color: Colors.orange, - label: 'Staging', - count: stagingAreaCount, - ), - _LegendItem( - icon: Icons.inventory_2, - color: Colors.purple, - label: 'Object', - count: objectCount, - ), - ], - ), - ), - ); - } -} - -class _LegendItem extends StatelessWidget { - final IconData icon; - final Color color; - final String label; - final int count; - - const _LegendItem({ - required this.icon, - required this.color, - required this.label, - required this.count, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: color), - const SizedBox(width: 8), - Text( - label, - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(width: 4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - count.toString(), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ); - } -} - -class _CompassWidget extends StatelessWidget { - final double heading; - final bool hasHeading; - - const _CompassWidget({ - required this.heading, - required this.hasHeading, - }); - - @override - Widget build(BuildContext context) { - return Card( - child: Container( - width: 56, - height: 56, - padding: const EdgeInsets.all(8), - child: Stack( - alignment: Alignment.center, - children: [ - // Compass rose background - rotates to show true north at top - Transform.rotate( - angle: heading * pi / 180, - child: CustomPaint( - size: const Size(40, 40), - painter: _CompassRosePainter(), - ), - ), - // Fixed needle pointing up (since map rotates) - Icon( - Icons.navigation, - color: hasHeading ? Colors.red : Colors.grey, - size: 28, - ), - // Heading text - Positioned( - bottom: 0, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - hasHeading ? '${heading.round()}°' : '--', - style: const TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], - ), - ), - ); - } -} - -class _CompassRosePainter extends CustomPainter { - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = Colors.grey.withValues(alpha: 0.3) - ..style = PaintingStyle.stroke - ..strokeWidth = 1; - - final center = Offset(size.width / 2, size.height / 2); - final radius = size.width / 2; - - // Draw circle - canvas.drawCircle(center, radius, paint); - - // Draw cardinal direction markers - final textPainter = TextPainter( - textDirection: TextDirection.ltr, - ); - - final directions = ['N', 'E', 'S', 'W']; - for (int i = 0; i < 4; i++) { - final angle = i * pi / 2 - pi / 2; // Start from North (top) - final x = center.dx + radius * 0.7 * cos(angle); - final y = center.dy + radius * 0.7 * sin(angle); - - textPainter.text = TextSpan( - text: directions[i], - style: TextStyle( - color: Colors.grey.shade700, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ); - textPainter.layout(); - textPainter.paint( - canvas, - Offset(x - textPainter.width / 2, y - textPainter.height / 2), - ); - } - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} - -// Detailed Compass Dialog -class _DetailedCompassDialog extends StatefulWidget { - final Position? initialPosition; - final double? initialHeading; - final List contacts; - final List sarMarkers; - final Contact? preSelectedContact; - final SarMarker? preSelectedSarMarker; - - const _DetailedCompassDialog({ - required this.initialPosition, - required this.initialHeading, - required this.contacts, - required this.sarMarkers, - this.preSelectedContact, - this.preSelectedSarMarker, - }); - - @override - State<_DetailedCompassDialog> createState() => _DetailedCompassDialogState(); -} - -class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { - double? _currentHeading; - Position? _currentPosition; - 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; - - // Selected item for isolation - Contact? _selectedContact; - SarMarker? _selectedSarMarker; - - @override - void initState() { - super.initState(); - _currentHeading = widget.initialHeading; - _currentPosition = widget.initialPosition; - _selectedContact = widget.preSelectedContact; - _selectedSarMarker = widget.preSelectedSarMarker; - - // Subscribe to compass updates - final compassStream = FlutterCompass.events; - if (compassStream != null) { - _compassSubscription = compassStream.listen((event) { - if (mounted && event.heading != null) { - setState(() { - _currentHeading = event.heading; - }); - } - }); - } - - // Subscribe to position updates - _positionSubscription = Geolocator.getPositionStream( - locationSettings: const LocationSettings( - accuracy: LocationAccuracy.best, - distanceFilter: 1, - ), - ).listen((position) { - if (mounted) { - setState(() { - _currentPosition = position; - }); - } - }); - } - - @override - void dispose() { - _compassSubscription?.cancel(); - _positionSubscription?.cancel(); - super.dispose(); - } - - // Get current heading (prefer compass over GPS) - double? get currentHeading { - if (_currentHeading != null) return _currentHeading; - if (_currentPosition?.heading != null && _currentPosition!.heading >= 0) { - return _currentPosition!.heading; - } - 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.object: - return true; // Always show object markers (add filter if needed) - 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: Theme.of(context).colorScheme.primary, - 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; - final position = _currentPosition; - return Column( - children: [ - // Header with back button - Container( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), - ), - const Expanded( - child: Column( - children: [ - Text( - 'Compass', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - Text( - 'Navigation & Contacts', - style: TextStyle( - color: Colors.grey, - fontSize: 14, - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.filter_list), - tooltip: 'Filter markers', - onPressed: () => _showFilterDialog(), - ), - ], - ), - ), - Expanded( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Heading and Elevation info - _buildInfoRow(context, heading, position), - const SizedBox(height: 12), - // Current location in multiple formats - if (position != null) _buildLocationFormats(context, position), - const SizedBox(height: 12), - // Large compass with zoom controls - GestureDetector( - onScaleStart: (details) { - _previousScale = 1.0; - }, - onScaleUpdate: (details) { - setState(() { - // 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, - child: _DetailedCompassPainter( - heading: heading ?? 0, - hasHeading: heading != null, - currentPosition: position, - contacts: _selectedContact != null - ? [_selectedContact!] - : (_selectedSarMarker != null - ? [] - : (_showContacts ? widget.contacts : [])), - sarMarkers: _selectedSarMarker != null - ? [_selectedSarMarker!] - : (_selectedContact != null - ? [] - : _getFilteredSarMarkers()), - zoomLevel: _zoomLevel, - ), - ), - ), - const SizedBox(height: 12), - // Selected item detail view - if (_selectedContact != null || _selectedSarMarker != null) - _buildSelectedItemDetail(context, heading, position), - const SizedBox(height: 12), - // Contacts list - if (_showContacts && widget.contacts.isNotEmpty) _buildContactsList(context, heading, position), - // SAR Markers list - if (_getFilteredSarMarkers().isNotEmpty) _buildSarMarkersList(context, heading, position), - ], - ), - ), - ), - ), - ], - ); - } - - Widget _buildInfoRow(BuildContext context, double? heading, Position? position) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _buildInfoCard( - context, - 'Heading', - heading != null ? '${heading.round()}°' : '--', - Icons.explore, - ), - _buildInfoCard( - context, - 'Elevation', - position?.altitude != null - ? '${position!.altitude.round()}m' - : '--', - Icons.terrain, - ), - _buildInfoCard( - context, - 'Accuracy', - position?.accuracy != null - ? '±${position!.accuracy.round()}m' - : '--', - Icons.gps_fixed, - ), - ], - ); - } - - Widget _buildInfoCard( - BuildContext context, String label, String value, IconData icon) { - return Column( - children: [ - Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), - const SizedBox(height: 4), - Text( - value, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Text( - label, - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ); - } - - Widget _buildLocationFormats(BuildContext context, Position position) { - return _LocationFormatToggle(position: position); - } - - Widget _buildSelectedItemDetail(BuildContext context, double? heading, Position? position) { - if (position == null) { - return const SizedBox.shrink(); - } - - String title; - IconData icon; - Color color; - double? bearing; - double? distance; - LatLng? targetLocation; - String? additionalInfo; - - if (_selectedContact != null) { - title = _selectedContact!.displayName; - icon = Icons.person; - color = Theme.of(context).colorScheme.primary; - targetLocation = _selectedContact!.displayLocation; - - if (targetLocation != null) { - bearing = _calculateBearing( - position.latitude, - position.longitude, - targetLocation.latitude, - targetLocation.longitude, - ); - distance = _calculateDistance( - position.latitude, - position.longitude, - targetLocation.latitude, - targetLocation.longitude, - ); - } - - // Show voltage/battery if available - if (_selectedContact!.telemetry?.batteryMilliVolts != null) { - final volts = (_selectedContact!.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3); - final percent = _selectedContact!.telemetry!.batteryPercentage != null - ? ' (${_selectedContact!.telemetry!.batteryPercentage!.round()}%)' - : ''; - additionalInfo = 'Voltage: ${volts}V$percent'; - } else if (_selectedContact!.telemetry?.batteryPercentage != null) { - additionalInfo = 'Battery: ${_selectedContact!.telemetry!.batteryPercentage!.round()}%'; - } - } else if (_selectedSarMarker != null) { - title = _selectedSarMarker!.type.displayName; - targetLocation = _selectedSarMarker!.location; - additionalInfo = _selectedSarMarker!.timeAgo; - - switch (_selectedSarMarker!.type) { - case SarMarkerType.foundPerson: - icon = Icons.person_pin; - color = Colors.green; - break; - case SarMarkerType.fire: - icon = Icons.local_fire_department; - color = Colors.red; - break; - case SarMarkerType.stagingArea: - icon = Icons.home_work; - color = Colors.orange; - break; - case SarMarkerType.object: - icon = Icons.inventory_2; - color = Colors.purple; - break; - case SarMarkerType.unknown: - icon = Icons.help_outline; - color = Colors.grey; - break; - } - - bearing = _calculateBearing( - position.latitude, - position.longitude, - targetLocation.latitude, - targetLocation.longitude, - ); - distance = _calculateDistance( - position.latitude, - position.longitude, - targetLocation.latitude, - targetLocation.longitude, - ); - } else { - return const SizedBox.shrink(); - } - - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16), - elevation: 4, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - // Header with icon and title - Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.2), - shape: BoxShape.circle, - ), - child: _selectedContact != null && _selectedContact!.roleEmoji != null - ? Text( - _selectedContact!.roleEmoji!, - style: const TextStyle(fontSize: 32), - ) - : Icon(icon, size: 32, color: color), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - if (additionalInfo != null) - Text( - additionalInfo, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.grey, - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () { - setState(() { - _selectedContact = null; - _selectedSarMarker = null; - }); - }, - ), - ], - ), - if (bearing != null && distance != null) ...[ - const SizedBox(height: 16), - const Divider(), - const SizedBox(height: 16), - // Distance and bearing info - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _buildLargeInfoCard( - context, - 'Distance', - _formatDistance(distance), - Icons.straighten, - color, - ), - _buildLargeInfoCard( - context, - 'Bearing', - '${bearing.round()}°', - Icons.navigation, - color, - ), - _buildLargeInfoCard( - context, - 'Direction', - _bearingToCardinal(bearing), - Icons.explore, - color, - ), - ], - ), - const SizedBox(height: 12), - // Coordinates - 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', - ), - ), - ], - ), - ), - ], - ], - ), - ), - ); - } - - Widget _buildLargeInfoCard( - BuildContext context, - String label, - String value, - IconData icon, - Color color, - ) { - return Column( - children: [ - Icon(icon, size: 28, color: color), - const SizedBox(height: 8), - Text( - value, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: color, - ), - ), - Text( - label, - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ); - } - - // Convert decimal degrees to DMS (Degrees, Minutes, Seconds) - String _formatDMS(double degrees, bool isLatitude) { - final direction = isLatitude - ? (degrees >= 0 ? 'N' : 'S') - : (degrees >= 0 ? 'E' : 'W'); - - final absolute = degrees.abs(); - final deg = absolute.floor(); - final minDecimal = (absolute - deg) * 60; - final min = minDecimal.floor(); - final sec = (minDecimal - min) * 60; - - return '$deg°${min.toString().padLeft(2, '0')}\'${sec.toStringAsFixed(2).padLeft(5, '0')}"$direction'; - } - - Widget _buildContactsList(BuildContext context, double? heading, Position? position) { - if (position == null) { - return const Text('Location unavailable'); - } - - // Calculate bearings and distances - final contactsWithBearing = widget.contacts.map((contact) { - if (contact.displayLocation == null) return null; - - final bearing = _calculateBearing( - position.latitude, - position.longitude, - contact.displayLocation!.latitude, - contact.displayLocation!.longitude, - ); - - final distance = _calculateDistance( - position.latitude, - position.longitude, - contact.displayLocation!.latitude, - contact.displayLocation!.longitude, - ); - - return { - 'contact': contact, - 'bearing': bearing, - 'distance': distance, - }; - }).whereType>().toList(); - - // Sort by distance - contactsWithBearing.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, bottom: 8), - child: Text( - 'Nearby Contacts', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - ...contactsWithBearing.map((item) { - final contact = item['contact'] as Contact; - final bearing = item['bearing'] as double; - final distance = item['distance'] as double; - - return Container( - margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: _selectedContact == contact - ? Theme.of(context).colorScheme.primaryContainer - : Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: _selectedContact == contact - ? Border.all( - color: Theme.of(context).colorScheme.primary, - width: 2, - ) - : null, - ), - child: ListTile( - dense: true, - leading: contact.roleEmoji != null - ? Text( - contact.roleEmoji!, - style: const TextStyle(fontSize: 24), - ) - : Icon( - Icons.person, - color: Theme.of(context).colorScheme.primary, - size: 24, - ), - title: Text(contact.displayName), - subtitle: Text( - '${_bearingToCardinal(bearing)} • ${_formatDistance(distance)}', - style: Theme.of(context).textTheme.bodySmall, - ), - trailing: Text( - '${bearing.round()}°', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - onTap: () { - setState(() { - if (_selectedContact == contact) { - // Deselect if already selected - _selectedContact = null; - } else { - // Select this contact and deselect SAR marker - _selectedContact = contact; - _selectedSarMarker = null; - } - }); - }, - ), - ); - }), - ], - ); - } - - 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.object: - markerColor = Colors.purple; - markerIcon = Icons.inventory_2; - 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: _selectedSarMarker == marker - ? Theme.of(context).colorScheme.primaryContainer - : Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: _selectedSarMarker == marker - ? Border.all( - color: Theme.of(context).colorScheme.primary, - width: 2, - ) - : null, - ), - 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, - ), - ), - onTap: () { - setState(() { - if (_selectedSarMarker == marker) { - // Deselect if already selected - _selectedSarMarker = null; - } else { - // Select this marker and deselect contact - _selectedSarMarker = marker; - _selectedContact = null; - } - }); - }, - ), - ); - }), - ], - ); - } - - // Calculate bearing between two points (in degrees) - double _calculateBearing( - double lat1, double lon1, double lat2, double lon2) { - final dLon = (lon2 - lon1) * pi / 180; - final lat1Rad = lat1 * pi / 180; - final lat2Rad = lat2 * pi / 180; - - final y = sin(dLon) * cos(lat2Rad); - final x = cos(lat1Rad) * sin(lat2Rad) - - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); - - final bearing = atan2(y, x) * 180 / pi; - return (bearing + 360) % 360; - } - - // Calculate distance between two points (in meters) - double _calculateDistance( - double lat1, double lon1, double lat2, double lon2) { - const R = 6371000; // Earth's radius in meters - final dLat = (lat2 - lat1) * pi / 180; - final dLon = (lon2 - lon1) * pi / 180; - - final a = sin(dLat / 2) * sin(dLat / 2) + - cos(lat1 * pi / 180) * - cos(lat2 * pi / 180) * - sin(dLon / 2) * - sin(dLon / 2); - - final c = 2 * atan2(sqrt(a), sqrt(1 - a)); - return R * c; - } - - String _bearingToCardinal(double bearing) { - const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; - final index = ((bearing + 22.5) / 45).floor() % 8; - return directions[index]; - } - - String _formatDistance(double meters) { - if (meters < 1000) { - return '${meters.round()}m'; - } else { - return '${(meters / 1000).toStringAsFixed(1)}km'; - } - } -} - -// Detailed Compass Painter with contacts -class _DetailedCompassPainter extends StatelessWidget { - final double heading; - final bool hasHeading; - final Position? currentPosition; - final List contacts; - final List sarMarkers; - final double zoomLevel; - - const _DetailedCompassPainter({ - required this.heading, - required this.hasHeading, - required this.currentPosition, - required this.contacts, - required this.sarMarkers, - this.zoomLevel = 1.0, - }); - - @override - Widget build(BuildContext context) { - return CustomPaint( - painter: _LargeCompassPainter( - heading: heading, - hasHeading: hasHeading, - currentPosition: currentPosition, - contacts: contacts, - sarMarkers: sarMarkers, - zoomLevel: zoomLevel, - ), - child: Container(), - ); - } -} - -class _LargeCompassPainter extends CustomPainter { - final double heading; - final bool hasHeading; - final Position? currentPosition; - final List contacts; - final List sarMarkers; - final double zoomLevel; - - _LargeCompassPainter({ - required this.heading, - required this.hasHeading, - required this.currentPosition, - required this.contacts, - required this.sarMarkers, - this.zoomLevel = 1.0, - }); - - @override - void paint(Canvas canvas, Size size) { - final center = Offset(size.width / 2, size.height / 2); - final radius = size.width / 2; - - // Draw outer circle - final circlePaint = Paint() - ..color = Colors.grey.withValues(alpha: 0.2) - ..style = PaintingStyle.stroke - ..strokeWidth = 2; - canvas.drawCircle(center, radius, circlePaint); - - // Draw degree markers - for (int i = 0; i < 360; i += 10) { - final angle = i * pi / 180 - pi / 2 + heading * pi / 180; - final isCardinal = i % 90 == 0; - final isMajor = i % 30 == 0; - - final startRadius = isCardinal ? radius - 25 : (isMajor ? radius - 15 : radius - 10); - final start = Offset( - center.dx + startRadius * cos(angle), - center.dy + startRadius * sin(angle), - ); - final end = Offset( - center.dx + radius * cos(angle), - center.dy + radius * sin(angle), - ); - - final markerPaint = Paint() - ..color = isCardinal ? Colors.red : Colors.grey - ..strokeWidth = isCardinal ? 3 : (isMajor ? 2 : 1); - - canvas.drawLine(start, end, markerPaint); - } - - // Draw cardinal directions - final textPainter = TextPainter(textDirection: TextDirection.ltr); - final directions = ['N', 'E', 'S', 'W']; - for (int i = 0; i < 4; i++) { - final angle = i * pi / 2 - pi / 2 + heading * pi / 180; - final x = center.dx + (radius - 35) * cos(angle); - final y = center.dy + (radius - 35) * sin(angle); - - textPainter.text = TextSpan( - text: directions[i], - style: TextStyle( - color: i == 0 ? Colors.red : Colors.grey.shade700, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ); - textPainter.layout(); - textPainter.paint( - canvas, - Offset(x - textPainter.width / 2, y - textPainter.height / 2), - ); - } - - // Draw contacts as dots relative to distance, scaled by zoom level - if (currentPosition != null && contacts.isNotEmpty) { - // Calculate distances for all contacts - final contactsWithDistance = contacts - .where((c) => c.displayLocation != null) - .map((contact) { - final bearing = _calculateBearing( - currentPosition!.latitude, - currentPosition!.longitude, - contact.displayLocation!.latitude, - contact.displayLocation!.longitude, - ); - final distance = _calculateDistance( - currentPosition!.latitude, - currentPosition!.longitude, - contact.displayLocation!.latitude, - contact.displayLocation!.longitude, - ); - return {'contact': contact, 'bearing': bearing, 'distance': distance}; - }).toList(); - - if (contactsWithDistance.isEmpty) return; - - // Find max distance for normalization - final maxDistance = contactsWithDistance - .map((c) => c['distance'] as double) - .reduce((a, b) => a > b ? a : b); - - // Base distance for zoom level 1.0 (in meters) - // At 1x zoom, contacts within 1km appear inside the compass - final baseDistance = 1000.0 / zoomLevel; - - for (final item in contactsWithDistance) { - final contact = item['contact'] as Contact; - 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) - // Apply zoom level: higher zoom = contacts appear closer - double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0); - - // Calculate contact position radius (from center to rim based on distance) - final contactRadius = radius * normalizedDistance * 0.85; // 0.85 to keep inside rim - - // Position of contact dot - final dotX = center.dx + contactRadius * cos(angle); - final dotY = center.dy + contactRadius * sin(angle); - - // Draw line from center to contact - final linePaint = Paint() - ..color = Colors.lightBlue.withValues(alpha: 0.3) - ..style = PaintingStyle.stroke - ..strokeWidth = 1.5; - canvas.drawLine( - center, - Offset(dotX, dotY), - linePaint, - ); - - // Draw contact dot (size varies with zoom) - final dotSize = (6.0 * (1.0 + zoomLevel * 0.3)).clamp(4.0, 12.0); - final dotPaint = Paint() - ..color = Colors.lightBlue - ..style = PaintingStyle.fill; - canvas.drawCircle(Offset(dotX, dotY), dotSize, dotPaint); - - // Draw white border - final borderPaint = Paint() - ..color = Colors.white - ..style = PaintingStyle.stroke - ..strokeWidth = 2; - canvas.drawCircle(Offset(dotX, dotY), dotSize, borderPaint); - - // Draw distance label near the contact (only if not too crowded) - if (zoomLevel >= 0.75) { - final distanceText = _formatDistance(distance); - final labelOffset = dotSize + 12; - final labelX = center.dx + (contactRadius + labelOffset) * cos(angle); - final labelY = center.dy + (contactRadius + labelOffset) * sin(angle); - - textPainter.text = TextSpan( - text: distanceText, - style: const TextStyle( - color: Colors.lightBlue, - fontSize: 9, - 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 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.object: - markerColor = Colors.purple; - 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 - ..style = PaintingStyle.fill; - - final path = ui.Path() - ..moveTo(center.dx, center.dy - 40) - ..lineTo(center.dx - 10, center.dy + 10) - ..lineTo(center.dx + 10, center.dy + 10) - ..close(); - - canvas.drawPath(path, indicatorPaint); - } - - double _calculateBearing( - double lat1, double lon1, double lat2, double lon2) { - final dLon = (lon2 - lon1) * pi / 180; - final lat1Rad = lat1 * pi / 180; - final lat2Rad = lat2 * pi / 180; - - final y = sin(dLon) * cos(lat2Rad); - final x = cos(lat1Rad) * sin(lat2Rad) - - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); - - final bearing = atan2(y, x) * 180 / pi; - return (bearing + 360) % 360; - } - - double _calculateDistance( - double lat1, double lon1, double lat2, double lon2) { - const R = 6371000; // Earth's radius in meters - final dLat = (lat2 - lat1) * pi / 180; - final dLon = (lon2 - lon1) * pi / 180; - - final a = sin(dLat / 2) * sin(dLat / 2) + - cos(lat1 * pi / 180) * - cos(lat2 * pi / 180) * - sin(dLon / 2) * - sin(dLon / 2); - - final c = 2 * atan2(sqrt(a), sqrt(1 - a)); - return R * c; - } - - String _formatDistance(double meters) { - if (meters < 1000) { - return '${meters.round()}m'; - } else { - return '${(meters / 1000).toStringAsFixed(1)}km'; - } - } - - @override - 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; - - const _LocationFormatToggle({required this.position}); - - @override - State<_LocationFormatToggle> createState() => _LocationFormatToggleState(); -} - -class _LocationFormatToggleState extends State<_LocationFormatToggle> { - bool _showDMS = false; - - String _formatDMS(double degrees, bool isLatitude) { - final direction = isLatitude - ? (degrees >= 0 ? 'N' : 'S') - : (degrees >= 0 ? 'E' : 'W'); - - final absolute = degrees.abs(); - final deg = absolute.floor(); - final minDecimal = (absolute - deg) * 60; - final min = minDecimal.floor(); - final sec = (minDecimal - min) * 60; - - return '$deg°${min.toString().padLeft(2, '0')}\'${sec.toStringAsFixed(2).padLeft(5, '0')}"$direction'; - } - - @override - Widget build(BuildContext context) { - final position = widget.position; - - final String displayText; - - if (_showDMS) { - displayText = '${_formatDMS(position.latitude, true)} ${_formatDMS(position.longitude, false)}'; - } else { - displayText = 'Lat: ${position.latitude.toStringAsFixed(5)} Lon: ${position.longitude.toStringAsFixed(5)}'; - } - - return GestureDetector( - onTap: () { - setState(() { - _showDMS = !_showDMS; - }); - }, - behavior: HitTestBehavior.opaque, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: Text( - displayText, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w500, - fontFamily: 'monospace', - fontSize: 11, - ), - ), - ), - ), - ); - } -} - diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart new file mode 100644 index 0000000..363b0cc --- /dev/null +++ b/lib/widgets/contacts/contact_tile.dart @@ -0,0 +1,851 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; +import '../../models/contact.dart'; +import '../../models/room_login_state.dart'; +import '../../providers/connection_provider.dart'; +import '../../providers/map_provider.dart'; +import 'direct_message_sheet.dart'; +import 'room_login_sheet.dart'; + +class ContactTile extends StatelessWidget { + final Contact contact; + final Position? currentPosition; + final double Function(double, double, double, double)? calculateDistance; + final String Function(double)? formatDistance; + + const ContactTile({ + super.key, + required this.contact, + this.currentPosition, + this.calculateDistance, + this.formatDistance, + }); + + @override + Widget build(BuildContext context) { + final hasTelemetry = contact.telemetry != null && contact.telemetry!.isRecent; + final battery = contact.displayBattery; + final location = contact.displayLocation; + + // Calculate distance if both positions are available + String? distanceText; + if (location != null && currentPosition != null && calculateDistance != null && formatDistance != null) { + final distanceMeters = calculateDistance!( + currentPosition!.latitude, + currentPosition!.longitude, + location.latitude, + location.longitude, + ); + distanceText = formatDistance!(distanceMeters); + } + + // Get room login state if this is a room + final connectionProvider = context.watch(); + final roomLoginState = contact.type == ContactType.room + ? connectionProvider.getRoomLoginState(contact.publicKeyPrefix) + : null; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: Stack( + children: [ + CircleAvatar( + backgroundColor: _getTypeColor(contact.type, context), + child: contact.roleEmoji != null + ? Text( + contact.roleEmoji!, + style: const TextStyle(fontSize: 24), + ) + : Icon( + _getTypeIcon(contact.type), + color: Colors.white, + ), + ), + // Room login status indicator badge + if (contact.type == ContactType.room && roomLoginState != null) + Positioned( + bottom: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: _getRoomStatusColor(roomLoginState), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: Icon( + _getRoomStatusIcon(roomLoginState), + size: 12, + color: Colors.white, + ), + ), + ), + ], + ), + title: Row( + children: [ + Expanded( + child: Text( + contact.displayName, + style: const TextStyle(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + ), + // Battery indicator on the right + if (battery != null) ...[ + Icon( + _getBatteryIcon(battery), + size: 16, + color: _getBatteryColor(battery), + ), + const SizedBox(width: 4), + Text( + '${battery.round()}%', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: _getBatteryColor(battery), + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + // Room login status badges + if (roomLoginState != null && roomLoginState.isLoggedIn) ...[ + Row( + children: [ + if (roomLoginState.isAdmin) + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.red, width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.admin_panel_settings, size: 10, color: Colors.red), + const SizedBox(width: 2), + Text( + 'Admin', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.red, + fontWeight: FontWeight.bold, + fontSize: 10, + ), + ), + ], + ), + ), + if (roomLoginState.isAdmin) const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: Colors.green.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.green, width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.check_circle, size: 10, color: Colors.green), + const SizedBox(width: 2), + Text( + 'Logged In', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.green, + fontWeight: FontWeight.bold, + fontSize: 10, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 4), + ], + // Type label (only for rooms - hide for chat and repeater) + if (contact.type == ContactType.room) ...[ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: _getTypeColor(contact.type, context).withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + contact.type.displayName, + style: Theme.of(context).textTheme.labelSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + ], + // Last seen + GPS info combined + Row( + children: [ + Icon( + Icons.access_time, + size: 12, + color: contact.isRecentlySeen ? Colors.green : Colors.grey, + ), + const SizedBox(width: 4), + Text( + contact.timeSinceLastSeen, + style: Theme.of(context).textTheme.labelSmall, + ), + if (location != null) ...[ + const SizedBox(width: 8), + const Text('•', style: TextStyle(color: Colors.grey)), + const SizedBox(width: 8), + if (hasTelemetry) + const Icon(Icons.sensors, size: 12, color: Colors.green) + else + const Icon(Icons.sensors_off, size: 12, color: Colors.grey), + const SizedBox(width: 4), + Expanded( + child: Text( + 'GPS: ${location.latitude.toStringAsFixed(4)}, ${location.longitude.toStringAsFixed(4)}', + style: Theme.of(context).textTheme.labelSmall, + overflow: TextOverflow.ellipsis, + ), + ), + ] else ...[ + const SizedBox(width: 8), + const Text('•', style: TextStyle(color: Colors.grey)), + const SizedBox(width: 8), + const Icon(Icons.sensors_off, size: 12, color: Colors.grey), + const SizedBox(width: 4), + Text( + 'No GPS data', + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ], + ), + // Distance info (new row) + if (distanceText != null) ...[ + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.straighten, size: 12, color: Colors.blue), + const SizedBox(width: 4), + Text( + 'Distance: $distanceText', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.blue, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ], + ), + trailing: null, + onTap: () => _showContactDetails(context, contact), + onLongPress: () { + final connectionProvider = context.read(); + connectionProvider.requestTelemetry(contact.publicKey, zeroHop: true); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Pinging ${contact.displayName} (direct connection)...'), + duration: const Duration(seconds: 2), + ), + ); + }, + ), + ); + } + + void _showDirectMessageDialog(BuildContext context, Contact contact) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => DirectMessageSheet(contact: contact), + ); + } + + void _showRoomLoginDialog(BuildContext context, Contact contact) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => RoomLoginSheet(contact: contact), + ); + } + + void _showContactDetails(BuildContext context, Contact contact) { + // Get room login state + final connectionProvider = context.read(); + final roomLoginState = contact.type == ContactType.room + ? connectionProvider.getRoomLoginState(contact.publicKeyPrefix) + : null; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.4, + maxChildSize: 0.9, + expand: false, + builder: (context, scrollController) => Column( + children: [ + // Handle bar + Container( + margin: const EdgeInsets.only(top: 8, bottom: 16), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + CircleAvatar( + backgroundColor: _getTypeColor(contact.type, context), + child: contact.roleEmoji != null + ? Text( + contact.roleEmoji!, + style: const TextStyle(fontSize: 24), + ) + : Icon( + _getTypeIcon(contact.type), + color: Colors.white, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + contact.displayName, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(), + // Content + Expanded( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.all(16), + children: [ + _DetailRow('Type', contact.type.displayName), + // Public Key with copy button + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox( + width: 100, + child: Text( + 'Public Key:', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + Expanded( + child: Text(contact.publicKeyShort), + ), + const SizedBox(width: 8), + InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: contact.publicKeyHex)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Public key copied to clipboard'), + duration: Duration(seconds: 2), + ), + ); + }, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + Icons.copy, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ], + ), + ), + _DetailRow('Last Seen', contact.timeSinceLastSeen), + const SizedBox(height: 16), + // Room Login Status + if (roomLoginState != null) ...[ + const Text( + 'Room Status:', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + _DetailRow( + 'Login Status', + roomLoginState.isLoggedIn ? 'Logged In' : 'Not Logged In', + ), + if (roomLoginState.isLoggedIn) ...[ + _DetailRow( + 'Admin Access', + roomLoginState.isAdmin ? 'Yes' : 'No', + ), + _DetailRow( + 'Permissions', + roomLoginState.permissions.toString(), + ), + if (roomLoginState.loginDurationFormatted != null) + _DetailRow( + 'Logged In', + roomLoginState.loginDurationFormatted!, + ), + ], + _DetailRow( + 'Password Saved', + roomLoginState.hasPassword ? 'Yes' : 'No', + ), + const SizedBox(height: 16), + ], + if (contact.displayLocation != null) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Location:', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + TextButton.icon( + onPressed: () { + // Navigate to map and close modal + final mapProvider = context.read(); + mapProvider.navigateToLocation( + location: LatLng( + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ), + ); + Navigator.pop(context); + + // Switch to map tab (assuming it's index 2) + DefaultTabController.of(context).animateTo(2); + }, + icon: const Icon(Icons.map, size: 18), + label: const Text('View on Map'), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + ), + ], + ), + const SizedBox(height: 8), + // Decimal Degrees (DD) + _DetailRowWithCopy( + context, + 'DD', + '${contact.displayLocation!.latitude.toStringAsFixed(6)}, ${contact.displayLocation!.longitude.toStringAsFixed(6)}', + ), + // Degrees Minutes Seconds (DMS) + _DetailRowWithCopy( + context, + 'DMS', + _convertToDMS(contact.displayLocation!.latitude, contact.displayLocation!.longitude), + ), + // Degrees Decimal Minutes (DDM) + _DetailRowWithCopy( + context, + 'DDM', + _convertToDDM(contact.displayLocation!.latitude, contact.displayLocation!.longitude), + ), + // MGRS (Military Grid Reference System) + _DetailRowWithCopy( + context, + 'MGRS', + _convertToMGRS(contact.displayLocation!.latitude, contact.displayLocation!.longitude), + ), + // Google Plus Code + _DetailRowWithCopy( + context, + 'Plus Code', + _convertToPlusCode(contact.displayLocation!.latitude, contact.displayLocation!.longitude), + ), + const SizedBox(height: 16), + ], + if (contact.telemetry != null) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Telemetry:', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + TextButton.icon( + onPressed: () { + final connectionProvider = context.read(); + connectionProvider.requestTelemetry(contact.publicKey, zeroHop: true); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Requesting telemetry from ${contact.displayName}...'), + duration: const Duration(seconds: 2), + ), + ); + }, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Refresh'), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + ), + ], + ), + const SizedBox(height: 8), + if (contact.telemetry!.batteryMilliVolts != null) + _DetailRow( + 'Voltage', + '${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V' + '${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}', + ) + else if (contact.telemetry!.batteryPercentage != null) + _DetailRow('Battery', '${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%'), + if (contact.telemetry!.temperature != null) + _DetailRow('Temperature', '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'), + if (contact.telemetry!.humidity != null) + _DetailRow('Humidity', '${contact.telemetry!.humidity!.toStringAsFixed(1)}%'), + if (contact.telemetry!.pressure != null) + _DetailRow('Pressure', '${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa'), + if (contact.telemetry!.gpsLocation != null) + _DetailRow( + 'GPS (Telemetry)', + '${contact.telemetry!.gpsLocation!.latitude.toStringAsFixed(6)}, ${contact.telemetry!.gpsLocation!.longitude.toStringAsFixed(6)}', + ), + _DetailRow( + 'Updated', + '${_formatTimestamp(contact.telemetry!.timestamp)} (${_formatTimeAgo(contact.telemetry!.timestamp)})', + ), + ], + // Direct Message button for chat contacts + if (contact.type == ContactType.chat) ...[ + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(context); // Close details first + _showDirectMessageDialog(context, contact); + }, + icon: const Icon(Icons.message), + label: const Text('Send Direct Message'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + backgroundColor: _getTypeColor(contact.type, context), + foregroundColor: Colors.white, + ), + ), + ), + ], + // Room Login button for room contacts (except Public Channel) + if (contact.type == ContactType.room && contact.advName != 'Public Channel') ...[ + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(context); // Close details first + _showRoomLoginDialog(context, contact); + }, + icon: const Icon(Icons.login), + label: Text(roomLoginState?.isLoggedIn == true ? 'Re-Login to Room' : 'Login to Room'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + backgroundColor: _getTypeColor(contact.type, context), + foregroundColor: Colors.white, + ), + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } + + Widget _DetailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + '$label:', + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + Expanded( + child: Text(value), + ), + ], + ), + ); + } + + Widget _DetailRowWithCopy(BuildContext context, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 100, + child: Text( + '$label:', + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + Expanded( + child: Text(value), + ), + const SizedBox(width: 8), + 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(4), + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + Icons.copy, + size: 16, + 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 + // For now, return a placeholder that shows it's not fully implemented + 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; + } + + IconData _getTypeIcon(ContactType type) { + switch (type) { + case ContactType.chat: + return Icons.person; + case ContactType.repeater: + return Icons.router; + case ContactType.room: + return Icons.tag; + default: + return Icons.help; + } + } + + Color _getTypeColor(ContactType type, BuildContext context) { + switch (type) { + case ContactType.chat: + return Theme.of(context).colorScheme.primary; + case ContactType.repeater: + return Colors.green; + case ContactType.room: + return Colors.orange; + default: + return Colors.grey; + } + } + + IconData _getBatteryIcon(double percentage) { + if (percentage > 80) return Icons.battery_full; + if (percentage > 50) return Icons.battery_5_bar; + if (percentage > 20) return Icons.battery_3_bar; + return Icons.battery_1_bar; + } + + Color _getBatteryColor(double percentage) { + if (percentage > 50) return Colors.green; + if (percentage > 20) return Colors.orange; + return Colors.red; + } + + /// Get room login status color + Color _getRoomStatusColor(RoomLoginState state) { + if (!state.isLoggedIn) { + return Colors.grey; // Grey for not logged in + } + if (state.isAdmin) { + return Colors.red; // Red for admin + } + return Colors.green; // Green for logged in (non-admin) + } + + /// Get room login status icon + IconData _getRoomStatusIcon(RoomLoginState state) { + if (!state.isLoggedIn) { + return Icons.lock; // Lock for not logged in + } + if (state.isAdmin) { + return Icons.admin_panel_settings; // Admin icon for admin + } + return Icons.check; // Check for logged in (non-admin) + } + + String _formatTimestamp(DateTime timestamp) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final timestampDate = DateTime(timestamp.year, timestamp.month, timestamp.day); + + if (timestampDate == today) { + // Today - show time only + return '${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}:${timestamp.second.toString().padLeft(2, '0')}'; + } else { + // Another day - show date and time + return '${timestamp.year}-${timestamp.month.toString().padLeft(2, '0')}-${timestamp.day.toString().padLeft(2, '0')} ${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}'; + } + } + + String _formatTimeAgo(DateTime timestamp) { + final now = DateTime.now(); + final diff = now.difference(timestamp); + + if (diff.inSeconds < 60) { + return '${diff.inSeconds}s ago'; + } else if (diff.inMinutes < 60) { + return '${diff.inMinutes}m ago'; + } else if (diff.inHours < 24) { + return '${diff.inHours}h ago'; + } else if (diff.inDays == 1) { + return 'yesterday'; + } else { + return '${diff.inDays}d ago'; + } + } +} diff --git a/lib/widgets/contacts/direct_message_sheet.dart b/lib/widgets/contacts/direct_message_sheet.dart new file mode 100644 index 0000000..73675ea --- /dev/null +++ b/lib/widgets/contacts/direct_message_sheet.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import '../../models/contact.dart'; +import '../../providers/connection_provider.dart'; + +class DirectMessageSheet extends StatefulWidget { + final Contact contact; + + const DirectMessageSheet({super.key, required this.contact}); + + @override + State createState() => _DirectMessageSheetState(); +} + +class _DirectMessageSheetState extends State { + final TextEditingController _textController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + int _characterCount = 0; + static const int _maxCharacters = 160; + + @override + void initState() { + super.initState(); + _textController.addListener(_updateCharacterCount); + } + + @override + void dispose() { + _textController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _updateCharacterCount() { + setState(() { + _characterCount = _textController.text.length; + }); + } + + Future _sendDirectMessage() async { + final text = _textController.text.trim(); + if (text.isEmpty) return; + + final connectionProvider = context.read(); + + if (!connectionProvider.deviceInfo.isConnected) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Not connected to device'), + backgroundColor: Colors.red, + ), + ); + return; + } + + try { + // Send direct message to contact + await connectionProvider.sendTextMessage( + contactPublicKey: widget.contact.publicKey, + text: text, + ); + + _textController.clear(); + _focusNode.unfocus(); + + if (!mounted) return; + Navigator.pop(context); // Close the dialog + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Direct message sent to ${widget.contact.displayName}'), + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to send: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return Container( + height: MediaQuery.of(context).size.height * 0.9, + decoration: const BoxDecoration( + color: Color(0xFF1E1E1E), + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + Expanded( + child: Column( + children: [ + const Text( + 'Direct Message', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + widget.contact.displayName, + style: const TextStyle( + color: Colors.grey, + fontSize: 14, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.more_vert, color: Colors.white), + onPressed: () {}, + ), + ], + ), + ), + + // Info banner + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: Theme.of(context).colorScheme.onPrimaryContainer), + const SizedBox(width: 12), + Expanded( + child: Text( + 'This message will be sent directly to ${widget.contact.displayName}. It will also appear in the main messages feed.', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontSize: 13, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 16), + + const Spacer(), + + // Message input + Container( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: 16 + MediaQuery.of(context).viewInsets.bottom, + ), + decoration: const BoxDecoration( + color: Color(0xFF2D2D2D), + ), + child: Column( + children: [ + TextField( + controller: _textController, + focusNode: _focusNode, + maxLength: _maxCharacters, + maxLines: 3, + autofocus: true, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: 'Type your message...', + hintStyle: const TextStyle(color: Colors.grey), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.grey), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.grey), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.white), + ), + contentPadding: const EdgeInsets.all(16), + counterText: _characterCount >= 150 + ? '$_characterCount/$_maxCharacters' + : '', + counterStyle: TextStyle( + fontSize: 11, + color: _characterCount > _maxCharacters * 0.9 + ? Colors.orange + : Colors.grey, + ), + ), + textInputAction: TextInputAction.send, + onSubmitted: (_) => _sendDirectMessage(), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _textController.text.trim().isEmpty + ? null + : _sendDirectMessage, + icon: const Icon(Icons.send), + label: const Text('Send Direct Message'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/contacts/room_login_sheet.dart b/lib/widgets/contacts/room_login_sheet.dart new file mode 100644 index 0000000..569b77d --- /dev/null +++ b/lib/widgets/contacts/room_login_sheet.dart @@ -0,0 +1,438 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../models/contact.dart'; +import '../../providers/connection_provider.dart'; +import '../../providers/contacts_provider.dart'; + +class RoomLoginSheet extends StatefulWidget { + final Contact contact; + + const RoomLoginSheet({super.key, required this.contact}); + + @override + State createState() => _RoomLoginSheetState(); +} + +class _RoomLoginSheetState extends State { + final TextEditingController _passwordController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + bool _isLoggingIn = false; + bool _obscurePassword = true; + + @override + void initState() { + super.initState(); + _loadSavedPassword(); + } + + @override + void dispose() { + _passwordController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + /// Load saved password for this room, or use default "hello" + Future _loadSavedPassword() async { + final prefs = await SharedPreferences.getInstance(); + final roomKey = 'room_password_${widget.contact.publicKeyHex}'; + final savedPassword = prefs.getString(roomKey) ?? 'hello'; + _passwordController.text = savedPassword; + } + + /// Save password for this room + Future _savePassword(String password) async { + final prefs = await SharedPreferences.getInstance(); + final roomKey = 'room_password_${widget.contact.publicKeyHex}'; + await prefs.setString(roomKey, password); + } + + Future _loginToRoom() async { + final password = _passwordController.text.trim().isEmpty + ? 'hello' + : _passwordController.text.trim(); + + final connectionProvider = context.read(); + final contactsProvider = context.read(); + + if (!connectionProvider.deviceInfo.isConnected) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Not connected to device'), + backgroundColor: Colors.red, + ), + ); + return; + } + + setState(() { + _isLoggingIn = true; + }); + + // 🕐 CLOCK DRIFT CHECK: Get device time to detect synchronization issues + print('🕐 [RoomLogin] Checking for clock drift between app and radio...'); + try { + await connectionProvider.getDeviceTime(); + // Give time for response to be logged + await Future.delayed(const Duration(milliseconds: 300)); + } catch (e) { + print('⚠️ [RoomLogin] Failed to get device time: $e'); + // Don't fail login - this is just a diagnostic check + } + + // 🔍 PRE-LOGIN CHECK: Ensure room contact exists in device + print('🔍 [RoomLogin] Checking if room "${widget.contact.advName}" exists in contacts...'); + print(' Target public key prefix: ${widget.contact.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + + // Check if the room exists in our local contacts + bool roomExists = contactsProvider.rooms.any( + (room) => room.publicKeyHex == widget.contact.publicKeyHex, + ); + + print(' Local contact list: ${roomExists ? "✅ Found" : "❌ Not found"}'); + + if (!roomExists) { + print('⚠️ [RoomLogin] Room not in local contacts - syncing with device...'); + + try { + // Sync contacts from device + await connectionProvider.getContacts(); + + // Give time for contacts to be processed + await Future.delayed(const Duration(milliseconds: 800)); + + // Check again after sync + roomExists = contactsProvider.rooms.any( + (room) => room.publicKeyHex == widget.contact.publicKeyHex, + ); + + print(' After sync: ${roomExists ? "✅ Found" : "❌ Still not found"}'); + + if (!roomExists) { + // Room still doesn't exist on the device - try to add it manually + print('❌ [RoomLogin] Room still not found after sync'); + print('🔧 [RoomLogin] Attempting to add room contact to companion radio...'); + + try { + // Manually add the room contact to the radio's flash storage + await connectionProvider.addOrUpdateContact(widget.contact); + + print('✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT'); + print(' Waiting 500ms for radio to save to flash...'); + + // Give the radio time to save the contact to flash + await Future.delayed(const Duration(milliseconds: 500)); + + print('✅ [RoomLogin] Room contact should now be available - proceeding with login'); + } catch (e) { + print('❌ [RoomLogin] Failed to add room contact: $e'); + + if (!mounted) return; + + setState(() { + _isLoggingIn = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Failed to add room to device: $e\n\n' + 'The room may not have advertised yet.\n' + 'Try waiting for the room to broadcast.', + ), + backgroundColor: Colors.red, + duration: const Duration(seconds: 7), + ), + ); + + // Log available rooms for debugging + final availableRooms = contactsProvider.rooms; + print('📋 [RoomLogin] Available rooms on device (${availableRooms.length}):'); + for (final room in availableRooms) { + print(' - ${room.advName} (${room.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')})'); + } + + return; + } + } + + print('✅ [RoomLogin] Room contact found after sync - proceeding with login'); + } catch (e) { + print('❌ [RoomLogin] Contact sync failed: $e'); + + if (!mounted) return; + + setState(() { + _isLoggingIn = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to sync contacts: $e'), + backgroundColor: Colors.red, + ), + ); + return; + } + } else { + print('✅ [RoomLogin] Room contact found in local contacts - proceeding with login'); + } + + // Save password before sending + await _savePassword(password); + + // Set up login callbacks + Function(Uint8List, int, bool, int)? originalOnSuccess; + Function(Uint8List)? originalOnFail; + + originalOnSuccess = connectionProvider.onLoginSuccess; + originalOnFail = connectionProvider.onLoginFail; + + connectionProvider.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async { + // Restore original callback + connectionProvider.onLoginSuccess = originalOnSuccess; + connectionProvider.onLoginFail = originalOnFail; + + print('✅ [RoomLogin] Login successful! Tag: $tag, Permissions: $permissions, Admin: $isAdmin'); + print('📡 [RoomLogin] Room server will now push messages automatically via PUSH_CODE_MSG_WAITING'); + print(' Messages will be fetched when onMessageWaiting callback is triggered'); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Logged in successfully! Waiting for room messages...'), + backgroundColor: Colors.green, + duration: Duration(seconds: 3), + ), + ); + } + }; + + connectionProvider.onLoginFail = (publicKeyPrefix) { + // Restore original callback + connectionProvider.onLoginSuccess = originalOnSuccess; + connectionProvider.onLoginFail = originalOnFail; + + print('❌ [RoomLogin] Login failed - incorrect password'); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Login failed - incorrect password'), + backgroundColor: Colors.red, + duration: Duration(seconds: 3), + ), + ); + } + }; + + try { + // Send login request to room + await connectionProvider.loginToRoom( + roomPublicKey: widget.contact.publicKey, + password: password, + ); + + _focusNode.unfocus(); + + if (!mounted) return; + Navigator.pop(context); // Close the dialog + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Logging in to ${widget.contact.displayName}...'), + backgroundColor: Colors.blue, + duration: const Duration(seconds: 2), + ), + ); + } catch (e) { + // Restore original callbacks on error + connectionProvider.onLoginSuccess = originalOnSuccess; + connectionProvider.onLoginFail = originalOnFail; + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to send login: $e'), + backgroundColor: Colors.red, + ), + ); + } finally { + if (mounted) { + setState(() { + _isLoggingIn = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + decoration: const BoxDecoration( + color: Color(0xFF1E1E1E), + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Container( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Login to Room', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + widget.contact.displayName, + style: const TextStyle( + color: Colors.grey, + fontSize: 14, + ), + ), + ], + ), + ), + const SizedBox(width: 48), // Balance the back button + ], + ), + ), + + // Scrollable content area + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + // Info banner + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Enter the password to access this room. Password defaults to "hello" and will be saved for future use.', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontSize: 12, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + + // Password input (fixed at bottom) + Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Color(0xFF2D2D2D), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _passwordController, + focusNode: _focusNode, + maxLength: 15, // Max password length from protocol + obscureText: _obscurePassword, + autofocus: true, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + labelText: 'Password', + labelStyle: const TextStyle(color: Colors.grey), + hintText: 'Enter room password (default: hello)', + hintStyle: const TextStyle(color: Colors.grey), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.grey), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.grey), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.white), + ), + contentPadding: const EdgeInsets.all(16), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility : Icons.visibility_off, + color: Colors.grey, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + ), + textInputAction: TextInputAction.done, + onSubmitted: (_) => _loginToRoom(), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isLoggingIn ? null : _loginToRoom, + icon: _isLoggingIn + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.login), + label: Text(_isLoggingIn ? 'Logging in...' : 'Login'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/map/compass_widget.dart b/lib/widgets/map/compass_widget.dart new file mode 100644 index 0000000..2616404 --- /dev/null +++ b/lib/widgets/map/compass_widget.dart @@ -0,0 +1,107 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; + +class CompassWidget extends StatelessWidget { + final double heading; + final bool hasHeading; + + const CompassWidget({ + super.key, + required this.heading, + required this.hasHeading, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Container( + width: 56, + height: 56, + padding: const EdgeInsets.all(8), + child: Stack( + alignment: Alignment.center, + children: [ + // Compass rose background - rotates to show true north at top + Transform.rotate( + angle: heading * pi / 180, + child: CustomPaint( + size: const Size(40, 40), + painter: _CompassRosePainter(), + ), + ), + // Fixed needle pointing up (since map rotates) + Icon( + Icons.navigation, + color: hasHeading ? Colors.red : Colors.grey, + size: 28, + ), + // Heading text + Positioned( + bottom: 0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + hasHeading ? '${heading.round()}°' : '--', + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _CompassRosePainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.grey.withValues(alpha: 0.3) + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width / 2; + + // Draw circle + canvas.drawCircle(center, radius, paint); + + // Draw cardinal direction markers + final textPainter = TextPainter( + textDirection: TextDirection.ltr, + ); + + final directions = ['N', 'E', 'S', 'W']; + for (int i = 0; i < 4; i++) { + final angle = i * pi / 2 - pi / 2; // Start from North (top) + final x = center.dx + radius * 0.7 * cos(angle); + final y = center.dy + radius * 0.7 * sin(angle); + + textPainter.text = TextSpan( + text: directions[i], + style: TextStyle( + color: Colors.grey.shade700, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ); + textPainter.layout(); + textPainter.paint( + canvas, + Offset(x - textPainter.width / 2, y - textPainter.height / 2), + ); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/lib/widgets/map/detailed_compass_dialog.dart b/lib/widgets/map/detailed_compass_dialog.dart new file mode 100644 index 0000000..30ba413 --- /dev/null +++ b/lib/widgets/map/detailed_compass_dialog.dart @@ -0,0 +1,1447 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:flutter_compass/flutter_compass.dart'; +import 'package:latlong2/latlong.dart'; +import '../../models/contact.dart'; +import '../../models/sar_marker.dart'; + +class DetailedCompassDialog extends StatefulWidget { + final Position? initialPosition; + final double? initialHeading; + final List contacts; + final List sarMarkers; + final Contact? preSelectedContact; + final SarMarker? preSelectedSarMarker; + + const DetailedCompassDialog({ + super.key, + required this.initialPosition, + required this.initialHeading, + required this.contacts, + required this.sarMarkers, + this.preSelectedContact, + this.preSelectedSarMarker, + }); + + @override + State createState() => _DetailedCompassDialogState(); +} + +class _DetailedCompassDialogState extends State { + double? _currentHeading; + Position? _currentPosition; + 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; + + // Selected item for isolation + Contact? _selectedContact; + SarMarker? _selectedSarMarker; + + @override + void initState() { + super.initState(); + _currentHeading = widget.initialHeading; + _currentPosition = widget.initialPosition; + _selectedContact = widget.preSelectedContact; + _selectedSarMarker = widget.preSelectedSarMarker; + + // Subscribe to compass updates + final compassStream = FlutterCompass.events; + if (compassStream != null) { + _compassSubscription = compassStream.listen((event) { + if (mounted && event.heading != null) { + setState(() { + _currentHeading = event.heading; + }); + } + }); + } + + // Subscribe to position updates + _positionSubscription = Geolocator.getPositionStream( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.best, + distanceFilter: 1, + ), + ).listen((position) { + if (mounted) { + setState(() { + _currentPosition = position; + }); + } + }); + } + + @override + void dispose() { + _compassSubscription?.cancel(); + _positionSubscription?.cancel(); + super.dispose(); + } + + // Get current heading (prefer compass over GPS) + double? get currentHeading { + if (_currentHeading != null) return _currentHeading; + if (_currentPosition?.heading != null && _currentPosition!.heading >= 0) { + return _currentPosition!.heading; + } + 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.object: + return true; // Always show object markers (add filter if needed) + 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: Theme.of(context).colorScheme.primary, + 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; + final position = _currentPosition; + return Column( + children: [ + // Header with back button + Container( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + const Expanded( + child: Column( + children: [ + Text( + 'Compass', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Navigation & Contacts', + style: TextStyle( + color: Colors.grey, + fontSize: 14, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.filter_list), + tooltip: 'Filter markers', + onPressed: () => _showFilterDialog(), + ), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Heading and Elevation info + _buildInfoRow(context, heading, position), + const SizedBox(height: 12), + // Current location in multiple formats + if (position != null) _buildLocationFormats(context, position), + const SizedBox(height: 12), + // Large compass with zoom controls + GestureDetector( + onScaleStart: (details) { + _previousScale = 1.0; + }, + onScaleUpdate: (details) { + setState(() { + // 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, + child: _DetailedCompassPainter( + heading: heading ?? 0, + hasHeading: heading != null, + currentPosition: position, + contacts: _selectedContact != null + ? [_selectedContact!] + : (_selectedSarMarker != null + ? [] + : (_showContacts ? widget.contacts : [])), + sarMarkers: _selectedSarMarker != null + ? [_selectedSarMarker!] + : (_selectedContact != null + ? [] + : _getFilteredSarMarkers()), + zoomLevel: _zoomLevel, + ), + ), + ), + const SizedBox(height: 12), + // Selected item detail view + if (_selectedContact != null || _selectedSarMarker != null) + _buildSelectedItemDetail(context, heading, position), + const SizedBox(height: 12), + // Contacts list + if (_showContacts && widget.contacts.isNotEmpty) _buildContactsList(context, heading, position), + // SAR Markers list + if (_getFilteredSarMarkers().isNotEmpty) _buildSarMarkersList(context, heading, position), + ], + ), + ), + ), + ), + ], + ); + } + + Widget _buildInfoRow(BuildContext context, double? heading, Position? position) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildInfoCard( + context, + 'Heading', + heading != null ? '${heading.round()}°' : '--', + Icons.explore, + ), + _buildInfoCard( + context, + 'Elevation', + position?.altitude != null + ? '${position!.altitude.round()}m' + : '--', + Icons.terrain, + ), + _buildInfoCard( + context, + 'Accuracy', + position?.accuracy != null + ? '±${position!.accuracy.round()}m' + : '--', + Icons.gps_fixed, + ), + ], + ); + } + + Widget _buildInfoCard( + BuildContext context, String label, String value, IconData icon) { + return Column( + children: [ + Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), + const SizedBox(height: 4), + Text( + value, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + label, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + } + + Widget _buildLocationFormats(BuildContext context, Position position) { + return _LocationFormatToggle(position: position); + } + + Widget _buildSelectedItemDetail(BuildContext context, double? heading, Position? position) { + if (position == null) { + return const SizedBox.shrink(); + } + + String title; + IconData icon; + Color color; + double? bearing; + double? distance; + LatLng? targetLocation; + String? additionalInfo; + + if (_selectedContact != null) { + title = _selectedContact!.displayName; + icon = Icons.person; + color = Theme.of(context).colorScheme.primary; + targetLocation = _selectedContact!.displayLocation; + + if (targetLocation != null) { + bearing = _calculateBearing( + position.latitude, + position.longitude, + targetLocation.latitude, + targetLocation.longitude, + ); + distance = _calculateDistance( + position.latitude, + position.longitude, + targetLocation.latitude, + targetLocation.longitude, + ); + } + + // Show voltage/battery if available + if (_selectedContact!.telemetry?.batteryMilliVolts != null) { + final volts = (_selectedContact!.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3); + final percent = _selectedContact!.telemetry!.batteryPercentage != null + ? ' (${_selectedContact!.telemetry!.batteryPercentage!.round()}%)' + : ''; + additionalInfo = 'Voltage: ${volts}V$percent'; + } else if (_selectedContact!.telemetry?.batteryPercentage != null) { + additionalInfo = 'Battery: ${_selectedContact!.telemetry!.batteryPercentage!.round()}%'; + } + } else if (_selectedSarMarker != null) { + title = _selectedSarMarker!.type.displayName; + targetLocation = _selectedSarMarker!.location; + additionalInfo = _selectedSarMarker!.timeAgo; + + switch (_selectedSarMarker!.type) { + case SarMarkerType.foundPerson: + icon = Icons.person_pin; + color = Colors.green; + break; + case SarMarkerType.fire: + icon = Icons.local_fire_department; + color = Colors.red; + break; + case SarMarkerType.stagingArea: + icon = Icons.home_work; + color = Colors.orange; + break; + case SarMarkerType.object: + icon = Icons.inventory_2; + color = Colors.purple; + break; + case SarMarkerType.unknown: + icon = Icons.help_outline; + color = Colors.grey; + break; + } + + bearing = _calculateBearing( + position.latitude, + position.longitude, + targetLocation.latitude, + targetLocation.longitude, + ); + distance = _calculateDistance( + position.latitude, + position.longitude, + targetLocation.latitude, + targetLocation.longitude, + ); + } else { + return const SizedBox.shrink(); + } + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16), + elevation: 4, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // Header with icon and title + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: _selectedContact != null && _selectedContact!.roleEmoji != null + ? Text( + _selectedContact!.roleEmoji!, + style: const TextStyle(fontSize: 32), + ) + : Icon(icon, size: 32, color: color), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (additionalInfo != null) + Text( + additionalInfo, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + setState(() { + _selectedContact = null; + _selectedSarMarker = null; + }); + }, + ), + ], + ), + if (bearing != null && distance != null) ...[ + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 16), + // Distance and bearing info + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildLargeInfoCard( + context, + 'Distance', + _formatDistance(distance), + Icons.straighten, + color, + ), + _buildLargeInfoCard( + context, + 'Bearing', + '${bearing.round()}°', + Icons.navigation, + color, + ), + _buildLargeInfoCard( + context, + 'Direction', + _bearingToCardinal(bearing), + Icons.explore, + color, + ), + ], + ), + const SizedBox(height: 12), + // Coordinates + 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', + ), + ), + ], + ), + ), + ], + ], + ), + ), + ); + } + + Widget _buildLargeInfoCard( + BuildContext context, + String label, + String value, + IconData icon, + Color color, + ) { + return Column( + children: [ + Icon(icon, size: 28, color: color), + const SizedBox(height: 8), + Text( + value, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: color, + ), + ), + Text( + label, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + } + + // Convert decimal degrees to DMS (Degrees, Minutes, Seconds) + String _formatDMS(double degrees, bool isLatitude) { + final direction = isLatitude + ? (degrees >= 0 ? 'N' : 'S') + : (degrees >= 0 ? 'E' : 'W'); + + final absolute = degrees.abs(); + final deg = absolute.floor(); + final minDecimal = (absolute - deg) * 60; + final min = minDecimal.floor(); + final sec = (minDecimal - min) * 60; + + return '$deg°${min.toString().padLeft(2, '0')}\'${sec.toStringAsFixed(2).padLeft(5, '0')}"$direction'; + } + + Widget _buildContactsList(BuildContext context, double? heading, Position? position) { + if (position == null) { + return const Text('Location unavailable'); + } + + // Calculate bearings and distances + final contactsWithBearing = widget.contacts.map((contact) { + if (contact.displayLocation == null) return null; + + final bearing = _calculateBearing( + position.latitude, + position.longitude, + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ); + + final distance = _calculateDistance( + position.latitude, + position.longitude, + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ); + + return { + 'contact': contact, + 'bearing': bearing, + 'distance': distance, + }; + }).whereType>().toList(); + + // Sort by distance + contactsWithBearing.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, bottom: 8), + child: Text( + 'Nearby Contacts', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ...contactsWithBearing.map((item) { + final contact = item['contact'] as Contact; + final bearing = item['bearing'] as double; + final distance = item['distance'] as double; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _selectedContact == contact + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: _selectedContact == contact + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ) + : null, + ), + child: ListTile( + dense: true, + leading: contact.roleEmoji != null + ? Text( + contact.roleEmoji!, + style: const TextStyle(fontSize: 24), + ) + : Icon( + Icons.person, + color: Theme.of(context).colorScheme.primary, + size: 24, + ), + title: Text(contact.displayName), + subtitle: Text( + '${_bearingToCardinal(bearing)} • ${_formatDistance(distance)}', + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: Text( + '${bearing.round()}°', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + onTap: () { + setState(() { + if (_selectedContact == contact) { + // Deselect if already selected + _selectedContact = null; + } else { + // Select this contact and deselect SAR marker + _selectedContact = contact; + _selectedSarMarker = null; + } + }); + }, + ), + ); + }), + ], + ); + } + + 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.object: + markerColor = Colors.purple; + markerIcon = Icons.inventory_2; + 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: _selectedSarMarker == marker + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: _selectedSarMarker == marker + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ) + : null, + ), + 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, + ), + ), + onTap: () { + setState(() { + if (_selectedSarMarker == marker) { + // Deselect if already selected + _selectedSarMarker = null; + } else { + // Select this marker and deselect contact + _selectedSarMarker = marker; + _selectedContact = null; + } + }); + }, + ), + ); + }), + ], + ); + } + + // Calculate bearing between two points (in degrees) + double _calculateBearing( + double lat1, double lon1, double lat2, double lon2) { + final dLon = (lon2 - lon1) * pi / 180; + final lat1Rad = lat1 * pi / 180; + final lat2Rad = lat2 * pi / 180; + + final y = sin(dLon) * cos(lat2Rad); + final x = cos(lat1Rad) * sin(lat2Rad) - + sin(lat1Rad) * cos(lat2Rad) * cos(dLon); + + final bearing = atan2(y, x) * 180 / pi; + return (bearing + 360) % 360; + } + + // Calculate distance between two points (in meters) + double _calculateDistance( + double lat1, double lon1, double lat2, double lon2) { + const R = 6371000; // Earth's radius in meters + final dLat = (lat2 - lat1) * pi / 180; + final dLon = (lon2 - lon1) * pi / 180; + + final a = sin(dLat / 2) * sin(dLat / 2) + + cos(lat1 * pi / 180) * + cos(lat2 * pi / 180) * + sin(dLon / 2) * + sin(dLon / 2); + + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); + return R * c; + } + + String _bearingToCardinal(double bearing) { + const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + final index = ((bearing + 22.5) / 45).floor() % 8; + return directions[index]; + } + + String _formatDistance(double meters) { + if (meters < 1000) { + return '${meters.round()}m'; + } else { + return '${(meters / 1000).toStringAsFixed(1)}km'; + } + } +} + +// Detailed Compass Painter with contacts +class _DetailedCompassPainter extends StatelessWidget { + final double heading; + final bool hasHeading; + final Position? currentPosition; + final List contacts; + final List sarMarkers; + final double zoomLevel; + + const _DetailedCompassPainter({ + required this.heading, + required this.hasHeading, + required this.currentPosition, + required this.contacts, + required this.sarMarkers, + this.zoomLevel = 1.0, + }); + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _LargeCompassPainter( + heading: heading, + hasHeading: hasHeading, + currentPosition: currentPosition, + contacts: contacts, + sarMarkers: sarMarkers, + zoomLevel: zoomLevel, + ), + child: Container(), + ); + } +} + +class _LargeCompassPainter extends CustomPainter { + final double heading; + final bool hasHeading; + final Position? currentPosition; + final List contacts; + final List sarMarkers; + final double zoomLevel; + + _LargeCompassPainter({ + required this.heading, + required this.hasHeading, + required this.currentPosition, + required this.contacts, + required this.sarMarkers, + this.zoomLevel = 1.0, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width / 2; + + // Draw outer circle + final circlePaint = Paint() + ..color = Colors.grey.withValues(alpha: 0.2) + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + canvas.drawCircle(center, radius, circlePaint); + + // Draw degree markers + for (int i = 0; i < 360; i += 10) { + final angle = i * pi / 180 - pi / 2 + heading * pi / 180; + final isCardinal = i % 90 == 0; + final isMajor = i % 30 == 0; + + final startRadius = isCardinal ? radius - 25 : (isMajor ? radius - 15 : radius - 10); + final start = Offset( + center.dx + startRadius * cos(angle), + center.dy + startRadius * sin(angle), + ); + final end = Offset( + center.dx + radius * cos(angle), + center.dy + radius * sin(angle), + ); + + final markerPaint = Paint() + ..color = isCardinal ? Colors.red : Colors.grey + ..strokeWidth = isCardinal ? 3 : (isMajor ? 2 : 1); + + canvas.drawLine(start, end, markerPaint); + } + + // Draw cardinal directions + final textPainter = TextPainter(textDirection: TextDirection.ltr); + final directions = ['N', 'E', 'S', 'W']; + for (int i = 0; i < 4; i++) { + final angle = i * pi / 2 - pi / 2 + heading * pi / 180; + final x = center.dx + (radius - 35) * cos(angle); + final y = center.dy + (radius - 35) * sin(angle); + + textPainter.text = TextSpan( + text: directions[i], + style: TextStyle( + color: i == 0 ? Colors.red : Colors.grey.shade700, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ); + textPainter.layout(); + textPainter.paint( + canvas, + Offset(x - textPainter.width / 2, y - textPainter.height / 2), + ); + } + + // Draw contacts as dots relative to distance, scaled by zoom level + if (currentPosition != null && contacts.isNotEmpty) { + // Calculate distances for all contacts + final contactsWithDistance = contacts + .where((c) => c.displayLocation != null) + .map((contact) { + final bearing = _calculateBearing( + currentPosition!.latitude, + currentPosition!.longitude, + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ); + final distance = _calculateDistance( + currentPosition!.latitude, + currentPosition!.longitude, + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ); + return {'contact': contact, 'bearing': bearing, 'distance': distance}; + }).toList(); + + if (contactsWithDistance.isEmpty) return; + + // Find max distance for normalization + final maxDistance = contactsWithDistance + .map((c) => c['distance'] as double) + .reduce((a, b) => a > b ? a : b); + + // Base distance for zoom level 1.0 (in meters) + // At 1x zoom, contacts within 1km appear inside the compass + final baseDistance = 1000.0 / zoomLevel; + + for (final item in contactsWithDistance) { + final contact = item['contact'] as Contact; + 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) + // Apply zoom level: higher zoom = contacts appear closer + double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0); + + // Calculate contact position radius (from center to rim based on distance) + final contactRadius = radius * normalizedDistance * 0.85; // 0.85 to keep inside rim + + // Position of contact dot + final dotX = center.dx + contactRadius * cos(angle); + final dotY = center.dy + contactRadius * sin(angle); + + // Draw line from center to contact + final linePaint = Paint() + ..color = Colors.lightBlue.withValues(alpha: 0.3) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + canvas.drawLine( + center, + Offset(dotX, dotY), + linePaint, + ); + + // Draw contact dot (size varies with zoom) + final dotSize = (6.0 * (1.0 + zoomLevel * 0.3)).clamp(4.0, 12.0); + final dotPaint = Paint() + ..color = Colors.lightBlue + ..style = PaintingStyle.fill; + canvas.drawCircle(Offset(dotX, dotY), dotSize, dotPaint); + + // Draw white border + final borderPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + canvas.drawCircle(Offset(dotX, dotY), dotSize, borderPaint); + + // Draw distance label near the contact (only if not too crowded) + if (zoomLevel >= 0.75) { + final distanceText = _formatDistance(distance); + final labelOffset = dotSize + 12; + final labelX = center.dx + (contactRadius + labelOffset) * cos(angle); + final labelY = center.dy + (contactRadius + labelOffset) * sin(angle); + + textPainter.text = TextSpan( + text: distanceText, + style: const TextStyle( + color: Colors.lightBlue, + fontSize: 9, + 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 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.object: + markerColor = Colors.purple; + 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 + ..style = PaintingStyle.fill; + + final path = ui.Path() + ..moveTo(center.dx, center.dy - 40) + ..lineTo(center.dx - 10, center.dy + 10) + ..lineTo(center.dx + 10, center.dy + 10) + ..close(); + + canvas.drawPath(path, indicatorPaint); + } + + double _calculateBearing( + double lat1, double lon1, double lat2, double lon2) { + final dLon = (lon2 - lon1) * pi / 180; + final lat1Rad = lat1 * pi / 180; + final lat2Rad = lat2 * pi / 180; + + final y = sin(dLon) * cos(lat2Rad); + final x = cos(lat1Rad) * sin(lat2Rad) - + sin(lat1Rad) * cos(lat2Rad) * cos(dLon); + + final bearing = atan2(y, x) * 180 / pi; + return (bearing + 360) % 360; + } + + double _calculateDistance( + double lat1, double lon1, double lat2, double lon2) { + const R = 6371000; // Earth's radius in meters + final dLat = (lat2 - lat1) * pi / 180; + final dLon = (lon2 - lon1) * pi / 180; + + final a = sin(dLat / 2) * sin(dLat / 2) + + cos(lat1 * pi / 180) * + cos(lat2 * pi / 180) * + sin(dLon / 2) * + sin(dLon / 2); + + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); + return R * c; + } + + String _formatDistance(double meters) { + if (meters < 1000) { + return '${meters.round()}m'; + } else { + return '${(meters / 1000).toStringAsFixed(1)}km'; + } + } + + @override + 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; + + const _LocationFormatToggle({required this.position}); + + @override + State<_LocationFormatToggle> createState() => _LocationFormatToggleState(); +} + +class _LocationFormatToggleState extends State<_LocationFormatToggle> { + bool _showDMS = false; + + String _formatDMS(double degrees, bool isLatitude) { + final direction = isLatitude + ? (degrees >= 0 ? 'N' : 'S') + : (degrees >= 0 ? 'E' : 'W'); + + final absolute = degrees.abs(); + final deg = absolute.floor(); + final minDecimal = (absolute - deg) * 60; + final min = minDecimal.floor(); + final sec = (minDecimal - min) * 60; + + return '$deg°${min.toString().padLeft(2, '0')}\'${sec.toStringAsFixed(2).padLeft(5, '0')}"$direction'; + } + + @override + Widget build(BuildContext context) { + final position = widget.position; + + final String displayText; + + if (_showDMS) { + displayText = '${_formatDMS(position.latitude, true)} ${_formatDMS(position.longitude, false)}'; + } else { + displayText = 'Lat: ${position.latitude.toStringAsFixed(5)} Lon: ${position.longitude.toStringAsFixed(5)}'; + } + + return GestureDetector( + onTap: () { + setState(() { + _showDMS = !_showDMS; + }); + }, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + displayText, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + fontFamily: 'monospace', + fontSize: 11, + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/map/map_legend.dart b/lib/widgets/map/map_legend.dart new file mode 100644 index 0000000..8452a5d --- /dev/null +++ b/lib/widgets/map/map_legend.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; + +class MapLegend extends StatelessWidget { + final int teamMemberCount; + final int foundPersonCount; + final int fireCount; + final int stagingAreaCount; + final int objectCount; + + const MapLegend({ + super.key, + required this.teamMemberCount, + required this.foundPersonCount, + required this.fireCount, + required this.stagingAreaCount, + required this.objectCount, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Legend', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + _LegendItem( + icon: Icons.person, + color: Theme.of(context).colorScheme.primary, + label: 'Team', + count: teamMemberCount, + ), + _LegendItem( + icon: Icons.person_pin, + color: Colors.green, + label: 'Found', + count: foundPersonCount, + ), + _LegendItem( + icon: Icons.local_fire_department, + color: Colors.red, + label: 'Fire', + count: fireCount, + ), + _LegendItem( + icon: Icons.home_work, + color: Colors.orange, + label: 'Staging', + count: stagingAreaCount, + ), + _LegendItem( + icon: Icons.inventory_2, + color: Colors.purple, + label: 'Object', + count: objectCount, + ), + ], + ), + ), + ); + } +} + +class _LegendItem extends StatelessWidget { + final IconData icon; + final Color color; + final String label; + final int count; + + const _LegendItem({ + required this.icon, + required this.color, + required this.label, + required this.count, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 8), + Text( + label, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + count.toString(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + } +}