feat: Enhance messaging and SAR marker functionalities

- Integrated SAR marker sending feature in MessagesTab with a dedicated dialog for user input.
- Updated message sending logic to default to the first available room if no contact is selected.
- Improved user feedback with SnackBars for connection issues and successful marker sends.
- Refactored contact selection to remove unused code and streamline the process.
- Added location fetching capabilities using Geolocator for SAR markers.
- Updated sample data with more relevant team names using emojis.
- Enhanced map markers to include rotation handling and improved display of contact information.
- Introduced color coding for location update age in map markers for better visibility.
This commit is contained in:
Janez T
2025-10-14 01:02:20 +02:00
parent 4ed0991193
commit e2fc9bcccf
9 changed files with 1416 additions and 436 deletions

View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(flutter analyze:*)"
],
"deny": [],
"ask": []
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:flutter/material.dart';
import 'contact_telemetry.dart'; import 'contact_telemetry.dart';
/// MeshCore contact types /// MeshCore contact types
@@ -129,6 +130,67 @@ class Contact {
return '${diff.inDays}d ago'; return '${diff.inDays}d ago';
} }
/// Get time when location was last updated
DateTime? get locationUpdateTime {
// Prefer telemetry timestamp if available
if (telemetry?.gpsLocation != null) {
return telemetry!.timestamp;
}
// Fall back to lastAdvert time if using advertised location
if (advertLocation != null) {
return lastSeenTime;
}
return null;
}
/// Get friendly time since location was last updated
String get timeSinceLocationUpdate {
final updateTime = locationUpdateTime;
if (updateTime == null) return 'Unknown';
final diff = DateTime.now().difference(updateTime);
if (diff.inMinutes < 1) return 'Now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m';
if (diff.inHours < 24) return '${diff.inHours}h';
return '${diff.inDays}d';
}
/// Extract role emoji from name (e.g., "🧑🏻🚒Janez" → "🧑🏻‍🚒")
/// Returns null if no emoji at start of name
String? get roleEmoji {
if (advName.isEmpty) return null;
// Get the first character/grapheme cluster (which could be a complex emoji)
final firstChar = advName.characters.first;
// Check if it's an emoji (basic check - emojis are typically in certain Unicode ranges)
final firstCodeUnit = firstChar.runes.first;
// Emoji ranges (simplified check):
// 0x1F300-0x1F9FF: Misc Symbols and Pictographs, Emoticons, Transport, etc.
// 0x2600-0x26FF: Misc symbols
// 0x2700-0x27BF: Dingbats
// 0xFE00-0xFE0F: Variation Selectors
// 0x1F900-0x1F9FF: Supplemental Symbols and Pictographs
if ((firstCodeUnit >= 0x1F300 && firstCodeUnit <= 0x1F9FF) ||
(firstCodeUnit >= 0x2600 && firstCodeUnit <= 0x27BF) ||
(firstCodeUnit >= 0x1F600 && firstCodeUnit <= 0x1F64F)) {
return firstChar;
}
return null;
}
/// Get display name without role emoji (e.g., "🧑🏻🚒Janez" → "Janez")
/// If no emoji, returns full advName
String get displayName {
final emoji = roleEmoji;
if (emoji == null) return advName;
// Remove the emoji from the beginning
return advName.substring(emoji.length).trim();
}
Contact copyWith({ Contact copyWith({
Uint8List? publicKey, Uint8List? publicKey,
ContactType? type, ContactType? type,

View File

@@ -6,6 +6,7 @@ enum SarMarkerType {
foundPerson('🧑', 'Found Person'), foundPerson('🧑', 'Found Person'),
fire('🔥', 'Fire'), fire('🔥', 'Fire'),
stagingArea('🏕️', 'Staging Area'), stagingArea('🏕️', 'Staging Area'),
object('📦', 'Object'),
unknown('', 'Unknown'); unknown('', 'Unknown');
const SarMarkerType(this.emoji, this.displayName); const SarMarkerType(this.emoji, this.displayName);
@@ -22,6 +23,8 @@ enum SarMarkerType {
case '🏕️': case '🏕️':
case '': case '':
return SarMarkerType.stagingArea; return SarMarkerType.stagingArea;
case '📦':
return SarMarkerType.object;
default: default:
return SarMarkerType.unknown; return SarMarkerType.unknown;
} }
@@ -36,6 +39,8 @@ enum SarMarkerType {
return '#F44336'; // Red return '#F44336'; // Red
case SarMarkerType.stagingArea: case SarMarkerType.stagingArea:
return '#2196F3'; // Blue return '#2196F3'; // Blue
case SarMarkerType.object:
return '#9C27B0'; // Purple
default: default:
return '#9E9E9E'; // Gray return '#9E9E9E'; // Gray
} }

View File

@@ -29,6 +29,9 @@ class MessagesProvider with ChangeNotifier {
List<SarMarker> get stagingAreaMarkers => List<SarMarker> get stagingAreaMarkers =>
sarMarkers.where((m) => m.type == SarMarkerType.stagingArea).toList(); sarMarkers.where((m) => m.type == SarMarkerType.stagingArea).toList();
List<SarMarker> get objectMarkers =>
sarMarkers.where((m) => m.type == SarMarkerType.object).toList();
/// Add a message /// Add a message
void addMessage(Message message) { void addMessage(Message message) {
_messages.add(message); _messages.add(message);
@@ -151,6 +154,7 @@ class MessagesProvider with ChangeNotifier {
'foundPerson': foundPersonMarkers.length, 'foundPerson': foundPersonMarkers.length,
'fire': fireMarkers.length, 'fire': fireMarkers.length,
'stagingArea': stagingAreaMarkers.length, 'stagingArea': stagingAreaMarkers.length,
'object': objectMarkers.length,
}; };
} }
} }

View File

@@ -141,7 +141,12 @@ class _ContactTile extends StatelessWidget {
child: ListTile( child: ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: _getTypeColor(contact.type), backgroundColor: _getTypeColor(contact.type),
child: Icon( child: contact.roleEmoji != null
? Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 24),
)
: Icon(
_getTypeIcon(contact.type), _getTypeIcon(contact.type),
color: Colors.white, color: Colors.white,
), ),
@@ -150,7 +155,7 @@ class _ContactTile extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
contact.advName, contact.displayName,
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
), ),
@@ -236,7 +241,7 @@ class _ContactTile extends StatelessWidget {
connectionProvider.requestTelemetry(contact.publicKey); connectionProvider.requestTelemetry(contact.publicKey);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Requesting telemetry from ${contact.advName}'), content: Text('Requesting telemetry from ${contact.displayName}'),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -249,27 +254,96 @@ class _ContactTile extends StatelessWidget {
} }
void _showContactDetails(BuildContext context, Contact contact) { void _showContactDetails(BuildContext context, Contact contact) {
showDialog( showModalBottomSheet(
context: context, context: context,
builder: (context) => AlertDialog( isScrollControlled: true,
title: Text(contact.advName), shape: const RoundedRectangleBorder(
content: SingleChildScrollView( borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
child: Column( ),
mainAxisSize: MainAxisSize.min, builder: (context) => DraggableScrollableSheet(
crossAxisAlignment: CrossAxisAlignment.start, initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (context, scrollController) => Column(
children: [
// Handle bar
Container(
margin: const EdgeInsets.only(top: 8, bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
CircleAvatar(
backgroundColor: _getTypeColor(contact.type),
child: contact.roleEmoji != null
? Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 24),
)
: Icon(
_getTypeIcon(contact.type),
color: Colors.white,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
contact.displayName,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
),
const Divider(),
// Content
Expanded(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.all(16),
children: [ children: [
_DetailRow('Type', contact.type.displayName), _DetailRow('Type', contact.type.displayName),
_DetailRow('Public Key', contact.publicKeyShort), _DetailRow('Public Key', contact.publicKeyShort),
_DetailRow('Last Seen', contact.timeSinceLastSeen), _DetailRow('Last Seen', contact.timeSinceLastSeen),
const Divider(), const SizedBox(height: 16),
if (contact.displayLocation != null) ...[ if (contact.displayLocation != null) ...[
const Text('Location:', style: TextStyle(fontWeight: FontWeight.bold)), const Text(
'Location:',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 8),
_DetailRow('Latitude', contact.displayLocation!.latitude.toStringAsFixed(6)), _DetailRow('Latitude', contact.displayLocation!.latitude.toStringAsFixed(6)),
_DetailRow('Longitude', contact.displayLocation!.longitude.toStringAsFixed(6)), _DetailRow('Longitude', contact.displayLocation!.longitude.toStringAsFixed(6)),
const Divider(), const SizedBox(height: 16),
], ],
if (contact.telemetry != null) ...[ if (contact.telemetry != null) ...[
const Text('Telemetry:', style: TextStyle(fontWeight: FontWeight.bold)), const Text(
'Telemetry:',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 8),
if (contact.telemetry!.batteryPercentage != null) if (contact.telemetry!.batteryPercentage != null)
_DetailRow('Battery', '${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%'), _DetailRow('Battery', '${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%'),
if (contact.telemetry!.temperature != null) if (contact.telemetry!.temperature != null)
@@ -279,13 +353,9 @@ class _ContactTile extends StatelessWidget {
], ],
), ),
), ),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
], ],
), ),
),
); );
} }

View File

@@ -527,6 +527,60 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
); );
} }
void _showDetailedCompassWithContact(
BuildContext context,
List<Contact> contacts,
List<SarMarker> sarMarkers,
Contact selectedContact,
) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => Container(
height: MediaQuery.of(context).size.height * 0.9,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: _DetailedCompassDialog(
initialPosition: _currentPosition,
initialHeading: _currentHeading,
contacts: contacts,
sarMarkers: sarMarkers,
preSelectedContact: selectedContact,
),
),
);
}
void _showDetailedCompassWithSarMarker(
BuildContext context,
List<Contact> contacts,
List<SarMarker> sarMarkers,
SarMarker selectedMarker,
) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => Container(
height: MediaQuery.of(context).size.height * 0.9,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: _DetailedCompassDialog(
initialPosition: _currentPosition,
initialHeading: _currentHeading,
contacts: contacts,
sarMarkers: sarMarkers,
preSelectedSarMarker: selectedMarker,
),
),
);
}
void _restartLocationStream() { void _restartLocationStream() {
// Cancel existing subscription // Cancel existing subscription
_positionStreamSubscription?.cancel(); _positionStreamSubscription?.cancel();
@@ -600,10 +654,28 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
...MapMarkers.createTeamMemberMarkers( ...MapMarkers.createTeamMemberMarkers(
contactsWithLocation, contactsWithLocation,
context, context,
mapRotation: _mapController.camera.rotation,
onContactTap: (contact) {
_showDetailedCompassWithContact(
context,
contactsProvider.chatContactsWithLocation,
messagesProvider.sarMarkers,
contact,
);
},
), ),
...MapMarkers.createSarMarkers( ...MapMarkers.createSarMarkers(
sarMarkers, sarMarkers,
context, context,
mapRotation: _mapController.camera.rotation,
onSarMarkerTap: (marker) {
_showDetailedCompassWithSarMarker(
context,
contactsProvider.chatContactsWithLocation,
messagesProvider.sarMarkers,
marker,
);
},
), ),
// User location marker // User location marker
if (_currentPosition != null) if (_currentPosition != null)
@@ -657,8 +729,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
], ],
), ),
), ),
// Compass widget - top right // Compass widget - top right (always visible)
if (_rotateMarkerWithHeading)
Positioned( Positioned(
top: 16, top: 16,
right: 16, right: 16,
@@ -677,13 +748,14 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
// Map legend overlay // Map legend overlay
if (_showLegend) if (_showLegend)
Positioned( Positioned(
top: _rotateMarkerWithHeading ? 80 : 16, top: 80, // Position below compass (which is always visible now)
right: 16, left: 16,
child: _MapLegend( child: _MapLegend(
teamMemberCount: contactsWithLocation.length, teamMemberCount: contactsWithLocation.length,
foundPersonCount: messagesProvider.foundPersonMarkers.length, foundPersonCount: messagesProvider.foundPersonMarkers.length,
fireCount: messagesProvider.fireMarkers.length, fireCount: messagesProvider.fireMarkers.length,
stagingAreaCount: messagesProvider.stagingAreaMarkers.length, stagingAreaCount: messagesProvider.stagingAreaMarkers.length,
objectCount: messagesProvider.objectMarkers.length,
), ),
), ),
// Map controls - right side // Map controls - right side
@@ -764,12 +836,14 @@ class _MapLegend extends StatelessWidget {
final int foundPersonCount; final int foundPersonCount;
final int fireCount; final int fireCount;
final int stagingAreaCount; final int stagingAreaCount;
final int objectCount;
const _MapLegend({ const _MapLegend({
required this.teamMemberCount, required this.teamMemberCount,
required this.foundPersonCount, required this.foundPersonCount,
required this.fireCount, required this.fireCount,
required this.stagingAreaCount, required this.stagingAreaCount,
required this.objectCount,
}); });
@override @override
@@ -812,6 +886,12 @@ class _MapLegend extends StatelessWidget {
label: 'Staging', label: 'Staging',
count: stagingAreaCount, count: stagingAreaCount,
), ),
_LegendItem(
icon: Icons.inventory_2,
color: Colors.purple,
label: 'Object',
count: objectCount,
),
], ],
), ),
), ),
@@ -975,12 +1055,16 @@ class _DetailedCompassDialog extends StatefulWidget {
final double? initialHeading; final double? initialHeading;
final List<Contact> contacts; final List<Contact> contacts;
final List<SarMarker> sarMarkers; final List<SarMarker> sarMarkers;
final Contact? preSelectedContact;
final SarMarker? preSelectedSarMarker;
const _DetailedCompassDialog({ const _DetailedCompassDialog({
required this.initialPosition, required this.initialPosition,
required this.initialHeading, required this.initialHeading,
required this.contacts, required this.contacts,
required this.sarMarkers, required this.sarMarkers,
this.preSelectedContact,
this.preSelectedSarMarker,
}); });
@override @override
@@ -1004,11 +1088,17 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
bool _showFire = true; bool _showFire = true;
bool _showStagingArea = true; bool _showStagingArea = true;
// Selected item for isolation
Contact? _selectedContact;
SarMarker? _selectedSarMarker;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_currentHeading = widget.initialHeading; _currentHeading = widget.initialHeading;
_currentPosition = widget.initialPosition; _currentPosition = widget.initialPosition;
_selectedContact = widget.preSelectedContact;
_selectedSarMarker = widget.preSelectedSarMarker;
// Subscribe to compass updates // Subscribe to compass updates
final compassStream = FlutterCompass.events; final compassStream = FlutterCompass.events;
@@ -1063,6 +1153,8 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
return _showFire; return _showFire;
case SarMarkerType.stagingArea: case SarMarkerType.stagingArea:
return _showStagingArea; return _showStagingArea;
case SarMarkerType.object:
return true; // Always show object markers (add filter if needed)
case SarMarkerType.unknown: case SarMarkerType.unknown:
return true; // Always show unknown markers return true; // Always show unknown markers
} }
@@ -1261,13 +1353,25 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
heading: heading ?? 0, heading: heading ?? 0,
hasHeading: heading != null, hasHeading: heading != null,
currentPosition: position, currentPosition: position,
contacts: _showContacts ? widget.contacts : [], contacts: _selectedContact != null
sarMarkers: _getFilteredSarMarkers(), ? [_selectedContact!]
: (_selectedSarMarker != null
? []
: (_showContacts ? widget.contacts : [])),
sarMarkers: _selectedSarMarker != null
? [_selectedSarMarker!]
: (_selectedContact != null
? []
: _getFilteredSarMarkers()),
zoomLevel: _zoomLevel, zoomLevel: _zoomLevel,
), ),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Selected item detail view
if (_selectedContact != null || _selectedSarMarker != null)
_buildSelectedItemDetail(context, heading, position),
const SizedBox(height: 12),
// Contacts list // Contacts list
if (_showContacts && widget.contacts.isNotEmpty) _buildContactsList(context, heading, position), if (_showContacts && widget.contacts.isNotEmpty) _buildContactsList(context, heading, position),
// SAR Markers list // SAR Markers list
@@ -1335,6 +1439,230 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
return _LocationFormatToggle(position: position); return _LocationFormatToggle(position: position);
} }
Widget _buildSelectedItemDetail(BuildContext context, double? heading, Position? position) {
if (position == null) {
return const SizedBox.shrink();
}
String title;
IconData icon;
Color color;
double? bearing;
double? distance;
LatLng? targetLocation;
String? additionalInfo;
if (_selectedContact != null) {
title = _selectedContact!.displayName;
icon = Icons.person;
color = Colors.blue;
targetLocation = _selectedContact!.displayLocation;
if (targetLocation != null) {
bearing = _calculateBearing(
position.latitude,
position.longitude,
targetLocation.latitude,
targetLocation.longitude,
);
distance = _calculateDistance(
position.latitude,
position.longitude,
targetLocation.latitude,
targetLocation.longitude,
);
}
// Show battery if available
if (_selectedContact!.telemetry?.batteryPercentage != null) {
additionalInfo = 'Battery: ${_selectedContact!.telemetry!.batteryPercentage!.round()}%';
}
} else if (_selectedSarMarker != null) {
title = _selectedSarMarker!.type.displayName;
targetLocation = _selectedSarMarker!.location;
additionalInfo = _selectedSarMarker!.timeAgo;
switch (_selectedSarMarker!.type) {
case SarMarkerType.foundPerson:
icon = Icons.person_pin;
color = Colors.green;
break;
case SarMarkerType.fire:
icon = Icons.local_fire_department;
color = Colors.red;
break;
case SarMarkerType.stagingArea:
icon = Icons.home_work;
color = Colors.orange;
break;
case SarMarkerType.object:
icon = Icons.inventory_2;
color = Colors.purple;
break;
case SarMarkerType.unknown:
icon = Icons.help_outline;
color = Colors.grey;
break;
}
bearing = _calculateBearing(
position.latitude,
position.longitude,
targetLocation.latitude,
targetLocation.longitude,
);
distance = _calculateDistance(
position.latitude,
position.longitude,
targetLocation.latitude,
targetLocation.longitude,
);
} else {
return const SizedBox.shrink();
}
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Header with icon and title
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: _selectedContact != null && _selectedContact!.roleEmoji != null
? Text(
_selectedContact!.roleEmoji!,
style: const TextStyle(fontSize: 32),
)
: Icon(icon, size: 32, color: color),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (additionalInfo != null)
Text(
additionalInfo,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() {
_selectedContact = null;
_selectedSarMarker = null;
});
},
),
],
),
if (bearing != null && distance != null) ...[
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
// Distance and bearing info
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildLargeInfoCard(
context,
'Distance',
_formatDistance(distance),
Icons.straighten,
color,
),
_buildLargeInfoCard(
context,
'Bearing',
'${bearing.round()}°',
Icons.navigation,
color,
),
_buildLargeInfoCard(
context,
'Direction',
_bearingToCardinal(bearing),
Icons.explore,
color,
),
],
),
const SizedBox(height: 12),
// Coordinates
if (targetLocation != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.location_on, size: 16),
const SizedBox(width: 8),
Text(
'${targetLocation.latitude.toStringAsFixed(5)}, ${targetLocation.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
],
),
),
],
],
),
),
);
}
Widget _buildLargeInfoCard(
BuildContext context,
String label,
String value,
IconData icon,
Color color,
) {
return Column(
children: [
Icon(icon, size: 28, color: color),
const SizedBox(height: 8),
Text(
value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
// Convert decimal degrees to DMS (Degrees, Minutes, Seconds) // Convert decimal degrees to DMS (Degrees, Minutes, Seconds)
String _formatDMS(double degrees, bool isLatitude) { String _formatDMS(double degrees, bool isLatitude) {
final direction = isLatitude final direction = isLatitude
@@ -1404,17 +1732,30 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: _selectedContact == contact
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: _selectedContact == contact
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
), ),
child: ListTile( child: ListTile(
dense: true, dense: true,
leading: const Icon( leading: contact.roleEmoji != null
? Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 24),
)
: const Icon(
Icons.person, Icons.person,
color: Colors.blue, color: Colors.blue,
size: 24, size: 24,
), ),
title: Text(contact.advName), title: Text(contact.displayName),
subtitle: Text( subtitle: Text(
'${_bearingToCardinal(bearing)}${_formatDistance(distance)}', '${_bearingToCardinal(bearing)}${_formatDistance(distance)}',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
@@ -1425,6 +1766,18 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
onTap: () {
setState(() {
if (_selectedContact == contact) {
// Deselect if already selected
_selectedContact = null;
} else {
// Select this contact and deselect SAR marker
_selectedContact = contact;
_selectedSarMarker = null;
}
});
},
), ),
); );
}), }),
@@ -1500,6 +1853,10 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
markerColor = Colors.orange; markerColor = Colors.orange;
markerIcon = Icons.home_work; markerIcon = Icons.home_work;
break; break;
case SarMarkerType.object:
markerColor = Colors.purple;
markerIcon = Icons.inventory_2;
break;
case SarMarkerType.unknown: case SarMarkerType.unknown:
markerColor = Colors.grey; markerColor = Colors.grey;
markerIcon = Icons.help_outline; markerIcon = Icons.help_outline;
@@ -1509,8 +1866,16 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: _selectedSarMarker == marker
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: _selectedSarMarker == marker
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
), ),
child: ListTile( child: ListTile(
dense: true, dense: true,
@@ -1530,6 +1895,18 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
onTap: () {
setState(() {
if (_selectedSarMarker == marker) {
// Deselect if already selected
_selectedSarMarker = null;
} else {
// Select this marker and deselect contact
_selectedSarMarker = marker;
_selectedContact = null;
}
});
},
), ),
); );
}), }),
@@ -1862,6 +2239,9 @@ class _LargeCompassPainter extends CustomPainter {
case SarMarkerType.stagingArea: case SarMarkerType.stagingArea:
markerColor = Colors.orange; markerColor = Colors.orange;
break; break;
case SarMarkerType.object:
markerColor = Colors.purple;
break;
case SarMarkerType.unknown: case SarMarkerType.unknown:
markerColor = Colors.grey; markerColor = Colors.grey;
break; break;

View File

@@ -1,12 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.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 '../providers/connection_provider.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/contact.dart'; import '../models/sar_marker.dart';
class MessagesTab extends StatefulWidget { class MessagesTab extends StatefulWidget {
final VoidCallback onNavigateToMap; final VoidCallback onNavigateToMap;
@@ -20,7 +21,6 @@ class MessagesTab extends StatefulWidget {
class _MessagesTabState extends State<MessagesTab> { class _MessagesTabState extends State<MessagesTab> {
final TextEditingController _textController = TextEditingController(); final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
Contact? _selectedContact;
int _characterCount = 0; int _characterCount = 0;
static const int _maxCharacters = 160; static const int _maxCharacters = 160;
@@ -48,6 +48,7 @@ class _MessagesTabState extends State<MessagesTab> {
if (text.isEmpty) return; if (text.isEmpty) return;
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
if (!connectionProvider.deviceInfo.isConnected) { if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return; if (!mounted) return;
@@ -60,23 +61,15 @@ class _MessagesTabState extends State<MessagesTab> {
return; return;
} }
if (_selectedContact == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select a recipient'),
backgroundColor: Colors.orange,
),
);
return;
}
try { try {
// Send to channel/room or direct contact // Default to sending to room/channel (first available room)
if (_selectedContact!.isRoom) { final rooms = contactsProvider.rooms;
// For rooms/channels, use the first byte of outPath as channel index
final channelIdx = _selectedContact!.outPath.isNotEmpty if (rooms.isNotEmpty) {
? _selectedContact!.outPath[0] // Send to first available room
final defaultRoom = rooms.first;
final channelIdx = defaultRoom.outPath.isNotEmpty
? defaultRoom.outPath[0]
: 0; : 0;
await connectionProvider.sendChannelMessage( await connectionProvider.sendChannelMessage(
@@ -84,11 +77,15 @@ class _MessagesTabState extends State<MessagesTab> {
text: text, text: text,
); );
} else { } else {
// For direct contacts (chat type) // No rooms available, show error
await connectionProvider.sendTextMessage( if (!mounted) return;
contactPublicKey: _selectedContact!.publicKey, ScaffoldMessenger.of(context).showSnackBar(
text: text, const SnackBar(
content: Text('No channels available'),
backgroundColor: Colors.orange,
),
); );
return;
} }
_textController.clear(); _textController.clear();
@@ -113,131 +110,93 @@ class _MessagesTabState extends State<MessagesTab> {
} }
} }
void _showContactSelector() { void _showSarDialog() {
final contactsProvider = context.read<ContactsProvider>(); showModalBottomSheet(
final allContacts = contactsProvider.contacts; context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => _SarUpdateSheet(
onSend: (sarType, position, notes) async {
await _sendSarMessage(sarType, position, notes);
},
),
);
}
if (allContacts.isEmpty) { Future<void> _sendSarMessage(
SarMarkerType sarType,
Position position,
String? notes,
) async {
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('No contacts available'), content: Text('Not connected to device'),
backgroundColor: Colors.red,
),
);
return;
}
try {
// Format: S:<emoji>:<latitude>,<longitude>
final sarMessage = 'S:${sarType.emoji}:${position.latitude},${position.longitude}';
// Add notes if provided
final fullMessage = notes != null && notes.isNotEmpty
? '$sarMessage $notes'
: sarMessage;
// Default to sending to room/channel (first available room)
final rooms = contactsProvider.rooms;
if (rooms.isNotEmpty) {
// Send to first available room
final defaultRoom = rooms.first;
final channelIdx = defaultRoom.outPath.isNotEmpty
? defaultRoom.outPath[0]
: 0;
await connectionProvider.sendChannelMessage(
channelIdx: channelIdx,
text: fullMessage,
);
} else {
// No rooms available, show error
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No channels available'),
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
), ),
); );
return; return;
} }
// Group contacts by type if (!mounted) return;
final chatContacts = contactsProvider.chatContacts; ScaffoldMessenger.of(context).showSnackBar(
final rooms = contactsProvider.rooms; SnackBar(
final repeaters = contactsProvider.repeaters; content: Text('${sarType.displayName} marker sent'),
backgroundColor: Colors.green,
showModalBottomSheet( duration: const Duration(seconds: 2),
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), } catch (e) {
Flexible( if (!mounted) return;
child: ListView( ScaffoldMessenger.of(context).showSnackBar(
shrinkWrap: true, SnackBar(
children: [ content: Text('Failed to send SAR marker: $e'),
// Chat contacts section backgroundColor: Colors.red,
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>(
@@ -308,73 +267,22 @@ class _MessagesTabState extends State<MessagesTab> {
), ),
), ),
), ),
padding: const EdgeInsets.fromLTRB(4, 4, 4, 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
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( 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, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
// Contact selector button (compact) // SAR quick action button
IconButton( IconButton(
icon: const Icon(Icons.contacts, size: 20), icon: const Icon(Icons.add_location_alt),
onPressed: _showContactSelector, tooltip: 'Send SAR marker',
tooltip: 'Select contact', onPressed: _showSarDialog,
padding: const EdgeInsets.all(8), style: IconButton.styleFrom(
constraints: const BoxConstraints( backgroundColor: Theme.of(context).colorScheme.primaryContainer,
minWidth: 36, foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer,
minHeight: 36,
), ),
), ),
const SizedBox(width: 8),
// Text field (compact) // Text field with embedded send button
Expanded( Expanded(
child: TextField( child: TextField(
controller: _textController, controller: _textController,
@@ -384,44 +292,43 @@ class _MessagesTabState extends State<MessagesTab> {
maxLengthEnforcement: MaxLengthEnforcement.enforced, maxLengthEnforcement: MaxLengthEnforcement.enforced,
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Message...', hintText: 'Message to channel...',
hintStyle: const TextStyle(fontSize: 14), hintStyle: const TextStyle(fontSize: 14),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(24),
), ),
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 12, horizontal: 16,
vertical: 8, vertical: 10,
), ),
isDense: true, isDense: true,
counterText: '$_characterCount/$_maxCharacters', counterText: _characterCount >= 150
? '$_characterCount/$_maxCharacters'
: '',
counterStyle: TextStyle( counterStyle: TextStyle(
fontSize: 10, fontSize: 10,
color: _characterCount > _maxCharacters * 0.9 color: _characterCount > _maxCharacters * 0.9
? Colors.orange ? Colors.orange
: Theme.of(context).textTheme.bodySmall?.color, : Theme.of(context).textTheme.bodySmall?.color,
), ),
suffixIcon: IconButton(
icon: Icon(
Icons.send_rounded,
size: 22,
color: _textController.text.trim().isEmpty
? Theme.of(context).disabledColor
: Theme.of(context).colorScheme.primary,
),
onPressed: _textController.text.trim().isEmpty
? null
: _sendMessage,
tooltip: 'Send',
),
), ),
textInputAction: TextInputAction.send, textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(), 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,
),
),
],
),
], ],
), ),
), ),
@@ -566,3 +473,512 @@ class _MessageBubble extends StatelessWidget {
return Theme.of(context).colorScheme.primaryContainer; return Theme.of(context).colorScheme.primaryContainer;
} }
} }
// SAR Update Sheet
class _SarUpdateSheet extends StatefulWidget {
final Future<void> Function(SarMarkerType, Position, String?) onSend;
const _SarUpdateSheet({required this.onSend});
@override
State<_SarUpdateSheet> createState() => _SarUpdateSheetState();
}
class _SarUpdateSheetState extends State<_SarUpdateSheet> {
SarMarkerType _selectedType = SarMarkerType.foundPerson;
Position? _currentPosition;
bool _loadingLocation = false;
String? _locationError;
final TextEditingController _notesController = TextEditingController();
@override
void initState() {
super.initState();
_getCurrentLocation();
}
@override
void dispose() {
_notesController.dispose();
super.dispose();
}
Future<void> _getCurrentLocation() async {
setState(() {
_loadingLocation = true;
_locationError = null;
});
try {
// Check if location services are enabled
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
setState(() {
_locationError = 'Location services are disabled';
_loadingLocation = false;
});
return;
}
// Check permissions
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
setState(() {
_locationError = 'Location permission denied';
_loadingLocation = false;
});
return;
}
}
if (permission == LocationPermission.deniedForever) {
setState(() {
_locationError = 'Location permission permanently denied';
_loadingLocation = false;
});
return;
}
// Get current position
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: 0,
),
);
if (mounted) {
setState(() {
_currentPosition = position;
_loadingLocation = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_locationError = 'Failed to get location: $e';
_loadingLocation = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header with drag handle
Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
child: Column(
children: [
// Drag handle
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
// Title
Row(
children: [
Icon(
Icons.add_location_alt,
size: 24,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Send SAR Marker',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
],
),
),
const Divider(height: 1),
// Content
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Marker type selection
Text(
'Marker Type',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
_MarkerTypeChip(
type: SarMarkerType.foundPerson,
isSelected: _selectedType == SarMarkerType.foundPerson,
onTap: () => setState(() => _selectedType = SarMarkerType.foundPerson),
),
const SizedBox(height: 8),
_MarkerTypeChip(
type: SarMarkerType.fire,
isSelected: _selectedType == SarMarkerType.fire,
onTap: () => setState(() => _selectedType = SarMarkerType.fire),
),
const SizedBox(height: 8),
_MarkerTypeChip(
type: SarMarkerType.stagingArea,
isSelected: _selectedType == SarMarkerType.stagingArea,
onTap: () => setState(() => _selectedType = SarMarkerType.stagingArea),
),
const SizedBox(height: 8),
_MarkerTypeChip(
type: SarMarkerType.object,
isSelected: _selectedType == SarMarkerType.object,
onTap: () => setState(() => _selectedType = SarMarkerType.object),
),
const SizedBox(height: 24),
// Location display
Text(
'Current Location',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
if (_loadingLocation)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: const Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 16),
Text('Getting location...'),
],
),
)
else if (_locationError != null)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.red.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 24),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Location Error',
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
const SizedBox(height: 4),
Text(
_locationError!,
style: TextStyle(
color: Colors.red.shade700,
fontSize: 12,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.refresh, color: Colors.red),
onPressed: _getCurrentLocation,
tooltip: 'Retry',
),
],
),
)
else if (_currentPosition != null)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.location_on,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${_currentPosition!.latitude.toStringAsFixed(5)}, ${_currentPosition!.longitude.toStringAsFixed(5)}',
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: _getCurrentLocation,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
tooltip: 'Refresh location',
),
],
),
if (_currentPosition!.accuracy != null) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.my_location,
size: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Text(
'Accuracy: ±${_currentPosition!.accuracy!.round()}m',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
],
],
),
),
const SizedBox(height: 24),
// Optional notes
Text(
'Notes (optional)',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
TextField(
controller: _notesController,
maxLines: 3,
maxLength: 100,
decoration: InputDecoration(
hintText: 'Add additional information...',
hintStyle: const TextStyle(fontSize: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.all(16),
),
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 8),
],
),
),
),
// Bottom action buttons
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: SafeArea(
top: false,
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: FilledButton.icon(
onPressed: _currentPosition == null
? null
: () async {
await widget.onSend(
_selectedType,
_currentPosition!,
_notesController.text.trim().isEmpty
? null
: _notesController.text.trim(),
);
if (context.mounted) {
Navigator.pop(context);
}
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
icon: const Icon(Icons.send, size: 20),
label: const Text('Send SAR Marker'),
),
),
],
),
),
),
],
),
);
}
}
// Marker Type Chip widget
class _MarkerTypeChip extends StatelessWidget {
final SarMarkerType type;
final bool isSelected;
final VoidCallback onTap;
const _MarkerTypeChip({
required this.type,
required this.isSelected,
required this.onTap,
});
Color _getMarkerColor() {
switch (type) {
case SarMarkerType.foundPerson:
return Colors.green;
case SarMarkerType.fire:
return Colors.red;
case SarMarkerType.stagingArea:
return Colors.orange;
case SarMarkerType.object:
return Colors.purple;
case SarMarkerType.unknown:
return Colors.grey;
}
}
@override
Widget build(BuildContext context) {
final color = _getMarkerColor();
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isSelected
? color.withValues(alpha: 0.15)
: Colors.transparent,
border: Border.all(
color: isSelected ? color : Colors.grey.withValues(alpha: 0.3),
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: isSelected
? color.withValues(alpha: 0.2)
: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(
type.emoji,
style: const TextStyle(fontSize: 28),
),
),
const SizedBox(width: 16),
Expanded(
child: Text(
type.displayName,
style: TextStyle(
fontSize: 16,
fontWeight: isSelected ? FontWeight.bold : FontWeight.w500,
color: isSelected ? color : null,
),
),
),
if (isSelected)
Icon(
Icons.check_circle,
color: color,
size: 24,
)
else
Icon(
Icons.radio_button_unchecked,
color: Colors.grey.withValues(alpha: 0.4),
size: 24,
),
],
),
),
);
}
}

View File

@@ -20,14 +20,14 @@ class SampleDataGenerator {
final now = DateTime.now(); final now = DateTime.now();
final teamNames = [ final teamNames = [
'Alpha Team Lead', '👮Police Lead',
'Bravo Scout', '🚁Drone Operator',
'Charlie Medic', '🧑🏻🚒Firefighter Alpha',
'Delta Navigator', '🧑Medic Charlie',
'Echo Support', '📡Command Delta',
'Foxtrot Runner', '🚒Fire Engine',
'Golf Comms', '👨Air Support',
'Hotel Base', '🧑💼Base Coordinator',
]; ];
final channelNames = [ final channelNames = [

View File

@@ -1,14 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/sar_marker.dart'; import '../models/sar_marker.dart';
class MapMarkers { class MapMarkers {
static List<Marker> createTeamMemberMarkers( static List<Marker> createTeamMemberMarkers(
List<Contact> contacts, List<Contact> contacts,
BuildContext context, BuildContext context, {
) { Function(Contact)? onContactTap,
double mapRotation = 0,
}) {
return contacts.map((contact) { return contacts.map((contact) {
final location = contact.displayLocation; final location = contact.displayLocation;
if (location == null) return null; if (location == null) return null;
@@ -17,21 +18,29 @@ class MapMarkers {
point: location, point: location,
width: 80, width: 80,
height: 100, height: 100,
rotate: false, // Don't rotate the entire marker with map
child: Transform.rotate(
angle: -mapRotation * 3.14159265359 / 180,
child: GestureDetector( child: GestureDetector(
onTap: () => _showContactInfo(context, contact), onTap: () {
if (onContactTap != null) {
onContactTap(contact);
} else {
_showContactInfo(context, contact);
}
},
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Battery indicator // Location update time indicator
if (contact.displayBattery != null)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getBatteryColor(contact.displayBattery!), color: _getLocationAgeColor(contact),
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
), ),
child: Text( child: Text(
'${contact.displayBattery!.round()}%', contact.timeSinceLocationUpdate,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 9, fontSize: 9,
@@ -39,7 +48,7 @@ class MapMarkers {
), ),
), ),
), ),
if (contact.displayBattery != null) const SizedBox(height: 2), const SizedBox(height: 2),
// Marker icon // Marker icon
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -62,7 +71,7 @@ class MapMarkers {
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
// Name label // Name label (without emoji)
Container( Container(
constraints: const BoxConstraints(maxWidth: 80), constraints: const BoxConstraints(maxWidth: 80),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
@@ -71,7 +80,7 @@ class MapMarkers {
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
), ),
child: Text( child: Text(
contact.advName, contact.displayName,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 9, fontSize: 9,
@@ -85,21 +94,33 @@ class MapMarkers {
], ],
), ),
), ),
),
); );
}).whereType<Marker>().toList(); }).whereType<Marker>().toList();
} }
static List<Marker> createSarMarkers( static List<Marker> createSarMarkers(
List<SarMarker> sarMarkers, List<SarMarker> sarMarkers,
BuildContext context, BuildContext context, {
) { Function(SarMarker)? onSarMarkerTap,
double mapRotation = 0,
}) {
return sarMarkers.map((marker) { return sarMarkers.map((marker) {
return Marker( return Marker(
point: marker.location, point: marker.location,
width: 90, width: 90,
height: 100, height: 100,
rotate: false, // Don't rotate the entire marker with map
child: Transform.rotate(
angle: -mapRotation * 3.14159265359 / 180,
child: GestureDetector( child: GestureDetector(
onTap: () => _showSarMarkerInfo(context, marker), onTap: () {
if (onSarMarkerTap != null) {
onSarMarkerTap(marker);
} else {
_showSarMarkerInfo(context, marker);
}
},
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -164,11 +185,17 @@ class MapMarkers {
], ],
), ),
), ),
),
); );
}).toList(); }).toList();
} }
static void _showContactInfo(BuildContext context, Contact contact) { static void _showContactInfo(BuildContext context, Contact contact) {
// Import provider to get all contacts and SAR markers for detailed view
// This will be handled by importing the screen's detailed compass dialog
// Since we can't directly access _DetailedCompassDialog from here,
// we'll pass a callback to the screen
// For now, show the simple dialog as a fallback
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -176,7 +203,7 @@ class MapMarkers {
children: [ children: [
const Icon(Icons.person, color: Colors.blue), const Icon(Icons.person, color: Colors.blue),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded(child: Text(contact.advName)), Expanded(child: Text(contact.displayName)),
], ],
), ),
content: Column( content: Column(
@@ -242,10 +269,15 @@ class MapMarkers {
); );
} }
static Color _getBatteryColor(double percentage) { static Color _getLocationAgeColor(Contact contact) {
if (percentage > 50) return Colors.green; final updateTime = contact.locationUpdateTime;
if (percentage > 20) return Colors.orange; if (updateTime == null) return Colors.grey;
return Colors.red;
final diff = DateTime.now().difference(updateTime);
if (diff.inMinutes < 5) return Colors.green; // Very recent
if (diff.inMinutes < 30) return Colors.blue; // Recent
if (diff.inHours < 2) return Colors.orange; // Getting old
return Colors.red; // Stale
} }
static Color _getSarMarkerColor(SarMarkerType type) { static Color _getSarMarkerColor(SarMarkerType type) {
@@ -256,6 +288,8 @@ class MapMarkers {
return Colors.red; return Colors.red;
case SarMarkerType.stagingArea: case SarMarkerType.stagingArea:
return Colors.orange; return Colors.orange;
case SarMarkerType.object:
return Colors.purple;
case SarMarkerType.unknown: case SarMarkerType.unknown:
return Colors.grey; return Colors.grey;
} }