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) {
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,
),
),
),
);
},
),
}),
],
);
}

View File

@@ -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<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
Widget build(BuildContext context) {
return Consumer<MessagesProvider>(
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>();
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>();
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,
),
),
],
),
],
),
),
],
);
},
);

View File

@@ -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<SettingsScreen> {
late ThemeMode _selectedTheme;
PackageInfo? _packageInfo;
bool _isLoadingSampleData = false;
@override
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
Widget build(BuildContext context) {
return Scaffold(
@@ -103,6 +220,54 @@ class _SettingsScreenState extends State<SettingsScreen> {
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),
),
),
),
],
),
),
],
),
);

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(
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,
),
),
],