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:
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(flutter analyze:*)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user