diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index 356b5dd..50ddd4a 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -507,12 +507,21 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } void _showDetailedCompass(BuildContext context, List contacts) { - showDialog( + showModalBottomSheet( context: context, - builder: (context) => _DetailedCompassDialog( - initialPosition: _currentPosition, - initialHeading: _currentHeading, - contacts: contacts, + 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, + ), ), ); } @@ -1036,34 +1045,56 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { Widget build(BuildContext context) { final heading = currentHeading; final position = _currentPosition; - return Dialog( - backgroundColor: Colors.transparent, - child: GestureDetector( - onTap: () => Navigator.pop(context), - child: Container( + return Column( + children: [ + // Header with back button + Container( padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(16), - ), - child: Column( - mainAxisSize: MainAxisSize.min, + child: Row( 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 - Stack( - alignment: Alignment.center, + 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, + ), + ), + ], + ), + ), + const SizedBox(width: 48), // Balance for back button + ], + ), + ), + Expanded( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - // Compass with gesture detection + // 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) { - // Prevent dialog from closing during zoom gesture - }, onScaleUpdate: (details) { setState(() { _zoomLevel = (_zoomLevel * details.scale).clamp(_minZoom, _maxZoom); @@ -1081,15 +1112,15 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { ), ), ), + const SizedBox(height: 12), + // Contacts list + if (widget.contacts.isNotEmpty) _buildContactsList(context, heading, position), ], ), - const SizedBox(height: 12), - // Contacts list - if (widget.contacts.isNotEmpty) _buildContactsList(context, heading, position), - ], + ), ), ), - ), + ], ); } @@ -1196,38 +1227,51 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { contactsWithBearing.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double)); - return Container( - constraints: const BoxConstraints(maxHeight: 150), - child: ListView.builder( - shrinkWrap: true, - itemCount: contactsWithBearing.length, - itemBuilder: (context, index) { - final item = contactsWithBearing[index]; + 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 ListTile( - dense: true, - leading: Icon( - Icons.person, - color: Colors.blue, - size: 20, + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), ), - title: Text(contact.advName), - 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, - ), + child: ListTile( + dense: true, + leading: const Icon( + Icons.person, + color: Colors.blue, + size: 24, + ), + title: Text(contact.advName), + 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, + ), + ), ), ); - }, - ), + }), + ], ); } diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 71d74fb..582c534 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -1,68 +1,431 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.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 '../utils/sar_message_parser.dart'; +import '../models/contact.dart'; -class MessagesTab extends StatelessWidget { +class MessagesTab extends StatefulWidget { final VoidCallback onNavigateToMap; const MessagesTab({super.key, required this.onNavigateToMap}); + @override + State createState() => _MessagesTabState(); +} + +class _MessagesTabState extends State { + final TextEditingController _textController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + Contact? _selectedContact; + 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 _sendMessage() 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; + } + + 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] + : 0; + + await connectionProvider.sendChannelMessage( + channelIdx: channelIdx, + text: text, + ); + } else { + // For direct contacts (chat type) + await connectionProvider.sendTextMessage( + contactPublicKey: _selectedContact!.publicKey, + text: text, + ); + } + + _textController.clear(); + _focusNode.unfocus(); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Message sent'), + backgroundColor: Colors.green, + duration: Duration(seconds: 1), + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to send: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + + void _showContactSelector() { + final contactsProvider = context.read(); + final allContacts = contactsProvider.contacts; + + if (allContacts.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No contacts available'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + // Group contacts by type + final chatContacts = contactsProvider.chatContacts; + final rooms = contactsProvider.rooms; + final repeaters = contactsProvider.repeaters; + + 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, + ), + ), + 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, + )), + ], + + // 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), + )), + ], + ], + ), + ), + ], + ), + ); + } + + 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) { return Consumer( builder: (context, messagesProvider, child) { final messages = messagesProvider.getRecentMessages(count: 100); - if (messages.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.message_outlined, - size: 64, - color: Theme.of(context).disabledColor, - ), - const SizedBox(height: 16), - Text( - 'No messages yet', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - 'Connect to a device to start receiving messages', - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - ], + return Column( + children: [ + // Messages list + Expanded( + child: messages.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.message_outlined, + size: 64, + color: Theme.of(context).disabledColor, + ), + const SizedBox(height: 16), + Text( + 'No messages yet', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Connect to a device to start receiving messages', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ) + : ListView.builder( + reverse: true, + padding: const EdgeInsets.all(8), + itemCount: messages.length, + itemBuilder: (context, index) { + final message = messages[index]; + return _MessageBubble( + message: message, + onTap: message.isSarMarker && + message.sarGpsCoordinates != null + ? () { + final mapProvider = + context.read(); + mapProvider.navigateToLocation( + location: message.sarGpsCoordinates!, + zoom: 15.0, + ); + widget.onNavigateToMap(); + } + : null, + ); + }, + ), ), - ); - } - return ListView.builder( - reverse: true, - padding: const EdgeInsets.all(8), - itemCount: messages.length, - itemBuilder: (context, index) { - final message = messages[index]; - return _MessageBubble( - message: message, - onTap: message.isSarMarker && message.sarGpsCoordinates != null - ? () { - final mapProvider = context.read(); - mapProvider.navigateToLocation( - location: message.sarGpsCoordinates!, - zoom: 15.0, - ); - onNavigateToMap(); - } - : null, - ); - }, + // Message input area + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border( + top: BorderSide( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + ), + padding: const EdgeInsets.fromLTRB(4, 4, 4, 4), + child: Column( + mainAxisSize: MainAxisSize.min, + 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), + ), + ), + ], + ), + ), + + // 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, + ), + ), + + // 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, + ), + ), + textInputAction: TextInputAction.send, + onSubmitted: (_) => _sendMessage(), + ), + ), + + // 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, + ), + ), + ], + ), + ], + ), + ), + ], ); }, ); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 75d0f96..dc4bffa 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,6 +1,12 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:provider/provider.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; +import '../providers/contacts_provider.dart'; +import '../providers/messages_provider.dart'; +import '../utils/sample_data_generator.dart'; class SettingsScreen extends StatefulWidget { final Function(ThemeMode) onThemeChanged; @@ -19,6 +25,7 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { late ThemeMode _selectedTheme; PackageInfo? _packageInfo; + bool _isLoadingSampleData = false; @override void initState() { @@ -51,6 +58,116 @@ class _SettingsScreenState extends State { } } + Future _loadSampleData() async { + setState(() => _isLoadingSampleData = true); + + try { + // Get current location or use default + LatLng centerLocation; + try { + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.best, + timeLimit: Duration(seconds: 5), + ), + ); + centerLocation = LatLng(position.latitude, position.longitude); + } catch (e) { + // Default to Ljubljana, Slovenia if location unavailable + centerLocation = const LatLng(46.0569, 14.5058); + } + + if (!mounted) return; + + // Generate sample data + final contacts = SampleDataGenerator.generateContacts( + centerLocation: centerLocation, + teamMemberCount: 5, + channelCount: 2, + ); + + final sarMessages = SampleDataGenerator.generateSarMarkerMessages( + centerLocation: centerLocation, + foundPersonCount: 2, + fireCount: 1, + stagingCount: 1, + ); + + // Add to providers + final contactsProvider = Provider.of(context, listen: false); + final messagesProvider = Provider.of(context, listen: false); + + contactsProvider.addContacts(contacts); + messagesProvider.addMessages(sarMessages); + + if (!mounted) return; + + final teamCount = contacts.where((c) => c.isChat).length; + final channelCount = contacts.where((c) => c.isRoom).length; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Loaded $teamCount team members, $channelCount channels, ${sarMessages.length} SAR markers', + ), + backgroundColor: Colors.green, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to load sample data: $e'), + backgroundColor: Colors.red, + ), + ); + } finally { + if (mounted) { + setState(() => _isLoadingSampleData = false); + } + } + } + + Future _clearSampleData() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear All Data'), + content: const Text( + 'This will clear all contacts and SAR markers. Are you sure?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Clear'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + final contactsProvider = Provider.of(context, listen: false); + final messagesProvider = Provider.of(context, listen: false); + + contactsProvider.clearContacts(); + messagesProvider.clearAll(); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('All data cleared'), + backgroundColor: Colors.orange, + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -103,6 +220,54 @@ class _SettingsScreenState extends State { title: const Text('Package Name'), subtitle: Text(_packageInfo?.packageName ?? 'com.meshcore.sar'), ), + const Divider(), + + // Sample Data Section + _buildSectionHeader('Sample Data'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + 'Load or clear sample contacts and SAR markers for testing', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: _isLoadingSampleData ? null : _loadSampleData, + icon: _isLoadingSampleData + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.add_circle_outline), + label: const Text('Load Sample Data'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: _isLoadingSampleData ? null : _clearSampleData, + icon: const Icon(Icons.delete_outline), + label: const Text('Clear All Data'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ), ], ), ); diff --git a/lib/utils/sample_data_generator.dart b/lib/utils/sample_data_generator.dart new file mode 100644 index 0000000..14b3158 --- /dev/null +++ b/lib/utils/sample_data_generator.dart @@ -0,0 +1,214 @@ +import 'dart:typed_data'; +import 'dart:math'; +import 'package:latlong2/latlong.dart'; +import '../models/contact.dart'; +import '../models/contact_telemetry.dart'; +import '../models/message.dart'; +import '../models/sar_marker.dart'; + +/// Generates sample data for testing/demo purposes +class SampleDataGenerator { + static final Random _random = Random(); + + /// Generate sample contacts around a center location + static List generateContacts({ + required LatLng centerLocation, + int teamMemberCount = 5, + int channelCount = 2, + }) { + final contacts = []; + final now = DateTime.now(); + + final teamNames = [ + 'Alpha Team Lead', + 'Bravo Scout', + 'Charlie Medic', + 'Delta Navigator', + 'Echo Support', + 'Foxtrot Runner', + 'Golf Comms', + 'Hotel Base', + ]; + + final channelNames = [ + 'General', + 'Emergency', + 'Coordination', + 'Updates', + ]; + + // Generate team members (chat contacts) + for (int i = 0; i < teamMemberCount && i < teamNames.length; i++) { + // Generate location within ~1km radius + final latOffset = (_random.nextDouble() - 0.5) * 0.02; // ~1km + final lonOffset = (_random.nextDouble() - 0.5) * 0.02; + final lat = centerLocation.latitude + latOffset; + final lon = centerLocation.longitude + lonOffset; + + // Generate random public key + final publicKey = Uint8List.fromList( + List.generate(32, (_) => _random.nextInt(256)), + ); + + // Random battery 20-100% + final battery = 20 + _random.nextInt(81); + + // Random temperature 15-35°C + final temp = 15.0 + _random.nextDouble() * 20.0; + + final telemetry = ContactTelemetry( + gpsLocation: LatLng(lat, lon), + batteryPercentage: battery.toDouble(), + batteryMilliVolts: 3000.0 + (battery / 100.0) * 1200.0, + temperature: temp, + timestamp: now.subtract(Duration(minutes: _random.nextInt(10))), + ); + + final contact = Contact( + publicKey: publicKey, + type: ContactType.chat, + flags: 0, + outPathLen: 1, + outPath: Uint8List(32), + advName: teamNames[i], + lastAdvert: now.millisecondsSinceEpoch ~/ 1000, + advLat: (lat * 1e7).toInt(), + advLon: (lon * 1e7).toInt(), + lastMod: now.millisecondsSinceEpoch ~/ 1000, + telemetry: telemetry, + ); + + contacts.add(contact); + } + + // Generate channels/rooms + for (int i = 0; i < channelCount && i < channelNames.length; i++) { + // Generate random public key + final publicKey = Uint8List.fromList( + List.generate(32, (_) => _random.nextInt(256)), + ); + + // Channel index stored in outPath[0] + final outPath = Uint8List(32); + outPath[0] = i; // Channel index + + final channel = Contact( + publicKey: publicKey, + type: ContactType.room, + flags: 0, + outPathLen: 1, + outPath: outPath, + advName: channelNames[i], + lastAdvert: now.millisecondsSinceEpoch ~/ 1000, + advLat: 0, // Channels don't have location + advLon: 0, + lastMod: now.millisecondsSinceEpoch ~/ 1000, + ); + + contacts.add(channel); + } + + return contacts; + } + + /// Generate sample SAR markers around a center location + static List generateSarMarkerMessages({ + required LatLng centerLocation, + int foundPersonCount = 2, + int fireCount = 1, + int stagingCount = 1, + }) { + final messages = []; + final now = DateTime.now(); + int messageId = 1; + + // Generate found person markers + for (int i = 0; i < foundPersonCount; i++) { + final latOffset = (_random.nextDouble() - 0.5) * 0.015; + final lonOffset = (_random.nextDouble() - 0.5) * 0.015; + final lat = centerLocation.latitude + latOffset; + final lon = centerLocation.longitude + lonOffset; + + final senderKey = Uint8List.fromList( + List.generate(32, (_) => _random.nextInt(256)), + ); + + final timestamp = now.subtract(Duration(minutes: 10 + i * 5)); + messages.add(Message( + id: 'sample_fp_$messageId', + messageType: MessageType.contact, + senderPublicKeyPrefix: senderKey.sublist(0, 6), + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000, + text: 'S:🧑:${lat.toStringAsFixed(4)},${lon.toStringAsFixed(4)}', + receivedAt: timestamp, + isSarMarker: true, + sarMarkerType: SarMarkerType.foundPerson, + sarGpsCoordinates: LatLng(lat, lon), + senderName: 'Sample Team Member', + )); + messageId++; + } + + // Generate fire markers + for (int i = 0; i < fireCount; i++) { + final latOffset = (_random.nextDouble() - 0.5) * 0.015; + final lonOffset = (_random.nextDouble() - 0.5) * 0.015; + final lat = centerLocation.latitude + latOffset; + final lon = centerLocation.longitude + lonOffset; + + final senderKey = Uint8List.fromList( + List.generate(32, (_) => _random.nextInt(256)), + ); + + final timestamp = now.subtract(Duration(minutes: 20 + i * 5)); + messages.add(Message( + id: 'sample_fire_$messageId', + messageType: MessageType.contact, + senderPublicKeyPrefix: senderKey.sublist(0, 6), + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000, + text: 'S:🔥:${lat.toStringAsFixed(4)},${lon.toStringAsFixed(4)}', + receivedAt: timestamp, + isSarMarker: true, + sarMarkerType: SarMarkerType.fire, + sarGpsCoordinates: LatLng(lat, lon), + senderName: 'Sample Scout', + )); + messageId++; + } + + // Generate staging area markers + for (int i = 0; i < stagingCount; i++) { + final latOffset = (_random.nextDouble() - 0.5) * 0.015; + final lonOffset = (_random.nextDouble() - 0.5) * 0.015; + final lat = centerLocation.latitude + latOffset; + final lon = centerLocation.longitude + lonOffset; + + final senderKey = Uint8List.fromList( + List.generate(32, (_) => _random.nextInt(256)), + ); + + final timestamp = now.subtract(Duration(minutes: 30 + i * 5)); + messages.add(Message( + id: 'sample_staging_$messageId', + messageType: MessageType.contact, + senderPublicKeyPrefix: senderKey.sublist(0, 6), + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000, + text: 'S:🏕️:${lat.toStringAsFixed(4)},${lon.toStringAsFixed(4)}', + receivedAt: timestamp, + isSarMarker: true, + sarMarkerType: SarMarkerType.stagingArea, + sarGpsCoordinates: LatLng(lat, lon), + senderName: 'Sample Base', + )); + messageId++; + } + + return messages; + } +} diff --git a/lib/widgets/map_markers.dart b/lib/widgets/map_markers.dart index 4dcc2b1..f0a6cf5 100644 --- a/lib/widgets/map_markers.dart +++ b/lib/widgets/map_markers.dart @@ -15,8 +15,8 @@ class MapMarkers { return Marker( point: location, - width: 60, - height: 80, + width: 80, + height: 100, child: GestureDetector( onTap: () => _showContactInfo(context, contact), child: Column( @@ -25,27 +25,27 @@ class MapMarkers { // Battery indicator if (contact.displayBattery != null) Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), decoration: BoxDecoration( color: _getBatteryColor(contact.displayBattery!), - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(3), ), child: Text( '${contact.displayBattery!.round()}%', style: const TextStyle( color: Colors.white, - fontSize: 10, + fontSize: 9, fontWeight: FontWeight.bold, ), ), ), - const SizedBox(height: 2), + 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: 3), + border: Border.all(color: Colors.white, width: 2), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.3), @@ -54,29 +54,32 @@ class MapMarkers { ), ], ), - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(6), child: const Icon( Icons.person, color: Colors.white, - size: 20, + size: 18, ), ), + const SizedBox(height: 2), // Name label Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + constraints: const BoxConstraints(maxWidth: 80), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), decoration: BoxDecoration( color: Colors.black.withOpacity(0.7), - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(3), ), child: Text( contact.advName, style: const TextStyle( color: Colors.white, - fontSize: 10, + fontSize: 9, fontWeight: FontWeight.bold, ), overflow: TextOverflow.ellipsis, maxLines: 1, + textAlign: TextAlign.center, ), ), ], @@ -93,8 +96,8 @@ class MapMarkers { return sarMarkers.map((marker) { return Marker( point: marker.location, - width: 60, - height: 80, + width: 90, + height: 100, child: GestureDetector( onTap: () => _showSarMarkerInfo(context, marker), child: Column( @@ -102,16 +105,16 @@ class MapMarkers { children: [ // Time ago label Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), decoration: BoxDecoration( color: _getSarMarkerColor(marker.type), - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(3), ), child: Text( marker.timeAgo, style: const TextStyle( color: Colors.white, - fontSize: 9, + fontSize: 8, fontWeight: FontWeight.bold, ), ), @@ -122,7 +125,7 @@ class MapMarkers { decoration: BoxDecoration( color: _getSarMarkerColor(marker.type), shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 3), + border: Border.all(color: Colors.white, width: 2), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.3), @@ -131,28 +134,31 @@ class MapMarkers { ), ], ), - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(6), child: Text( marker.type.emoji, - style: const TextStyle(fontSize: 20), + style: const TextStyle(fontSize: 18), ), ), + const SizedBox(height: 2), // Type label Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + constraints: const BoxConstraints(maxWidth: 90), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), decoration: BoxDecoration( color: Colors.black.withOpacity(0.7), - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(3), ), child: Text( marker.type.displayName, style: const TextStyle( color: Colors.white, - fontSize: 10, + fontSize: 9, fontWeight: FontWeight.bold, ), overflow: TextOverflow.ellipsis, maxLines: 1, + textAlign: TextAlign.center, ), ), ],