mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
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:
@@ -1,5 +1,6 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'contact_telemetry.dart';
|
||||
|
||||
/// MeshCore contact types
|
||||
@@ -129,6 +130,67 @@ class Contact {
|
||||
return '${diff.inDays}d ago';
|
||||
}
|
||||
|
||||
/// Get time when location was last updated
|
||||
DateTime? get locationUpdateTime {
|
||||
// Prefer telemetry timestamp if available
|
||||
if (telemetry?.gpsLocation != null) {
|
||||
return telemetry!.timestamp;
|
||||
}
|
||||
// Fall back to lastAdvert time if using advertised location
|
||||
if (advertLocation != null) {
|
||||
return lastSeenTime;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get friendly time since location was last updated
|
||||
String get timeSinceLocationUpdate {
|
||||
final updateTime = locationUpdateTime;
|
||||
if (updateTime == null) return 'Unknown';
|
||||
|
||||
final diff = DateTime.now().difference(updateTime);
|
||||
if (diff.inMinutes < 1) return 'Now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h';
|
||||
return '${diff.inDays}d';
|
||||
}
|
||||
|
||||
/// Extract role emoji from name (e.g., "🧑🏻🚒Janez" → "🧑🏻🚒")
|
||||
/// Returns null if no emoji at start of name
|
||||
String? get roleEmoji {
|
||||
if (advName.isEmpty) return null;
|
||||
|
||||
// Get the first character/grapheme cluster (which could be a complex emoji)
|
||||
final firstChar = advName.characters.first;
|
||||
|
||||
// Check if it's an emoji (basic check - emojis are typically in certain Unicode ranges)
|
||||
final firstCodeUnit = firstChar.runes.first;
|
||||
|
||||
// Emoji ranges (simplified check):
|
||||
// 0x1F300-0x1F9FF: Misc Symbols and Pictographs, Emoticons, Transport, etc.
|
||||
// 0x2600-0x26FF: Misc symbols
|
||||
// 0x2700-0x27BF: Dingbats
|
||||
// 0xFE00-0xFE0F: Variation Selectors
|
||||
// 0x1F900-0x1F9FF: Supplemental Symbols and Pictographs
|
||||
if ((firstCodeUnit >= 0x1F300 && firstCodeUnit <= 0x1F9FF) ||
|
||||
(firstCodeUnit >= 0x2600 && firstCodeUnit <= 0x27BF) ||
|
||||
(firstCodeUnit >= 0x1F600 && firstCodeUnit <= 0x1F64F)) {
|
||||
return firstChar;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get display name without role emoji (e.g., "🧑🏻🚒Janez" → "Janez")
|
||||
/// If no emoji, returns full advName
|
||||
String get displayName {
|
||||
final emoji = roleEmoji;
|
||||
if (emoji == null) return advName;
|
||||
|
||||
// Remove the emoji from the beginning
|
||||
return advName.substring(emoji.length).trim();
|
||||
}
|
||||
|
||||
Contact copyWith({
|
||||
Uint8List? publicKey,
|
||||
ContactType? type,
|
||||
|
||||
@@ -6,6 +6,7 @@ enum SarMarkerType {
|
||||
foundPerson('🧑', 'Found Person'),
|
||||
fire('🔥', 'Fire'),
|
||||
stagingArea('🏕️', 'Staging Area'),
|
||||
object('📦', 'Object'),
|
||||
unknown('❓', 'Unknown');
|
||||
|
||||
const SarMarkerType(this.emoji, this.displayName);
|
||||
@@ -22,6 +23,8 @@ enum SarMarkerType {
|
||||
case '🏕️':
|
||||
case '⛺':
|
||||
return SarMarkerType.stagingArea;
|
||||
case '📦':
|
||||
return SarMarkerType.object;
|
||||
default:
|
||||
return SarMarkerType.unknown;
|
||||
}
|
||||
@@ -36,6 +39,8 @@ enum SarMarkerType {
|
||||
return '#F44336'; // Red
|
||||
case SarMarkerType.stagingArea:
|
||||
return '#2196F3'; // Blue
|
||||
case SarMarkerType.object:
|
||||
return '#9C27B0'; // Purple
|
||||
default:
|
||||
return '#9E9E9E'; // Gray
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
List<SarMarker> get stagingAreaMarkers =>
|
||||
sarMarkers.where((m) => m.type == SarMarkerType.stagingArea).toList();
|
||||
|
||||
List<SarMarker> get objectMarkers =>
|
||||
sarMarkers.where((m) => m.type == SarMarkerType.object).toList();
|
||||
|
||||
/// Add a message
|
||||
void addMessage(Message message) {
|
||||
_messages.add(message);
|
||||
@@ -151,6 +154,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
'foundPerson': foundPersonMarkers.length,
|
||||
'fire': fireMarkers.length,
|
||||
'stagingArea': stagingAreaMarkers.length,
|
||||
'object': objectMarkers.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,16 +141,21 @@ class _ContactTile extends StatelessWidget {
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _getTypeColor(contact.type),
|
||||
child: Icon(
|
||||
_getTypeIcon(contact.type),
|
||||
color: Colors.white,
|
||||
),
|
||||
child: contact.roleEmoji != null
|
||||
? Text(
|
||||
contact.roleEmoji!,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
)
|
||||
: Icon(
|
||||
_getTypeIcon(contact.type),
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
contact.advName,
|
||||
contact.displayName,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
@@ -236,7 +241,7 @@ class _ContactTile extends StatelessWidget {
|
||||
connectionProvider.requestTelemetry(contact.publicKey);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Requesting telemetry from ${contact.advName}'),
|
||||
content: Text('Requesting telemetry from ${contact.displayName}'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -249,42 +254,107 @@ class _ContactTile extends StatelessWidget {
|
||||
}
|
||||
|
||||
void _showContactDetails(BuildContext context, Contact contact) {
|
||||
showDialog(
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(contact.advName),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_DetailRow('Type', contact.type.displayName),
|
||||
_DetailRow('Public Key', contact.publicKeyShort),
|
||||
_DetailRow('Last Seen', contact.timeSinceLastSeen),
|
||||
const Divider(),
|
||||
if (contact.displayLocation != null) ...[
|
||||
const Text('Location:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
_DetailRow('Latitude', contact.displayLocation!.latitude.toStringAsFixed(6)),
|
||||
_DetailRow('Longitude', contact.displayLocation!.longitude.toStringAsFixed(6)),
|
||||
const Divider(),
|
||||
],
|
||||
if (contact.telemetry != null) ...[
|
||||
const Text('Telemetry:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
if (contact.telemetry!.batteryPercentage != null)
|
||||
_DetailRow('Battery', '${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%'),
|
||||
if (contact.telemetry!.temperature != null)
|
||||
_DetailRow('Temperature', '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'),
|
||||
_DetailRow('Updated', contact.telemetry!.isRecent ? 'Recently' : 'Stale'),
|
||||
],
|
||||
],
|
||||
),
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.9,
|
||||
expand: false,
|
||||
builder: (context, scrollController) => Column(
|
||||
children: [
|
||||
// Handle bar
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 8, bottom: 16),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: _getTypeColor(contact.type),
|
||||
child: contact.roleEmoji != null
|
||||
? Text(
|
||||
contact.roleEmoji!,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
)
|
||||
: Icon(
|
||||
_getTypeIcon(contact.type),
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
contact.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// Content
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_DetailRow('Type', contact.type.displayName),
|
||||
_DetailRow('Public Key', contact.publicKeyShort),
|
||||
_DetailRow('Last Seen', contact.timeSinceLastSeen),
|
||||
const SizedBox(height: 16),
|
||||
if (contact.displayLocation != null) ...[
|
||||
const Text(
|
||||
'Location:',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_DetailRow('Latitude', contact.displayLocation!.latitude.toStringAsFixed(6)),
|
||||
_DetailRow('Longitude', contact.displayLocation!.longitude.toStringAsFixed(6)),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (contact.telemetry != null) ...[
|
||||
const Text(
|
||||
'Telemetry:',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (contact.telemetry!.batteryPercentage != null)
|
||||
_DetailRow('Battery', '${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%'),
|
||||
if (contact.telemetry!.temperature != null)
|
||||
_DetailRow('Temperature', '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'),
|
||||
_DetailRow('Updated', contact.telemetry!.isRecent ? 'Recently' : 'Stale'),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
// Cancel existing subscription
|
||||
_positionStreamSubscription?.cancel();
|
||||
@@ -600,10 +654,28 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
...MapMarkers.createTeamMemberMarkers(
|
||||
contactsWithLocation,
|
||||
context,
|
||||
mapRotation: _mapController.camera.rotation,
|
||||
onContactTap: (contact) {
|
||||
_showDetailedCompassWithContact(
|
||||
context,
|
||||
contactsProvider.chatContactsWithLocation,
|
||||
messagesProvider.sarMarkers,
|
||||
contact,
|
||||
);
|
||||
},
|
||||
),
|
||||
...MapMarkers.createSarMarkers(
|
||||
sarMarkers,
|
||||
context,
|
||||
mapRotation: _mapController.camera.rotation,
|
||||
onSarMarkerTap: (marker) {
|
||||
_showDetailedCompassWithSarMarker(
|
||||
context,
|
||||
contactsProvider.chatContactsWithLocation,
|
||||
messagesProvider.sarMarkers,
|
||||
marker,
|
||||
);
|
||||
},
|
||||
),
|
||||
// User location marker
|
||||
if (_currentPosition != null)
|
||||
@@ -657,33 +729,33 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
],
|
||||
),
|
||||
),
|
||||
// Compass widget - top right
|
||||
if (_rotateMarkerWithHeading)
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: GestureDetector(
|
||||
onTap: () => _showDetailedCompass(
|
||||
context,
|
||||
contactsProvider.chatContactsWithLocation,
|
||||
messagesProvider.sarMarkers,
|
||||
),
|
||||
child: _CompassWidget(
|
||||
heading: _currentHeading ?? 0,
|
||||
hasHeading: _currentHeading != null,
|
||||
),
|
||||
// Compass widget - top right (always visible)
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: GestureDetector(
|
||||
onTap: () => _showDetailedCompass(
|
||||
context,
|
||||
contactsProvider.chatContactsWithLocation,
|
||||
messagesProvider.sarMarkers,
|
||||
),
|
||||
child: _CompassWidget(
|
||||
heading: _currentHeading ?? 0,
|
||||
hasHeading: _currentHeading != null,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Map legend overlay
|
||||
if (_showLegend)
|
||||
Positioned(
|
||||
top: _rotateMarkerWithHeading ? 80 : 16,
|
||||
right: 16,
|
||||
top: 80, // Position below compass (which is always visible now)
|
||||
left: 16,
|
||||
child: _MapLegend(
|
||||
teamMemberCount: contactsWithLocation.length,
|
||||
foundPersonCount: messagesProvider.foundPersonMarkers.length,
|
||||
fireCount: messagesProvider.fireMarkers.length,
|
||||
stagingAreaCount: messagesProvider.stagingAreaMarkers.length,
|
||||
objectCount: messagesProvider.objectMarkers.length,
|
||||
),
|
||||
),
|
||||
// Map controls - right side
|
||||
@@ -764,12 +836,14 @@ class _MapLegend extends StatelessWidget {
|
||||
final int foundPersonCount;
|
||||
final int fireCount;
|
||||
final int stagingAreaCount;
|
||||
final int objectCount;
|
||||
|
||||
const _MapLegend({
|
||||
required this.teamMemberCount,
|
||||
required this.foundPersonCount,
|
||||
required this.fireCount,
|
||||
required this.stagingAreaCount,
|
||||
required this.objectCount,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -812,6 +886,12 @@ class _MapLegend extends StatelessWidget {
|
||||
label: 'Staging',
|
||||
count: stagingAreaCount,
|
||||
),
|
||||
_LegendItem(
|
||||
icon: Icons.inventory_2,
|
||||
color: Colors.purple,
|
||||
label: 'Object',
|
||||
count: objectCount,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -975,12 +1055,16 @@ class _DetailedCompassDialog extends StatefulWidget {
|
||||
final double? initialHeading;
|
||||
final List<Contact> contacts;
|
||||
final List<SarMarker> sarMarkers;
|
||||
final Contact? preSelectedContact;
|
||||
final SarMarker? preSelectedSarMarker;
|
||||
|
||||
const _DetailedCompassDialog({
|
||||
required this.initialPosition,
|
||||
required this.initialHeading,
|
||||
required this.contacts,
|
||||
required this.sarMarkers,
|
||||
this.preSelectedContact,
|
||||
this.preSelectedSarMarker,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -1004,11 +1088,17 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
|
||||
bool _showFire = true;
|
||||
bool _showStagingArea = true;
|
||||
|
||||
// Selected item for isolation
|
||||
Contact? _selectedContact;
|
||||
SarMarker? _selectedSarMarker;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentHeading = widget.initialHeading;
|
||||
_currentPosition = widget.initialPosition;
|
||||
_selectedContact = widget.preSelectedContact;
|
||||
_selectedSarMarker = widget.preSelectedSarMarker;
|
||||
|
||||
// Subscribe to compass updates
|
||||
final compassStream = FlutterCompass.events;
|
||||
@@ -1063,6 +1153,8 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
|
||||
return _showFire;
|
||||
case SarMarkerType.stagingArea:
|
||||
return _showStagingArea;
|
||||
case SarMarkerType.object:
|
||||
return true; // Always show object markers (add filter if needed)
|
||||
case SarMarkerType.unknown:
|
||||
return true; // Always show unknown markers
|
||||
}
|
||||
@@ -1261,13 +1353,25 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
|
||||
heading: heading ?? 0,
|
||||
hasHeading: heading != null,
|
||||
currentPosition: position,
|
||||
contacts: _showContacts ? widget.contacts : [],
|
||||
sarMarkers: _getFilteredSarMarkers(),
|
||||
contacts: _selectedContact != null
|
||||
? [_selectedContact!]
|
||||
: (_selectedSarMarker != null
|
||||
? []
|
||||
: (_showContacts ? widget.contacts : [])),
|
||||
sarMarkers: _selectedSarMarker != null
|
||||
? [_selectedSarMarker!]
|
||||
: (_selectedContact != null
|
||||
? []
|
||||
: _getFilteredSarMarkers()),
|
||||
zoomLevel: _zoomLevel,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Selected item detail view
|
||||
if (_selectedContact != null || _selectedSarMarker != null)
|
||||
_buildSelectedItemDetail(context, heading, position),
|
||||
const SizedBox(height: 12),
|
||||
// Contacts list
|
||||
if (_showContacts && widget.contacts.isNotEmpty) _buildContactsList(context, heading, position),
|
||||
// SAR Markers list
|
||||
@@ -1335,6 +1439,230 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
|
||||
return _LocationFormatToggle(position: position);
|
||||
}
|
||||
|
||||
Widget _buildSelectedItemDetail(BuildContext context, double? heading, Position? position) {
|
||||
if (position == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
String title;
|
||||
IconData icon;
|
||||
Color color;
|
||||
double? bearing;
|
||||
double? distance;
|
||||
LatLng? targetLocation;
|
||||
String? additionalInfo;
|
||||
|
||||
if (_selectedContact != null) {
|
||||
title = _selectedContact!.displayName;
|
||||
icon = Icons.person;
|
||||
color = Colors.blue;
|
||||
targetLocation = _selectedContact!.displayLocation;
|
||||
|
||||
if (targetLocation != null) {
|
||||
bearing = _calculateBearing(
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
targetLocation.latitude,
|
||||
targetLocation.longitude,
|
||||
);
|
||||
distance = _calculateDistance(
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
targetLocation.latitude,
|
||||
targetLocation.longitude,
|
||||
);
|
||||
}
|
||||
|
||||
// Show battery if available
|
||||
if (_selectedContact!.telemetry?.batteryPercentage != null) {
|
||||
additionalInfo = 'Battery: ${_selectedContact!.telemetry!.batteryPercentage!.round()}%';
|
||||
}
|
||||
} else if (_selectedSarMarker != null) {
|
||||
title = _selectedSarMarker!.type.displayName;
|
||||
targetLocation = _selectedSarMarker!.location;
|
||||
additionalInfo = _selectedSarMarker!.timeAgo;
|
||||
|
||||
switch (_selectedSarMarker!.type) {
|
||||
case SarMarkerType.foundPerson:
|
||||
icon = Icons.person_pin;
|
||||
color = Colors.green;
|
||||
break;
|
||||
case SarMarkerType.fire:
|
||||
icon = Icons.local_fire_department;
|
||||
color = Colors.red;
|
||||
break;
|
||||
case SarMarkerType.stagingArea:
|
||||
icon = Icons.home_work;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case SarMarkerType.object:
|
||||
icon = Icons.inventory_2;
|
||||
color = Colors.purple;
|
||||
break;
|
||||
case SarMarkerType.unknown:
|
||||
icon = Icons.help_outline;
|
||||
color = Colors.grey;
|
||||
break;
|
||||
}
|
||||
|
||||
bearing = _calculateBearing(
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
targetLocation.latitude,
|
||||
targetLocation.longitude,
|
||||
);
|
||||
distance = _calculateDistance(
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
targetLocation.latitude,
|
||||
targetLocation.longitude,
|
||||
);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
elevation: 4,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header with icon and title
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: _selectedContact != null && _selectedContact!.roleEmoji != null
|
||||
? Text(
|
||||
_selectedContact!.roleEmoji!,
|
||||
style: const TextStyle(fontSize: 32),
|
||||
)
|
||||
: Icon(icon, size: 32, color: color),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (additionalInfo != null)
|
||||
Text(
|
||||
additionalInfo,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_selectedContact = null;
|
||||
_selectedSarMarker = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (bearing != null && distance != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
// Distance and bearing info
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildLargeInfoCard(
|
||||
context,
|
||||
'Distance',
|
||||
_formatDistance(distance),
|
||||
Icons.straighten,
|
||||
color,
|
||||
),
|
||||
_buildLargeInfoCard(
|
||||
context,
|
||||
'Bearing',
|
||||
'${bearing.round()}°',
|
||||
Icons.navigation,
|
||||
color,
|
||||
),
|
||||
_buildLargeInfoCard(
|
||||
context,
|
||||
'Direction',
|
||||
_bearingToCardinal(bearing),
|
||||
Icons.explore,
|
||||
color,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Coordinates
|
||||
if (targetLocation != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.location_on, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${targetLocation.latitude.toStringAsFixed(5)}, ${targetLocation.longitude.toStringAsFixed(5)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLargeInfoCard(
|
||||
BuildContext context,
|
||||
String label,
|
||||
String value,
|
||||
IconData icon,
|
||||
Color color,
|
||||
) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(icon, size: 28, color: color),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Convert decimal degrees to DMS (Degrees, Minutes, Seconds)
|
||||
String _formatDMS(double degrees, bool isLatitude) {
|
||||
final direction = isLatitude
|
||||
@@ -1404,17 +1732,30 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
color: _selectedContact == contact
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: _selectedContact == contact
|
||||
? Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(
|
||||
Icons.person,
|
||||
color: Colors.blue,
|
||||
size: 24,
|
||||
),
|
||||
title: Text(contact.advName),
|
||||
leading: contact.roleEmoji != null
|
||||
? Text(
|
||||
contact.roleEmoji!,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.person,
|
||||
color: Colors.blue,
|
||||
size: 24,
|
||||
),
|
||||
title: Text(contact.displayName),
|
||||
subtitle: Text(
|
||||
'${_bearingToCardinal(bearing)} • ${_formatDistance(distance)}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
@@ -1425,6 +1766,18 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (_selectedContact == contact) {
|
||||
// Deselect if already selected
|
||||
_selectedContact = null;
|
||||
} else {
|
||||
// Select this contact and deselect SAR marker
|
||||
_selectedContact = contact;
|
||||
_selectedSarMarker = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
@@ -1500,6 +1853,10 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
|
||||
markerColor = Colors.orange;
|
||||
markerIcon = Icons.home_work;
|
||||
break;
|
||||
case SarMarkerType.object:
|
||||
markerColor = Colors.purple;
|
||||
markerIcon = Icons.inventory_2;
|
||||
break;
|
||||
case SarMarkerType.unknown:
|
||||
markerColor = Colors.grey;
|
||||
markerIcon = Icons.help_outline;
|
||||
@@ -1509,8 +1866,16 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
color: _selectedSarMarker == marker
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: _selectedSarMarker == marker
|
||||
? Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
@@ -1530,6 +1895,18 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (_selectedSarMarker == marker) {
|
||||
// Deselect if already selected
|
||||
_selectedSarMarker = null;
|
||||
} else {
|
||||
// Select this marker and deselect contact
|
||||
_selectedSarMarker = marker;
|
||||
_selectedContact = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
@@ -1862,6 +2239,9 @@ class _LargeCompassPainter extends CustomPainter {
|
||||
case SarMarkerType.stagingArea:
|
||||
markerColor = Colors.orange;
|
||||
break;
|
||||
case SarMarkerType.object:
|
||||
markerColor = Colors.purple;
|
||||
break;
|
||||
case SarMarkerType.unknown:
|
||||
markerColor = Colors.grey;
|
||||
break;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../providers/messages_provider.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../providers/map_provider.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
|
||||
class MessagesTab extends StatefulWidget {
|
||||
final VoidCallback onNavigateToMap;
|
||||
@@ -20,7 +21,6 @@ class MessagesTab extends StatefulWidget {
|
||||
class _MessagesTabState extends State<MessagesTab> {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
Contact? _selectedContact;
|
||||
int _characterCount = 0;
|
||||
static const int _maxCharacters = 160;
|
||||
|
||||
@@ -48,6 +48,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
if (text.isEmpty) return;
|
||||
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
if (!mounted) return;
|
||||
@@ -60,23 +61,15 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedContact == null) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Please select a recipient'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Send to channel/room or direct contact
|
||||
if (_selectedContact!.isRoom) {
|
||||
// For rooms/channels, use the first byte of outPath as channel index
|
||||
final channelIdx = _selectedContact!.outPath.isNotEmpty
|
||||
? _selectedContact!.outPath[0]
|
||||
// Default to sending to room/channel (first available room)
|
||||
final rooms = contactsProvider.rooms;
|
||||
|
||||
if (rooms.isNotEmpty) {
|
||||
// Send to first available room
|
||||
final defaultRoom = rooms.first;
|
||||
final channelIdx = defaultRoom.outPath.isNotEmpty
|
||||
? defaultRoom.outPath[0]
|
||||
: 0;
|
||||
|
||||
await connectionProvider.sendChannelMessage(
|
||||
@@ -84,11 +77,15 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
text: text,
|
||||
);
|
||||
} else {
|
||||
// For direct contacts (chat type)
|
||||
await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: _selectedContact!.publicKey,
|
||||
text: text,
|
||||
// No rooms available, show error
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No channels available'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_textController.clear();
|
||||
@@ -113,130 +110,92 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
}
|
||||
|
||||
void _showContactSelector() {
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final allContacts = contactsProvider.contacts;
|
||||
void _showSarDialog() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => _SarUpdateSheet(
|
||||
onSend: (sarType, position, notes) async {
|
||||
await _sendSarMessage(sarType, position, notes);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (allContacts.isEmpty) {
|
||||
Future<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(
|
||||
const SnackBar(
|
||||
content: Text('No contacts available'),
|
||||
backgroundColor: Colors.orange,
|
||||
content: Text('Not connected to device'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Group contacts by type
|
||||
final chatContacts = contactsProvider.chatContacts;
|
||||
final rooms = contactsProvider.rooms;
|
||||
final repeaters = contactsProvider.repeaters;
|
||||
try {
|
||||
// Format: S:<emoji>:<latitude>,<longitude>
|
||||
final sarMessage = 'S:${sarType.emoji}:${position.latitude},${position.longitude}';
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Select Recipient',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
// Add notes if provided
|
||||
final fullMessage = notes != null && notes.isNotEmpty
|
||||
? '$sarMessage $notes'
|
||||
: sarMessage;
|
||||
|
||||
// Default to sending to room/channel (first available room)
|
||||
final rooms = contactsProvider.rooms;
|
||||
|
||||
if (rooms.isNotEmpty) {
|
||||
// Send to first available room
|
||||
final defaultRoom = rooms.first;
|
||||
final channelIdx = defaultRoom.outPath.isNotEmpty
|
||||
? defaultRoom.outPath[0]
|
||||
: 0;
|
||||
|
||||
await connectionProvider.sendChannelMessage(
|
||||
channelIdx: channelIdx,
|
||||
text: fullMessage,
|
||||
);
|
||||
} else {
|
||||
// No rooms available, show error
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No channels available'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
// Chat contacts section
|
||||
if (chatContacts.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
'Team Members',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
...chatContacts.map((contact) => _buildContactTile(
|
||||
contact: contact,
|
||||
icon: Icons.person,
|
||||
color: Colors.blue,
|
||||
)),
|
||||
],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Rooms/Channels section
|
||||
if (rooms.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
'Channels',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
...rooms.map((contact) => _buildContactTile(
|
||||
contact: contact,
|
||||
icon: Icons.tag,
|
||||
color: Colors.purple,
|
||||
)),
|
||||
],
|
||||
|
||||
// Repeaters section (informational only)
|
||||
if (repeaters.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
'Repeaters (Read-only)',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
...repeaters.map((contact) => ListTile(
|
||||
enabled: false,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.grey,
|
||||
child: const Icon(Icons.router, color: Colors.white, size: 20),
|
||||
),
|
||||
title: Text(contact.advName),
|
||||
subtitle: Text(contact.timeSinceLastSeen),
|
||||
)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${sarType.displayName} marker sent'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to send SAR marker: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildContactTile({
|
||||
required Contact contact,
|
||||
required IconData icon,
|
||||
required Color color,
|
||||
}) {
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: color,
|
||||
child: Icon(icon, color: Colors.white, size: 20),
|
||||
),
|
||||
title: Text(contact.advName),
|
||||
subtitle: Text(contact.timeSinceLastSeen),
|
||||
trailing: _selectedContact?.publicKeyHex == contact.publicKeyHex
|
||||
? const Icon(Icons.check_circle, color: Colors.green)
|
||||
: null,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedContact = contact;
|
||||
});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -308,119 +267,67 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// Contact selector (compact)
|
||||
if (_selectedContact != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_selectedContact!.isRoom
|
||||
? Icons.tag
|
||||
: Icons.person,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_selectedContact!.advName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedContact = null;
|
||||
});
|
||||
},
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(4),
|
||||
child: Icon(Icons.close, size: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// SAR quick action button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_location_alt),
|
||||
tooltip: 'Send SAR marker',
|
||||
onPressed: _showSarDialog,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
|
||||
// Text input row
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// Contact selector button (compact)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.contacts, size: 20),
|
||||
onPressed: _showContactSelector,
|
||||
tooltip: 'Select contact',
|
||||
padding: const EdgeInsets.all(8),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Text field with embedded send button
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
focusNode: _focusNode,
|
||||
maxLength: _maxCharacters,
|
||||
maxLines: null,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Message to channel...',
|
||||
hintStyle: const TextStyle(fontSize: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
|
||||
// Text field (compact)
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
focusNode: _focusNode,
|
||||
maxLength: _maxCharacters,
|
||||
maxLines: null,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Message...',
|
||||
hintStyle: const TextStyle(fontSize: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
isDense: true,
|
||||
counterText: '$_characterCount/$_maxCharacters',
|
||||
counterStyle: TextStyle(
|
||||
fontSize: 10,
|
||||
color: _characterCount > _maxCharacters * 0.9
|
||||
? Colors.orange
|
||||
: Theme.of(context).textTheme.bodySmall?.color,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
isDense: true,
|
||||
counterText: _characterCount >= 150
|
||||
? '$_characterCount/$_maxCharacters'
|
||||
: '',
|
||||
counterStyle: TextStyle(
|
||||
fontSize: 10,
|
||||
color: _characterCount > _maxCharacters * 0.9
|
||||
? Colors.orange
|
||||
: Theme.of(context).textTheme.bodySmall?.color,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
Icons.send_rounded,
|
||||
size: 22,
|
||||
color: _textController.text.trim().isEmpty
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
onPressed: _textController.text.trim().isEmpty
|
||||
? null
|
||||
: _sendMessage,
|
||||
tooltip: 'Send',
|
||||
),
|
||||
),
|
||||
|
||||
// Send button (compact)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send, size: 20),
|
||||
onPressed: _textController.text.trim().isEmpty
|
||||
? null
|
||||
: _sendMessage,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
padding: const EdgeInsets.all(8),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
),
|
||||
),
|
||||
],
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -566,3 +473,512 @@ class _MessageBubble extends StatelessWidget {
|
||||
return Theme.of(context).colorScheme.primaryContainer;
|
||||
}
|
||||
}
|
||||
|
||||
// SAR Update Sheet
|
||||
class _SarUpdateSheet extends StatefulWidget {
|
||||
final Future<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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,14 +20,14 @@ class SampleDataGenerator {
|
||||
final now = DateTime.now();
|
||||
|
||||
final teamNames = [
|
||||
'Alpha Team Lead',
|
||||
'Bravo Scout',
|
||||
'Charlie Medic',
|
||||
'Delta Navigator',
|
||||
'Echo Support',
|
||||
'Foxtrot Runner',
|
||||
'Golf Comms',
|
||||
'Hotel Base',
|
||||
'👮Police Lead',
|
||||
'🚁Drone Operator',
|
||||
'🧑🏻🚒Firefighter Alpha',
|
||||
'🧑⚕️Medic Charlie',
|
||||
'📡Command Delta',
|
||||
'🚒Fire Engine',
|
||||
'👨✈️Air Support',
|
||||
'🧑💼Base Coordinator',
|
||||
];
|
||||
|
||||
final channelNames = [
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
|
||||
class MapMarkers {
|
||||
static List<Marker> createTeamMemberMarkers(
|
||||
List<Contact> contacts,
|
||||
BuildContext context,
|
||||
) {
|
||||
BuildContext context, {
|
||||
Function(Contact)? onContactTap,
|
||||
double mapRotation = 0,
|
||||
}) {
|
||||
return contacts.map((contact) {
|
||||
final location = contact.displayLocation;
|
||||
if (location == null) return null;
|
||||
@@ -17,21 +18,29 @@ class MapMarkers {
|
||||
point: location,
|
||||
width: 80,
|
||||
height: 100,
|
||||
child: GestureDetector(
|
||||
onTap: () => _showContactInfo(context, contact),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Battery indicator
|
||||
if (contact.displayBattery != null)
|
||||
rotate: false, // Don't rotate the entire marker with map
|
||||
child: Transform.rotate(
|
||||
angle: -mapRotation * 3.14159265359 / 180,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (onContactTap != null) {
|
||||
onContactTap(contact);
|
||||
} else {
|
||||
_showContactInfo(context, contact);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Location update time indicator
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: _getBatteryColor(contact.displayBattery!),
|
||||
color: _getLocationAgeColor(contact),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
'${contact.displayBattery!.round()}%',
|
||||
contact.timeSinceLocationUpdate,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
@@ -39,50 +48,51 @@ class MapMarkers {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (contact.displayBattery != null) const SizedBox(height: 2),
|
||||
// Marker icon
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: const Icon(
|
||||
Icons.person,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Name label
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 80),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
contact.advName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
const SizedBox(height: 2),
|
||||
// Marker icon
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: const Icon(
|
||||
Icons.person,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 2),
|
||||
// Name label (without emoji)
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 80),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
contact.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -91,77 +101,89 @@ class MapMarkers {
|
||||
|
||||
static List<Marker> createSarMarkers(
|
||||
List<SarMarker> sarMarkers,
|
||||
BuildContext context,
|
||||
) {
|
||||
BuildContext context, {
|
||||
Function(SarMarker)? onSarMarkerTap,
|
||||
double mapRotation = 0,
|
||||
}) {
|
||||
return sarMarkers.map((marker) {
|
||||
return Marker(
|
||||
point: marker.location,
|
||||
width: 90,
|
||||
height: 100,
|
||||
child: GestureDetector(
|
||||
onTap: () => _showSarMarkerInfo(context, marker),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Time ago label
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: _getSarMarkerColor(marker.type),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
marker.timeAgo,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.bold,
|
||||
rotate: false, // Don't rotate the entire marker with map
|
||||
child: Transform.rotate(
|
||||
angle: -mapRotation * 3.14159265359 / 180,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (onSarMarkerTap != null) {
|
||||
onSarMarkerTap(marker);
|
||||
} else {
|
||||
_showSarMarkerInfo(context, marker);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Time ago label
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: _getSarMarkerColor(marker.type),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Marker emoji/icon
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _getSarMarkerColor(marker.type),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
child: Text(
|
||||
marker.timeAgo,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Text(
|
||||
marker.type.emoji,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Type label
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 90),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
marker.type.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 2),
|
||||
// Marker emoji/icon
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _getSarMarkerColor(marker.type),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Text(
|
||||
marker.type.emoji,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Type label
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 90),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
marker.type.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -169,6 +191,11 @@ class MapMarkers {
|
||||
}
|
||||
|
||||
static void _showContactInfo(BuildContext context, Contact contact) {
|
||||
// Import provider to get all contacts and SAR markers for detailed view
|
||||
// This will be handled by importing the screen's detailed compass dialog
|
||||
// Since we can't directly access _DetailedCompassDialog from here,
|
||||
// we'll pass a callback to the screen
|
||||
// For now, show the simple dialog as a fallback
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
@@ -176,7 +203,7 @@ class MapMarkers {
|
||||
children: [
|
||||
const Icon(Icons.person, color: Colors.blue),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(contact.advName)),
|
||||
Expanded(child: Text(contact.displayName)),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
@@ -242,10 +269,15 @@ class MapMarkers {
|
||||
);
|
||||
}
|
||||
|
||||
static Color _getBatteryColor(double percentage) {
|
||||
if (percentage > 50) return Colors.green;
|
||||
if (percentage > 20) return Colors.orange;
|
||||
return Colors.red;
|
||||
static Color _getLocationAgeColor(Contact contact) {
|
||||
final updateTime = contact.locationUpdateTime;
|
||||
if (updateTime == null) return Colors.grey;
|
||||
|
||||
final diff = DateTime.now().difference(updateTime);
|
||||
if (diff.inMinutes < 5) return Colors.green; // Very recent
|
||||
if (diff.inMinutes < 30) return Colors.blue; // Recent
|
||||
if (diff.inHours < 2) return Colors.orange; // Getting old
|
||||
return Colors.red; // Stale
|
||||
}
|
||||
|
||||
static Color _getSarMarkerColor(SarMarkerType type) {
|
||||
@@ -256,6 +288,8 @@ class MapMarkers {
|
||||
return Colors.red;
|
||||
case SarMarkerType.stagingArea:
|
||||
return Colors.orange;
|
||||
case SarMarkerType.object:
|
||||
return Colors.purple;
|
||||
case SarMarkerType.unknown:
|
||||
return Colors.grey;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user