From e2fc9bcccf712a807122dac6dc7bc1144f25f401 Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 14 Oct 2025 01:02:20 +0200 Subject: [PATCH] feat: Enhance messaging and SAR marker functionalities - Integrated SAR marker sending feature in MessagesTab with a dedicated dialog for user input. - Updated message sending logic to default to the first available room if no contact is selected. - Improved user feedback with SnackBars for connection issues and successful marker sends. - Refactored contact selection to remove unused code and streamline the process. - Added location fetching capabilities using Geolocator for SAR markers. - Updated sample data with more relevant team names using emojis. - Enhanced map markers to include rotation handling and improved display of contact information. - Introduced color coding for location update age in map markers for better visibility. --- .claude/settings.local.json | 9 + lib/models/contact.dart | 62 ++ lib/models/sar_marker.dart | 5 + lib/providers/messages_provider.dart | 4 + lib/screens/contacts_tab.dart | 150 +++-- lib/screens/map_tab.dart | 434 ++++++++++++- lib/screens/messages_tab.dart | 896 ++++++++++++++++++++------- lib/utils/sample_data_generator.dart | 16 +- lib/widgets/map_markers.dart | 276 +++++---- 9 files changed, 1416 insertions(+), 436 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..5cfc099 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(flutter analyze:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/lib/models/contact.dart b/lib/models/contact.dart index bf37b59..7a28457 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; import 'package:latlong2/latlong.dart'; +import 'package:flutter/material.dart'; import 'contact_telemetry.dart'; /// MeshCore contact types @@ -129,6 +130,67 @@ class Contact { return '${diff.inDays}d ago'; } + /// Get time when location was last updated + DateTime? get locationUpdateTime { + // Prefer telemetry timestamp if available + if (telemetry?.gpsLocation != null) { + return telemetry!.timestamp; + } + // Fall back to lastAdvert time if using advertised location + if (advertLocation != null) { + return lastSeenTime; + } + return null; + } + + /// Get friendly time since location was last updated + String get timeSinceLocationUpdate { + final updateTime = locationUpdateTime; + if (updateTime == null) return 'Unknown'; + + final diff = DateTime.now().difference(updateTime); + if (diff.inMinutes < 1) return 'Now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m'; + if (diff.inHours < 24) return '${diff.inHours}h'; + return '${diff.inDays}d'; + } + + /// Extract role emoji from name (e.g., "🧑🏻‍🚒Janez" → "🧑🏻‍🚒") + /// Returns null if no emoji at start of name + String? get roleEmoji { + if (advName.isEmpty) return null; + + // Get the first character/grapheme cluster (which could be a complex emoji) + final firstChar = advName.characters.first; + + // Check if it's an emoji (basic check - emojis are typically in certain Unicode ranges) + final firstCodeUnit = firstChar.runes.first; + + // Emoji ranges (simplified check): + // 0x1F300-0x1F9FF: Misc Symbols and Pictographs, Emoticons, Transport, etc. + // 0x2600-0x26FF: Misc symbols + // 0x2700-0x27BF: Dingbats + // 0xFE00-0xFE0F: Variation Selectors + // 0x1F900-0x1F9FF: Supplemental Symbols and Pictographs + if ((firstCodeUnit >= 0x1F300 && firstCodeUnit <= 0x1F9FF) || + (firstCodeUnit >= 0x2600 && firstCodeUnit <= 0x27BF) || + (firstCodeUnit >= 0x1F600 && firstCodeUnit <= 0x1F64F)) { + return firstChar; + } + + return null; + } + + /// Get display name without role emoji (e.g., "🧑🏻‍🚒Janez" → "Janez") + /// If no emoji, returns full advName + String get displayName { + final emoji = roleEmoji; + if (emoji == null) return advName; + + // Remove the emoji from the beginning + return advName.substring(emoji.length).trim(); + } + Contact copyWith({ Uint8List? publicKey, ContactType? type, diff --git a/lib/models/sar_marker.dart b/lib/models/sar_marker.dart index e241738..c539d4b 100644 --- a/lib/models/sar_marker.dart +++ b/lib/models/sar_marker.dart @@ -6,6 +6,7 @@ enum SarMarkerType { foundPerson('🧑', 'Found Person'), fire('🔥', 'Fire'), stagingArea('🏕️', 'Staging Area'), + object('📦', 'Object'), unknown('❓', 'Unknown'); const SarMarkerType(this.emoji, this.displayName); @@ -22,6 +23,8 @@ enum SarMarkerType { case '🏕️': case '⛺': return SarMarkerType.stagingArea; + case '📦': + return SarMarkerType.object; default: return SarMarkerType.unknown; } @@ -36,6 +39,8 @@ enum SarMarkerType { return '#F44336'; // Red case SarMarkerType.stagingArea: return '#2196F3'; // Blue + case SarMarkerType.object: + return '#9C27B0'; // Purple default: return '#9E9E9E'; // Gray } diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 771eb72..ac43421 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -29,6 +29,9 @@ class MessagesProvider with ChangeNotifier { List get stagingAreaMarkers => sarMarkers.where((m) => m.type == SarMarkerType.stagingArea).toList(); + List get objectMarkers => + sarMarkers.where((m) => m.type == SarMarkerType.object).toList(); + /// Add a message void addMessage(Message message) { _messages.add(message); @@ -151,6 +154,7 @@ class MessagesProvider with ChangeNotifier { 'foundPerson': foundPersonMarkers.length, 'fire': fireMarkers.length, 'stagingArea': stagingAreaMarkers.length, + 'object': objectMarkers.length, }; } } diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index 83d05b8..0486050 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -141,16 +141,21 @@ class _ContactTile extends StatelessWidget { child: ListTile( leading: CircleAvatar( backgroundColor: _getTypeColor(contact.type), - child: Icon( - _getTypeIcon(contact.type), - color: Colors.white, - ), + child: contact.roleEmoji != null + ? Text( + contact.roleEmoji!, + style: const TextStyle(fontSize: 24), + ) + : Icon( + _getTypeIcon(contact.type), + color: Colors.white, + ), ), title: Row( children: [ Expanded( child: Text( - contact.advName, + contact.displayName, style: const TextStyle(fontWeight: FontWeight.bold), ), ), @@ -236,7 +241,7 @@ class _ContactTile extends StatelessWidget { connectionProvider.requestTelemetry(contact.publicKey); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Requesting telemetry from ${contact.advName}'), + content: Text('Requesting telemetry from ${contact.displayName}'), duration: const Duration(seconds: 2), ), ); @@ -249,42 +254,107 @@ class _ContactTile extends StatelessWidget { } void _showContactDetails(BuildContext context, Contact contact) { - showDialog( + showModalBottomSheet( context: context, - builder: (context) => AlertDialog( - title: Text(contact.advName), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _DetailRow('Type', contact.type.displayName), - _DetailRow('Public Key', contact.publicKeyShort), - _DetailRow('Last Seen', contact.timeSinceLastSeen), - const Divider(), - if (contact.displayLocation != null) ...[ - const Text('Location:', style: TextStyle(fontWeight: FontWeight.bold)), - _DetailRow('Latitude', contact.displayLocation!.latitude.toStringAsFixed(6)), - _DetailRow('Longitude', contact.displayLocation!.longitude.toStringAsFixed(6)), - const Divider(), - ], - if (contact.telemetry != null) ...[ - const Text('Telemetry:', style: TextStyle(fontWeight: FontWeight.bold)), - 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'), - _DetailRow('Updated', contact.telemetry!.isRecent ? 'Recently' : 'Stale'), - ], - ], - ), + 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), + 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), + _DetailRow('Public Key', contact.publicKeyShort), + _DetailRow('Last Seen', contact.timeSinceLastSeen), + const SizedBox(height: 16), + if (contact.displayLocation != null) ...[ + const Text( + 'Location:', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + _DetailRow('Latitude', contact.displayLocation!.latitude.toStringAsFixed(6)), + _DetailRow('Longitude', contact.displayLocation!.longitude.toStringAsFixed(6)), + const SizedBox(height: 16), + ], + if (contact.telemetry != null) ...[ + const Text( + 'Telemetry:', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + 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'), + _DetailRow('Updated', contact.telemetry!.isRecent ? 'Recently' : 'Stale'), + ], + ], + ), + ), + ], ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ), - ], ), ); } diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index 6269956..855e613 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -527,6 +527,60 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ); } + void _showDetailedCompassWithContact( + BuildContext context, + List contacts, + List sarMarkers, + Contact selectedContact, + ) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => Container( + height: MediaQuery.of(context).size.height * 0.9, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: _DetailedCompassDialog( + initialPosition: _currentPosition, + initialHeading: _currentHeading, + contacts: contacts, + sarMarkers: sarMarkers, + preSelectedContact: selectedContact, + ), + ), + ); + } + + void _showDetailedCompassWithSarMarker( + BuildContext context, + List contacts, + List sarMarkers, + SarMarker selectedMarker, + ) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => Container( + height: MediaQuery.of(context).size.height * 0.9, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: _DetailedCompassDialog( + initialPosition: _currentPosition, + initialHeading: _currentHeading, + contacts: contacts, + sarMarkers: sarMarkers, + preSelectedSarMarker: selectedMarker, + ), + ), + ); + } + void _restartLocationStream() { // Cancel existing subscription _positionStreamSubscription?.cancel(); @@ -600,10 +654,28 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ...MapMarkers.createTeamMemberMarkers( contactsWithLocation, context, + mapRotation: _mapController.camera.rotation, + onContactTap: (contact) { + _showDetailedCompassWithContact( + context, + contactsProvider.chatContactsWithLocation, + messagesProvider.sarMarkers, + contact, + ); + }, ), ...MapMarkers.createSarMarkers( sarMarkers, context, + mapRotation: _mapController.camera.rotation, + onSarMarkerTap: (marker) { + _showDetailedCompassWithSarMarker( + context, + contactsProvider.chatContactsWithLocation, + messagesProvider.sarMarkers, + marker, + ); + }, ), // User location marker if (_currentPosition != null) @@ -657,33 +729,33 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ], ), ), - // Compass widget - top right - if (_rotateMarkerWithHeading) - Positioned( - top: 16, - right: 16, - child: GestureDetector( - onTap: () => _showDetailedCompass( - context, - contactsProvider.chatContactsWithLocation, - messagesProvider.sarMarkers, - ), - child: _CompassWidget( - heading: _currentHeading ?? 0, - hasHeading: _currentHeading != null, - ), + // Compass widget - top right (always visible) + Positioned( + top: 16, + right: 16, + child: GestureDetector( + onTap: () => _showDetailedCompass( + context, + contactsProvider.chatContactsWithLocation, + messagesProvider.sarMarkers, + ), + child: _CompassWidget( + heading: _currentHeading ?? 0, + hasHeading: _currentHeading != null, ), ), + ), // Map legend overlay if (_showLegend) Positioned( - top: _rotateMarkerWithHeading ? 80 : 16, - right: 16, + top: 80, // Position below compass (which is always visible now) + left: 16, child: _MapLegend( teamMemberCount: contactsWithLocation.length, foundPersonCount: messagesProvider.foundPersonMarkers.length, fireCount: messagesProvider.fireMarkers.length, stagingAreaCount: messagesProvider.stagingAreaMarkers.length, + objectCount: messagesProvider.objectMarkers.length, ), ), // Map controls - right side @@ -764,12 +836,14 @@ class _MapLegend extends StatelessWidget { 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 @@ -812,6 +886,12 @@ class _MapLegend extends StatelessWidget { label: 'Staging', count: stagingAreaCount, ), + _LegendItem( + icon: Icons.inventory_2, + color: Colors.purple, + label: 'Object', + count: objectCount, + ), ], ), ), @@ -975,12 +1055,16 @@ class _DetailedCompassDialog extends StatefulWidget { 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 @@ -1004,11 +1088,17 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { 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; @@ -1063,6 +1153,8 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { 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 } @@ -1261,13 +1353,25 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { heading: heading ?? 0, hasHeading: heading != null, currentPosition: position, - contacts: _showContacts ? widget.contacts : [], - sarMarkers: _getFilteredSarMarkers(), + 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 @@ -1335,6 +1439,230 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { 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 = Colors.blue; + 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 battery if available + 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 @@ -1404,17 +1732,30 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { return Container( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + 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: const Icon( - Icons.person, - color: Colors.blue, - size: 24, - ), - title: Text(contact.advName), + leading: contact.roleEmoji != null + ? Text( + contact.roleEmoji!, + style: const TextStyle(fontSize: 24), + ) + : const Icon( + Icons.person, + color: Colors.blue, + size: 24, + ), + title: Text(contact.displayName), subtitle: Text( '${_bearingToCardinal(bearing)} • ${_formatDistance(distance)}', style: Theme.of(context).textTheme.bodySmall, @@ -1425,6 +1766,18 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { 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; + } + }); + }, ), ); }), @@ -1500,6 +1853,10 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { 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; @@ -1509,8 +1866,16 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { return Container( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + 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, @@ -1530,6 +1895,18 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { 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; + } + }); + }, ), ); }), @@ -1862,6 +2239,9 @@ class _LargeCompassPainter extends CustomPainter { case SarMarkerType.stagingArea: markerColor = Colors.orange; break; + case SarMarkerType.object: + markerColor = Colors.purple; + break; case SarMarkerType.unknown: markerColor = Colors.grey; break; diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 582c534..0596b6c 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'package:geolocator/geolocator.dart'; import '../providers/messages_provider.dart'; import '../providers/contacts_provider.dart'; import '../providers/map_provider.dart'; import '../providers/connection_provider.dart'; import '../models/message.dart'; -import '../models/contact.dart'; +import '../models/sar_marker.dart'; class MessagesTab extends StatefulWidget { final VoidCallback onNavigateToMap; @@ -20,7 +21,6 @@ class MessagesTab extends StatefulWidget { class _MessagesTabState extends State { final TextEditingController _textController = TextEditingController(); final FocusNode _focusNode = FocusNode(); - Contact? _selectedContact; int _characterCount = 0; static const int _maxCharacters = 160; @@ -48,6 +48,7 @@ class _MessagesTabState extends State { if (text.isEmpty) return; final connectionProvider = context.read(); + final contactsProvider = context.read(); if (!connectionProvider.deviceInfo.isConnected) { if (!mounted) return; @@ -60,23 +61,15 @@ class _MessagesTabState extends State { return; } - if (_selectedContact == null) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Please select a recipient'), - backgroundColor: Colors.orange, - ), - ); - return; - } - try { - // Send to channel/room or direct contact - if (_selectedContact!.isRoom) { - // For rooms/channels, use the first byte of outPath as channel index - final channelIdx = _selectedContact!.outPath.isNotEmpty - ? _selectedContact!.outPath[0] + // Default to sending to room/channel (first available room) + final rooms = contactsProvider.rooms; + + if (rooms.isNotEmpty) { + // Send to first available room + final defaultRoom = rooms.first; + final channelIdx = defaultRoom.outPath.isNotEmpty + ? defaultRoom.outPath[0] : 0; await connectionProvider.sendChannelMessage( @@ -84,11 +77,15 @@ class _MessagesTabState extends State { text: text, ); } else { - // For direct contacts (chat type) - await connectionProvider.sendTextMessage( - contactPublicKey: _selectedContact!.publicKey, - text: text, + // No rooms available, show error + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No channels available'), + backgroundColor: Colors.orange, + ), ); + return; } _textController.clear(); @@ -113,130 +110,92 @@ class _MessagesTabState extends State { } } - void _showContactSelector() { - final contactsProvider = context.read(); - final allContacts = contactsProvider.contacts; + void _showSarDialog() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => _SarUpdateSheet( + onSend: (sarType, position, notes) async { + await _sendSarMessage(sarType, position, notes); + }, + ), + ); + } - if (allContacts.isEmpty) { + Future _sendSarMessage( + SarMarkerType sarType, + Position position, + String? notes, + ) async { + final connectionProvider = context.read(); + final contactsProvider = context.read(); + + if (!connectionProvider.deviceInfo.isConnected) { + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('No contacts available'), - backgroundColor: Colors.orange, + content: Text('Not connected to device'), + backgroundColor: Colors.red, ), ); return; } - // Group contacts by type - final chatContacts = contactsProvider.chatContacts; - final rooms = contactsProvider.rooms; - final repeaters = contactsProvider.repeaters; + try { + // Format: S::, + final sarMessage = 'S:${sarType.emoji}:${position.latitude},${position.longitude}'; - showModalBottomSheet( - context: context, - builder: (context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'Select Recipient', - style: Theme.of(context).textTheme.titleLarge, - ), + // Add notes if provided + final fullMessage = notes != null && notes.isNotEmpty + ? '$sarMessage $notes' + : sarMessage; + + // Default to sending to room/channel (first available room) + final rooms = contactsProvider.rooms; + + if (rooms.isNotEmpty) { + // Send to first available room + final defaultRoom = rooms.first; + final channelIdx = defaultRoom.outPath.isNotEmpty + ? defaultRoom.outPath[0] + : 0; + + await connectionProvider.sendChannelMessage( + channelIdx: channelIdx, + text: fullMessage, + ); + } else { + // No rooms available, show error + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No channels available'), + backgroundColor: Colors.orange, ), - const Divider(height: 1), - Flexible( - child: ListView( - shrinkWrap: true, - children: [ - // Chat contacts section - if (chatContacts.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Text( - 'Team Members', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ...chatContacts.map((contact) => _buildContactTile( - contact: contact, - icon: Icons.person, - color: Colors.blue, - )), - ], + ); + return; + } - // Rooms/Channels section - if (rooms.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Text( - 'Channels', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ...rooms.map((contact) => _buildContactTile( - contact: contact, - icon: Icons.tag, - color: Colors.purple, - )), - ], - - // Repeaters section (informational only) - if (repeaters.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Text( - 'Repeaters (Read-only)', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: Colors.grey, - ), - ), - ), - ...repeaters.map((contact) => ListTile( - enabled: false, - leading: CircleAvatar( - backgroundColor: Colors.grey, - child: const Icon(Icons.router, color: Colors.white, size: 20), - ), - title: Text(contact.advName), - subtitle: Text(contact.timeSinceLastSeen), - )), - ], - ], - ), - ), - ], - ), - ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${sarType.displayName} marker sent'), + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to send SAR marker: $e'), + backgroundColor: Colors.red, + ), + ); + } } - Widget _buildContactTile({ - required Contact contact, - required IconData icon, - required Color color, - }) { - return ListTile( - leading: CircleAvatar( - backgroundColor: color, - child: Icon(icon, color: Colors.white, size: 20), - ), - title: Text(contact.advName), - subtitle: Text(contact.timeSinceLastSeen), - trailing: _selectedContact?.publicKeyHex == contact.publicKeyHex - ? const Icon(Icons.check_circle, color: Colors.green) - : null, - onTap: () { - setState(() { - _selectedContact = contact; - }); - Navigator.pop(context); - }, - ); - } @override Widget build(BuildContext context) { @@ -308,119 +267,67 @@ class _MessagesTabState extends State { ), ), ), - padding: const EdgeInsets.fromLTRB(4, 4, 4, 4), - child: Column( - mainAxisSize: MainAxisSize.min, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ - // Contact selector (compact) - if (_selectedContact != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - margin: const EdgeInsets.only(bottom: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon( - _selectedContact!.isRoom - ? Icons.tag - : Icons.person, - size: 14, - ), - const SizedBox(width: 6), - Expanded( - child: Text( - _selectedContact!.advName, - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 12, - ), - overflow: TextOverflow.ellipsis, - ), - ), - GestureDetector( - onTap: () { - setState(() { - _selectedContact = null; - }); - }, - child: const Padding( - padding: EdgeInsets.all(4), - child: Icon(Icons.close, size: 14), - ), - ), - ], - ), + // SAR quick action button + IconButton( + icon: const Icon(Icons.add_location_alt), + tooltip: 'Send SAR marker', + onPressed: _showSarDialog, + style: IconButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer, ), - - // Text input row - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - // Contact selector button (compact) - IconButton( - icon: const Icon(Icons.contacts, size: 20), - onPressed: _showContactSelector, - tooltip: 'Select contact', - padding: const EdgeInsets.all(8), - constraints: const BoxConstraints( - minWidth: 36, - minHeight: 36, + ), + const SizedBox(width: 8), + // Text field with embedded send button + Expanded( + child: TextField( + controller: _textController, + focusNode: _focusNode, + maxLength: _maxCharacters, + maxLines: null, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + hintText: 'Message to channel...', + hintStyle: const TextStyle(fontSize: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), ), - ), - - // Text field (compact) - Expanded( - child: TextField( - controller: _textController, - focusNode: _focusNode, - maxLength: _maxCharacters, - maxLines: null, - maxLengthEnforcement: MaxLengthEnforcement.enforced, - style: const TextStyle(fontSize: 14), - decoration: InputDecoration( - hintText: 'Message...', - hintStyle: const TextStyle(fontSize: 14), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - isDense: true, - counterText: '$_characterCount/$_maxCharacters', - counterStyle: TextStyle( - fontSize: 10, - color: _characterCount > _maxCharacters * 0.9 - ? Colors.orange - : Theme.of(context).textTheme.bodySmall?.color, - ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + isDense: true, + counterText: _characterCount >= 150 + ? '$_characterCount/$_maxCharacters' + : '', + counterStyle: TextStyle( + fontSize: 10, + color: _characterCount > _maxCharacters * 0.9 + ? Colors.orange + : Theme.of(context).textTheme.bodySmall?.color, + ), + suffixIcon: IconButton( + icon: Icon( + Icons.send_rounded, + size: 22, + color: _textController.text.trim().isEmpty + ? Theme.of(context).disabledColor + : Theme.of(context).colorScheme.primary, ), - textInputAction: TextInputAction.send, - onSubmitted: (_) => _sendMessage(), + onPressed: _textController.text.trim().isEmpty + ? null + : _sendMessage, + tooltip: 'Send', ), ), - - // Send button (compact) - IconButton( - icon: const Icon(Icons.send, size: 20), - onPressed: _textController.text.trim().isEmpty - ? null - : _sendMessage, - color: Theme.of(context).colorScheme.primary, - padding: const EdgeInsets.all(8), - constraints: const BoxConstraints( - minWidth: 36, - minHeight: 36, - ), - ), - ], + textInputAction: TextInputAction.send, + onSubmitted: (_) => _sendMessage(), + ), ), ], ), @@ -566,3 +473,512 @@ class _MessageBubble extends StatelessWidget { return Theme.of(context).colorScheme.primaryContainer; } } + +// SAR Update Sheet +class _SarUpdateSheet extends StatefulWidget { + final Future Function(SarMarkerType, Position, String?) onSend; + + const _SarUpdateSheet({required this.onSend}); + + @override + State<_SarUpdateSheet> createState() => _SarUpdateSheetState(); +} + +class _SarUpdateSheetState extends State<_SarUpdateSheet> { + SarMarkerType _selectedType = SarMarkerType.foundPerson; + Position? _currentPosition; + bool _loadingLocation = false; + String? _locationError; + final TextEditingController _notesController = TextEditingController(); + + @override + void initState() { + super.initState(); + _getCurrentLocation(); + } + + @override + void dispose() { + _notesController.dispose(); + super.dispose(); + } + + Future _getCurrentLocation() async { + setState(() { + _loadingLocation = true; + _locationError = null; + }); + + try { + // Check if location services are enabled + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + setState(() { + _locationError = 'Location services are disabled'; + _loadingLocation = false; + }); + return; + } + + // Check permissions + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + setState(() { + _locationError = 'Location permission denied'; + _loadingLocation = false; + }); + return; + } + } + + if (permission == LocationPermission.deniedForever) { + setState(() { + _locationError = 'Location permission permanently denied'; + _loadingLocation = false; + }); + return; + } + + // Get current position + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.best, + distanceFilter: 0, + ), + ); + + if (mounted) { + setState(() { + _currentPosition = position; + _loadingLocation = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _locationError = 'Failed to get location: $e'; + _loadingLocation = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header with drag handle + Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), + child: Column( + children: [ + // Drag handle + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + // Title + Row( + children: [ + Icon( + Icons.add_location_alt, + size: 24, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Send SAR Marker', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ], + ), + ), + const Divider(height: 1), + // Content + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Marker type selection + Text( + 'Marker Type', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + _MarkerTypeChip( + type: SarMarkerType.foundPerson, + isSelected: _selectedType == SarMarkerType.foundPerson, + onTap: () => setState(() => _selectedType = SarMarkerType.foundPerson), + ), + const SizedBox(height: 8), + _MarkerTypeChip( + type: SarMarkerType.fire, + isSelected: _selectedType == SarMarkerType.fire, + onTap: () => setState(() => _selectedType = SarMarkerType.fire), + ), + const SizedBox(height: 8), + _MarkerTypeChip( + type: SarMarkerType.stagingArea, + isSelected: _selectedType == SarMarkerType.stagingArea, + onTap: () => setState(() => _selectedType = SarMarkerType.stagingArea), + ), + const SizedBox(height: 8), + _MarkerTypeChip( + type: SarMarkerType.object, + isSelected: _selectedType == SarMarkerType.object, + onTap: () => setState(() => _selectedType = SarMarkerType.object), + ), + const SizedBox(height: 24), + + // Location display + Text( + 'Current Location', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + if (_loadingLocation) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: const Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 16), + Text('Getting location...'), + ], + ), + ) + else if (_locationError != null) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.red.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 24), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Location Error', + style: TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + const SizedBox(height: 4), + Text( + _locationError!, + style: TextStyle( + color: Colors.red.shade700, + fontSize: 12, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.refresh, color: Colors.red), + onPressed: _getCurrentLocation, + tooltip: 'Retry', + ), + ], + ), + ) + else if (_currentPosition != null) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.location_on, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '${_currentPosition!.latitude.toStringAsFixed(5)}, ${_currentPosition!.longitude.toStringAsFixed(5)}', + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + IconButton( + icon: const Icon(Icons.refresh, size: 20), + onPressed: _getCurrentLocation, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Refresh location', + ), + ], + ), + if (_currentPosition!.accuracy != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.my_location, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text( + 'Accuracy: ±${_currentPosition!.accuracy!.round()}m', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ], + ], + ), + ), + const SizedBox(height: 24), + + // Optional notes + Text( + 'Notes (optional)', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _notesController, + maxLines: 3, + maxLength: 100, + decoration: InputDecoration( + hintText: 'Add additional information...', + hintStyle: const TextStyle(fontSize: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.all(16), + ), + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + // Bottom action buttons + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border( + top: BorderSide( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + ), + child: SafeArea( + top: false, + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: FilledButton.icon( + onPressed: _currentPosition == null + ? null + : () async { + await widget.onSend( + _selectedType, + _currentPosition!, + _notesController.text.trim().isEmpty + ? null + : _notesController.text.trim(), + ); + if (context.mounted) { + Navigator.pop(context); + } + }, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: const Icon(Icons.send, size: 20), + label: const Text('Send SAR Marker'), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +// Marker Type Chip widget +class _MarkerTypeChip extends StatelessWidget { + final SarMarkerType type; + final bool isSelected; + final VoidCallback onTap; + + const _MarkerTypeChip({ + required this.type, + required this.isSelected, + required this.onTap, + }); + + Color _getMarkerColor() { + switch (type) { + case SarMarkerType.foundPerson: + return Colors.green; + case SarMarkerType.fire: + return Colors.red; + case SarMarkerType.stagingArea: + return Colors.orange; + case SarMarkerType.object: + return Colors.purple; + case SarMarkerType.unknown: + return Colors.grey; + } + } + + @override + Widget build(BuildContext context) { + final color = _getMarkerColor(); + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected + ? color.withValues(alpha: 0.15) + : Colors.transparent, + border: Border.all( + color: isSelected ? color : Colors.grey.withValues(alpha: 0.3), + width: isSelected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: isSelected + ? color.withValues(alpha: 0.2) + : Colors.grey.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text( + type.emoji, + style: const TextStyle(fontSize: 28), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + type.displayName, + style: TextStyle( + fontSize: 16, + fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, + color: isSelected ? color : null, + ), + ), + ), + if (isSelected) + Icon( + Icons.check_circle, + color: color, + size: 24, + ) + else + Icon( + Icons.radio_button_unchecked, + color: Colors.grey.withValues(alpha: 0.4), + size: 24, + ), + ], + ), + ), + ); + } +} diff --git a/lib/utils/sample_data_generator.dart b/lib/utils/sample_data_generator.dart index eb37e33..61872c4 100644 --- a/lib/utils/sample_data_generator.dart +++ b/lib/utils/sample_data_generator.dart @@ -20,14 +20,14 @@ class SampleDataGenerator { final now = DateTime.now(); final teamNames = [ - 'Alpha Team Lead', - 'Bravo Scout', - 'Charlie Medic', - 'Delta Navigator', - 'Echo Support', - 'Foxtrot Runner', - 'Golf Comms', - 'Hotel Base', + '👮Police Lead', + '🚁Drone Operator', + '🧑🏻‍🚒Firefighter Alpha', + '🧑‍⚕️Medic Charlie', + '📡Command Delta', + '🚒Fire Engine', + '👨‍✈️Air Support', + '🧑‍💼Base Coordinator', ]; final channelNames = [ diff --git a/lib/widgets/map_markers.dart b/lib/widgets/map_markers.dart index f0a6cf5..ad79e2a 100644 --- a/lib/widgets/map_markers.dart +++ b/lib/widgets/map_markers.dart @@ -1,14 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:latlong2/latlong.dart'; import '../models/contact.dart'; import '../models/sar_marker.dart'; class MapMarkers { static List createTeamMemberMarkers( List contacts, - BuildContext context, - ) { + BuildContext context, { + Function(Contact)? onContactTap, + double mapRotation = 0, + }) { return contacts.map((contact) { final location = contact.displayLocation; if (location == null) return null; @@ -17,21 +18,29 @@ class MapMarkers { point: location, width: 80, height: 100, - child: GestureDetector( - onTap: () => _showContactInfo(context, contact), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Battery indicator - if (contact.displayBattery != null) + rotate: false, // Don't rotate the entire marker with map + child: Transform.rotate( + angle: -mapRotation * 3.14159265359 / 180, + child: GestureDetector( + onTap: () { + if (onContactTap != null) { + onContactTap(contact); + } else { + _showContactInfo(context, contact); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Location update time indicator Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), decoration: BoxDecoration( - color: _getBatteryColor(contact.displayBattery!), + color: _getLocationAgeColor(contact), borderRadius: BorderRadius.circular(3), ), child: Text( - '${contact.displayBattery!.round()}%', + contact.timeSinceLocationUpdate, style: const TextStyle( color: Colors.white, fontSize: 9, @@ -39,50 +48,51 @@ class MapMarkers { ), ), ), - if (contact.displayBattery != null) const SizedBox(height: 2), - // Marker icon - Container( - decoration: BoxDecoration( - color: Colors.blue, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.3), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - padding: const EdgeInsets.all(6), - child: const Icon( - Icons.person, - color: Colors.white, - size: 18, - ), - ), - const SizedBox(height: 2), - // Name label - Container( - constraints: const BoxConstraints(maxWidth: 80), - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.7), - borderRadius: BorderRadius.circular(3), - ), - child: Text( - contact.advName, - style: const TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.bold, + const SizedBox(height: 2), + // Marker icon + Container( + decoration: BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(6), + child: const Icon( + Icons.person, + color: Colors.white, + size: 18, ), - overflow: TextOverflow.ellipsis, - maxLines: 1, - textAlign: TextAlign.center, ), - ), - ], + const SizedBox(height: 2), + // Name label (without emoji) + Container( + constraints: const BoxConstraints(maxWidth: 80), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + contact.displayName, + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center, + ), + ), + ], + ), ), ), ); @@ -91,77 +101,89 @@ class MapMarkers { static List createSarMarkers( List sarMarkers, - BuildContext context, - ) { + BuildContext context, { + Function(SarMarker)? onSarMarkerTap, + double mapRotation = 0, + }) { return sarMarkers.map((marker) { return Marker( point: marker.location, width: 90, height: 100, - child: GestureDetector( - onTap: () => _showSarMarkerInfo(context, marker), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Time ago label - Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: _getSarMarkerColor(marker.type), - borderRadius: BorderRadius.circular(3), - ), - child: Text( - marker.timeAgo, - style: const TextStyle( - color: Colors.white, - fontSize: 8, - fontWeight: FontWeight.bold, + rotate: false, // Don't rotate the entire marker with map + child: Transform.rotate( + angle: -mapRotation * 3.14159265359 / 180, + child: GestureDetector( + onTap: () { + if (onSarMarkerTap != null) { + onSarMarkerTap(marker); + } else { + _showSarMarkerInfo(context, marker); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Time ago label + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: _getSarMarkerColor(marker.type), + borderRadius: BorderRadius.circular(3), ), - ), - ), - const SizedBox(height: 2), - // Marker emoji/icon - Container( - decoration: BoxDecoration( - color: _getSarMarkerColor(marker.type), - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.3), - blurRadius: 4, - offset: const Offset(0, 2), + child: Text( + marker.timeAgo, + style: const TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.bold, ), - ], - ), - padding: const EdgeInsets.all(6), - child: Text( - marker.type.emoji, - style: const TextStyle(fontSize: 18), - ), - ), - const SizedBox(height: 2), - // Type label - Container( - constraints: const BoxConstraints(maxWidth: 90), - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.7), - borderRadius: BorderRadius.circular(3), - ), - child: Text( - marker.type.displayName, - style: const TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.bold, ), - overflow: TextOverflow.ellipsis, - maxLines: 1, - textAlign: TextAlign.center, ), - ), - ], + const SizedBox(height: 2), + // Marker emoji/icon + Container( + decoration: BoxDecoration( + color: _getSarMarkerColor(marker.type), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(6), + child: Text( + marker.type.emoji, + style: const TextStyle(fontSize: 18), + ), + ), + const SizedBox(height: 2), + // Type label + Container( + constraints: const BoxConstraints(maxWidth: 90), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + marker.type.displayName, + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center, + ), + ), + ], + ), ), ), ); @@ -169,6 +191,11 @@ class MapMarkers { } static void _showContactInfo(BuildContext context, Contact contact) { + // Import provider to get all contacts and SAR markers for detailed view + // This will be handled by importing the screen's detailed compass dialog + // Since we can't directly access _DetailedCompassDialog from here, + // we'll pass a callback to the screen + // For now, show the simple dialog as a fallback showDialog( context: context, builder: (context) => AlertDialog( @@ -176,7 +203,7 @@ class MapMarkers { children: [ const Icon(Icons.person, color: Colors.blue), const SizedBox(width: 8), - Expanded(child: Text(contact.advName)), + Expanded(child: Text(contact.displayName)), ], ), content: Column( @@ -242,10 +269,15 @@ class MapMarkers { ); } - static Color _getBatteryColor(double percentage) { - if (percentage > 50) return Colors.green; - if (percentage > 20) return Colors.orange; - return Colors.red; + static Color _getLocationAgeColor(Contact contact) { + final updateTime = contact.locationUpdateTime; + if (updateTime == null) return Colors.grey; + + final diff = DateTime.now().difference(updateTime); + if (diff.inMinutes < 5) return Colors.green; // Very recent + if (diff.inMinutes < 30) return Colors.blue; // Recent + if (diff.inHours < 2) return Colors.orange; // Getting old + return Colors.red; // Stale } static Color _getSarMarkerColor(SarMarkerType type) { @@ -256,6 +288,8 @@ class MapMarkers { return Colors.red; case SarMarkerType.stagingArea: return Colors.orange; + case SarMarkerType.object: + return Colors.purple; case SarMarkerType.unknown: return Colors.grey; }