Refactor map tab and messages tab UI; enhance settings screen with sample data loading and clearing functionality; improve map markers display

This commit is contained in:
Janez T
2025-10-14 00:15:45 +02:00
parent 76b4f18564
commit c2e882bb2b
5 changed files with 920 additions and 128 deletions

View File

@@ -507,12 +507,21 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
} }
void _showDetailedCompass(BuildContext context, List<Contact> contacts) { void _showDetailedCompass(BuildContext context, List<Contact> contacts) {
showDialog( showModalBottomSheet(
context: context, context: context,
builder: (context) => _DetailedCompassDialog( isScrollControlled: true,
initialPosition: _currentPosition, backgroundColor: Colors.transparent,
initialHeading: _currentHeading, builder: (context) => Container(
contacts: contacts, 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) { Widget build(BuildContext context) {
final heading = currentHeading; final heading = currentHeading;
final position = _currentPosition; final position = _currentPosition;
return Dialog( return Column(
backgroundColor: Colors.transparent, children: [
child: GestureDetector( // Header with back button
onTap: () => Navigator.pop(context), Container(
child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( child: Row(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
// Heading and Elevation info IconButton(
_buildInfoRow(context, heading, position), icon: const Icon(Icons.arrow_back),
const SizedBox(height: 12), onPressed: () => Navigator.pop(context),
// Current location in multiple formats ),
if (position != null) _buildLocationFormats(context, position), const Expanded(
const SizedBox(height: 12), child: Column(
// Large compass with zoom controls children: [
Stack( Text(
alignment: Alignment.center, '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: [ 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( GestureDetector(
onScaleStart: (details) {
// Prevent dialog from closing during zoom gesture
},
onScaleUpdate: (details) { onScaleUpdate: (details) {
setState(() { setState(() {
_zoomLevel = (_zoomLevel * details.scale).clamp(_minZoom, _maxZoom); _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) => contactsWithBearing.sort((a, b) =>
(a['distance'] as double).compareTo(b['distance'] as double)); (a['distance'] as double).compareTo(b['distance'] as double));
return Container( return Column(
constraints: const BoxConstraints(maxHeight: 150), crossAxisAlignment: CrossAxisAlignment.start,
child: ListView.builder( children: [
shrinkWrap: true, Padding(
itemCount: contactsWithBearing.length, padding: const EdgeInsets.only(left: 16, bottom: 8),
itemBuilder: (context, index) { child: Text(
final item = contactsWithBearing[index]; 'Nearby Contacts',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
...contactsWithBearing.map((item) {
final contact = item['contact'] as Contact; final contact = item['contact'] as Contact;
final bearing = item['bearing'] as double; final bearing = item['bearing'] as double;
final distance = item['distance'] as double; final distance = item['distance'] as double;
return ListTile( return Container(
dense: true, margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
leading: Icon( decoration: BoxDecoration(
Icons.person, color: Theme.of(context).colorScheme.surfaceContainerHighest,
color: Colors.blue, borderRadius: BorderRadius.circular(8),
size: 20,
), ),
title: Text(contact.advName), child: ListTile(
subtitle: Text( dense: true,
'${_bearingToCardinal(bearing)}${_formatDistance(distance)}', leading: const Icon(
style: Theme.of(context).textTheme.bodySmall, Icons.person,
), color: Colors.blue,
trailing: Text( size: 24,
'${bearing.round()}°', ),
style: Theme.of(context).textTheme.bodySmall?.copyWith( title: Text(contact.advName),
fontWeight: FontWeight.bold, 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,
),
),
), ),
); );
}, }),
), ],
); );
} }

View File

@@ -1,68 +1,431 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/messages_provider.dart'; import '../providers/messages_provider.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../providers/map_provider.dart'; import '../providers/map_provider.dart';
import '../providers/connection_provider.dart';
import '../models/message.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; final VoidCallback onNavigateToMap;
const MessagesTab({super.key, required this.onNavigateToMap}); const MessagesTab({super.key, required this.onNavigateToMap});
@override
State<MessagesTab> createState() => _MessagesTabState();
}
class _MessagesTabState extends State<MessagesTab> {
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<void> _sendMessage() async {
final text = _textController.text.trim();
if (text.isEmpty) return;
final connectionProvider = context.read<ConnectionProvider>();
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<ContactsProvider>();
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<MessagesProvider>( return Consumer<MessagesProvider>(
builder: (context, messagesProvider, child) { builder: (context, messagesProvider, child) {
final messages = messagesProvider.getRecentMessages(count: 100); final messages = messagesProvider.getRecentMessages(count: 100);
if (messages.isEmpty) { return Column(
return Center( children: [
child: Column( // Messages list
mainAxisAlignment: MainAxisAlignment.center, Expanded(
children: [ child: messages.isEmpty
Icon( ? Center(
Icons.message_outlined, child: Column(
size: 64, mainAxisAlignment: MainAxisAlignment.center,
color: Theme.of(context).disabledColor, children: [
), Icon(
const SizedBox(height: 16), Icons.message_outlined,
Text( size: 64,
'No messages yet', color: Theme.of(context).disabledColor,
style: Theme.of(context).textTheme.titleLarge, ),
), const SizedBox(height: 16),
const SizedBox(height: 8), Text(
Text( 'No messages yet',
'Connect to a device to start receiving messages', style: Theme.of(context).textTheme.titleLarge,
style: Theme.of(context).textTheme.bodyMedium, ),
textAlign: TextAlign.center, 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>();
mapProvider.navigateToLocation(
location: message.sarGpsCoordinates!,
zoom: 15.0,
);
widget.onNavigateToMap();
}
: null,
);
},
),
), ),
);
}
return ListView.builder( // Message input area
reverse: true, Container(
padding: const EdgeInsets.all(8), decoration: BoxDecoration(
itemCount: messages.length, color: Theme.of(context).colorScheme.surface,
itemBuilder: (context, index) { border: Border(
final message = messages[index]; top: BorderSide(
return _MessageBubble( color: Theme.of(context).dividerColor,
message: message, width: 1,
onTap: message.isSarMarker && message.sarGpsCoordinates != null ),
? () { ),
final mapProvider = context.read<MapProvider>(); ),
mapProvider.navigateToLocation( padding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
location: message.sarGpsCoordinates!, child: Column(
zoom: 15.0, mainAxisSize: MainAxisSize.min,
); children: [
onNavigateToMap(); // Contact selector (compact)
} if (_selectedContact != null)
: 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,
),
),
],
),
],
),
),
],
); );
}, },
); );

View File

@@ -1,6 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:package_info_plus/package_info_plus.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 { class SettingsScreen extends StatefulWidget {
final Function(ThemeMode) onThemeChanged; final Function(ThemeMode) onThemeChanged;
@@ -19,6 +25,7 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
late ThemeMode _selectedTheme; late ThemeMode _selectedTheme;
PackageInfo? _packageInfo; PackageInfo? _packageInfo;
bool _isLoadingSampleData = false;
@override @override
void initState() { void initState() {
@@ -51,6 +58,116 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
} }
Future<void> _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<ContactsProvider>(context, listen: false);
final messagesProvider = Provider.of<MessagesProvider>(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<void> _clearSampleData() async {
final confirmed = await showDialog<bool>(
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<ContactsProvider>(context, listen: false);
final messagesProvider = Provider.of<MessagesProvider>(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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -103,6 +220,54 @@ class _SettingsScreenState extends State<SettingsScreen> {
title: const Text('Package Name'), title: const Text('Package Name'),
subtitle: Text(_packageInfo?.packageName ?? 'com.meshcore.sar'), 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),
),
),
),
],
),
),
], ],
), ),
); );

View File

@@ -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<Contact> generateContacts({
required LatLng centerLocation,
int teamMemberCount = 5,
int channelCount = 2,
}) {
final contacts = <Contact>[];
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<Message> generateSarMarkerMessages({
required LatLng centerLocation,
int foundPersonCount = 2,
int fireCount = 1,
int stagingCount = 1,
}) {
final messages = <Message>[];
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;
}
}

View File

@@ -15,8 +15,8 @@ class MapMarkers {
return Marker( return Marker(
point: location, point: location,
width: 60, width: 80,
height: 80, height: 100,
child: GestureDetector( child: GestureDetector(
onTap: () => _showContactInfo(context, contact), onTap: () => _showContactInfo(context, contact),
child: Column( child: Column(
@@ -25,27 +25,27 @@ class MapMarkers {
// Battery indicator // Battery indicator
if (contact.displayBattery != null) if (contact.displayBattery != null)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getBatteryColor(contact.displayBattery!), color: _getBatteryColor(contact.displayBattery!),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(3),
), ),
child: Text( child: Text(
'${contact.displayBattery!.round()}%', '${contact.displayBattery!.round()}%',
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 10, fontSize: 9,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
), ),
const SizedBox(height: 2), if (contact.displayBattery != null) const SizedBox(height: 2),
// Marker icon // Marker icon
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blue, color: Colors.blue,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3), border: Border.all(color: Colors.white, width: 2),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.3), 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( child: const Icon(
Icons.person, Icons.person,
color: Colors.white, color: Colors.white,
size: 20, size: 18,
), ),
), ),
const SizedBox(height: 2),
// Name label // Name label
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), constraints: const BoxConstraints(maxWidth: 80),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7), color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(3),
), ),
child: Text( child: Text(
contact.advName, contact.advName,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 10, fontSize: 9,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
maxLines: 1, maxLines: 1,
textAlign: TextAlign.center,
), ),
), ),
], ],
@@ -93,8 +96,8 @@ class MapMarkers {
return sarMarkers.map((marker) { return sarMarkers.map((marker) {
return Marker( return Marker(
point: marker.location, point: marker.location,
width: 60, width: 90,
height: 80, height: 100,
child: GestureDetector( child: GestureDetector(
onTap: () => _showSarMarkerInfo(context, marker), onTap: () => _showSarMarkerInfo(context, marker),
child: Column( child: Column(
@@ -102,16 +105,16 @@ class MapMarkers {
children: [ children: [
// Time ago label // Time ago label
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getSarMarkerColor(marker.type), color: _getSarMarkerColor(marker.type),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(3),
), ),
child: Text( child: Text(
marker.timeAgo, marker.timeAgo,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 9, fontSize: 8,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -122,7 +125,7 @@ class MapMarkers {
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getSarMarkerColor(marker.type), color: _getSarMarkerColor(marker.type),
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3), border: Border.all(color: Colors.white, width: 2),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.3), color: Colors.black.withOpacity(0.3),
@@ -131,28 +134,31 @@ class MapMarkers {
), ),
], ],
), ),
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(6),
child: Text( child: Text(
marker.type.emoji, marker.type.emoji,
style: const TextStyle(fontSize: 20), style: const TextStyle(fontSize: 18),
), ),
), ),
const SizedBox(height: 2),
// Type label // Type label
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), constraints: const BoxConstraints(maxWidth: 90),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7), color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(3),
), ),
child: Text( child: Text(
marker.type.displayName, marker.type.displayName,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 10, fontSize: 9,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
maxLines: 1, maxLines: 1,
textAlign: TextAlign.center,
), ),
), ),
], ],