mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
feat: Add new contact and message tracking features, including unread status and removal functionality
This commit is contained in:
@@ -53,6 +53,9 @@ class Contact {
|
||||
// Telemetry data (updated separately)
|
||||
ContactTelemetry? telemetry;
|
||||
|
||||
// UI state tracking
|
||||
final bool isNew; // Whether contact is newly added and not yet viewed
|
||||
|
||||
Contact({
|
||||
required this.publicKey,
|
||||
required this.type,
|
||||
@@ -65,6 +68,7 @@ class Contact {
|
||||
required this.advLon,
|
||||
required this.lastMod,
|
||||
this.telemetry,
|
||||
this.isNew = false,
|
||||
});
|
||||
|
||||
/// Get public key as hex string (first 8 bytes)
|
||||
@@ -250,6 +254,7 @@ class Contact {
|
||||
int? advLon,
|
||||
int? lastMod,
|
||||
ContactTelemetry? telemetry,
|
||||
bool? isNew,
|
||||
}) {
|
||||
return Contact(
|
||||
publicKey: publicKey ?? this.publicKey,
|
||||
@@ -263,6 +268,7 @@ class Contact {
|
||||
advLon: advLon ?? this.advLon,
|
||||
lastMod: lastMod ?? this.lastMod,
|
||||
telemetry: telemetry ?? this.telemetry,
|
||||
isNew: isNew ?? this.isNew,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,9 @@ class Message {
|
||||
final DateTime? deliveredAt; // When delivery was confirmed
|
||||
final Uint8List? recipientPublicKey; // Full 32-byte public key of recipient (for retry)
|
||||
|
||||
// Read status tracking
|
||||
final bool isRead; // Whether message has been read by user
|
||||
|
||||
Message({
|
||||
required this.id,
|
||||
required this.messageType,
|
||||
@@ -83,6 +86,7 @@ class Message {
|
||||
this.roundTripTimeMs,
|
||||
this.deliveredAt,
|
||||
this.recipientPublicKey,
|
||||
this.isRead = false,
|
||||
});
|
||||
|
||||
/// Get sender public key as hex string
|
||||
@@ -222,6 +226,7 @@ class Message {
|
||||
int? roundTripTimeMs,
|
||||
DateTime? deliveredAt,
|
||||
Uint8List? recipientPublicKey,
|
||||
bool? isRead,
|
||||
}) {
|
||||
return Message(
|
||||
id: id ?? this.id,
|
||||
@@ -243,6 +248,7 @@ class Message {
|
||||
roundTripTimeMs: roundTripTimeMs ?? this.roundTripTimeMs,
|
||||
deliveredAt: deliveredAt ?? this.deliveredAt,
|
||||
recipientPublicKey: recipientPublicKey ?? this.recipientPublicKey,
|
||||
isRead: isRead ?? this.isRead,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1098,6 +1098,26 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a contact from the companion radio
|
||||
///
|
||||
/// Deletes the contact from the device's internal contact table.
|
||||
/// The contact will no longer appear in the contact list and all
|
||||
/// routing information will be cleared.
|
||||
Future<void> removeContact(Uint8List contactPublicKey) async {
|
||||
if (!_bleService.isConnected) {
|
||||
_error = 'Not connected to device';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _bleService.removeContact(contactPublicKey);
|
||||
} catch (e) {
|
||||
_error = 'Failed to remove contact: $e';
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear error message
|
||||
void clearError() {
|
||||
_error = null;
|
||||
|
||||
@@ -125,7 +125,18 @@ class ContactsProvider with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
_contacts[contact.publicKeyHex] = contact;
|
||||
// Check if this is a new contact
|
||||
final isNewContact = !_contacts.containsKey(contact.publicKeyHex);
|
||||
|
||||
// If it's a new contact, mark it as new
|
||||
if (isNewContact) {
|
||||
_contacts[contact.publicKeyHex] = contact.copyWith(isNew: true);
|
||||
} else {
|
||||
// Keep existing isNew status when updating
|
||||
final existingContact = _contacts[contact.publicKeyHex]!;
|
||||
_contacts[contact.publicKeyHex] = contact.copyWith(isNew: existingContact.isNew);
|
||||
}
|
||||
|
||||
_persistContacts();
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -242,6 +253,35 @@ class ContactsProvider with ChangeNotifier {
|
||||
return contacts.where((c) => c.isRecentlySeen).toList();
|
||||
}
|
||||
|
||||
/// Get count of new contacts (not yet viewed)
|
||||
int get newContactsCount =>
|
||||
contacts.where((c) => c.isNew && !c.isChannel).length;
|
||||
|
||||
/// Mark all contacts as viewed (not new)
|
||||
void markAllAsViewed() {
|
||||
bool hasChanges = false;
|
||||
_contacts.forEach((key, contact) {
|
||||
if (contact.isNew && !contact.isChannel) {
|
||||
_contacts[key] = contact.copyWith(isNew: false);
|
||||
hasChanges = true;
|
||||
}
|
||||
});
|
||||
if (hasChanges) {
|
||||
_persistContacts();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a specific contact as viewed (not new)
|
||||
void markAsViewed(String publicKeyHex) {
|
||||
final contact = _contacts[publicKeyHex];
|
||||
if (contact != null && contact.isNew) {
|
||||
_contacts[publicKeyHex] = contact.copyWith(isNew: false);
|
||||
_persistContacts();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all contacts
|
||||
void clearContacts() {
|
||||
_contacts.clear();
|
||||
@@ -250,7 +290,21 @@ class ContactsProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Remove a contact
|
||||
void removeContact(String publicKeyHex) {
|
||||
/// [onRemoveFromDevice] - Optional callback to remove contact from BLE device
|
||||
Future<void> removeContact(
|
||||
String publicKeyHex, {
|
||||
Future<void> Function(Uint8List)? onRemoveFromDevice,
|
||||
}) async {
|
||||
// Get the contact before removing
|
||||
final contact = _contacts[publicKeyHex];
|
||||
if (contact == null) return;
|
||||
|
||||
// Remove from device first if callback provided
|
||||
if (onRemoveFromDevice != null) {
|
||||
await onRemoveFromDevice(contact.publicKey);
|
||||
}
|
||||
|
||||
// Then remove from local storage
|
||||
_contacts.remove(publicKeyHex);
|
||||
_persistContacts();
|
||||
notifyListeners();
|
||||
|
||||
@@ -49,6 +49,14 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
/// Get count of unread messages (excluding sent messages and system messages)
|
||||
int get unreadCount => _messages
|
||||
.where((m) =>
|
||||
!m.isRead &&
|
||||
!m.isSentMessage &&
|
||||
!m.isSystemMessage)
|
||||
.length;
|
||||
|
||||
/// Initialize and load persisted messages
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
@@ -287,6 +295,31 @@ class MessagesProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Mark all messages as read
|
||||
void markAllAsRead() {
|
||||
bool hasChanges = false;
|
||||
for (int i = 0; i < _messages.length; i++) {
|
||||
if (!_messages[i].isRead && !_messages[i].isSentMessage && !_messages[i].isSystemMessage) {
|
||||
_messages[i] = _messages[i].copyWith(isRead: true);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
if (hasChanges) {
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a specific message as read
|
||||
void markAsRead(String messageId) {
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (index != -1 && !_messages[index].isRead) {
|
||||
_messages[index] = _messages[index].copyWith(isRead: true);
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a specific message by ID
|
||||
void deleteMessage(String messageId) {
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
@@ -412,9 +445,10 @@ class MessagesProvider with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add message with sending status
|
||||
// Add message with sending status and mark as read (sent messages are always read)
|
||||
final sendingMessage = enhancedMessage.copyWith(
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
isRead: true, // Sent messages are always marked as read
|
||||
);
|
||||
_messages.add(sendingMessage);
|
||||
print(' ✅ Message added to list at index ${_messages.length - 1}');
|
||||
|
||||
@@ -20,6 +20,10 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_getCurrentLocation();
|
||||
// Mark all contacts as viewed when tab is opened
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<ContactsProvider>().markAllAsViewed();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _getCurrentLocation() async {
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:provider/provider.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../providers/messages_provider.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'messages_tab.dart';
|
||||
import 'contacts_tab.dart';
|
||||
@@ -369,24 +371,43 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
const MapTab(),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: Container(
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, -2),
|
||||
bottomNavigationBar: Consumer2<MessagesProvider, ContactsProvider>(
|
||||
builder: (context, messagesProvider, contactsProvider, child) {
|
||||
final unreadCount = messagesProvider.unreadCount;
|
||||
final newContactsCount = contactsProvider.newContactsCount;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(icon: Icon(Icons.message), text: 'Messages'),
|
||||
Tab(icon: Icon(Icons.contacts), text: 'Contacts'),
|
||||
Tab(icon: Icon(Icons.map), text: 'Map'),
|
||||
],
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: [
|
||||
Tab(
|
||||
icon: _buildTabIconWithBadge(
|
||||
Icons.message,
|
||||
unreadCount,
|
||||
),
|
||||
text: 'Messages',
|
||||
),
|
||||
Tab(
|
||||
icon: _buildTabIconWithBadge(
|
||||
Icons.contacts,
|
||||
newContactsCount,
|
||||
),
|
||||
text: 'Contacts',
|
||||
),
|
||||
const Tab(icon: Icon(Icons.map), text: 'Map'),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -756,4 +777,42 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
if (rssi > -70) return Colors.orange;
|
||||
return Colors.red;
|
||||
}
|
||||
|
||||
/// Build tab icon with badge showing count
|
||||
Widget _buildTabIconWithBadge(IconData icon, int count) {
|
||||
if (count == 0) {
|
||||
return Icon(icon);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Icon(icon),
|
||||
Positioned(
|
||||
right: -8,
|
||||
top: -4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 18,
|
||||
minHeight: 18,
|
||||
),
|
||||
child: Text(
|
||||
count > 99 ? '99+' : count.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textController.addListener(_updateCharacterCount);
|
||||
// Mark all messages as read when tab is opened
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<MessagesProvider>().markAllAsRead();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -532,10 +536,25 @@ class _MessageBubble extends StatelessWidget {
|
||||
final isSarMarker = message.isSarMarker;
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// Get device's own public key from ConnectionProvider
|
||||
// Determine if this is own message
|
||||
// Use isSentMessage (delivery status) as primary check since it's more reliable
|
||||
// after loading from storage
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
|
||||
final isOwnMessage = message.isFromSelf(selfPublicKey);
|
||||
final isOwnMessage = message.isSentMessage || message.isFromSelf(selfPublicKey);
|
||||
|
||||
// Debug logging for sent messages
|
||||
if (message.isSentMessage) {
|
||||
debugPrint('🔍 [MessageBubble] Sent message check:');
|
||||
debugPrint(' Message ID: ${message.id}');
|
||||
debugPrint(' Delivery Status: ${message.deliveryStatus.name}');
|
||||
debugPrint(' isSentMessage: ${message.isSentMessage}');
|
||||
debugPrint(' isOwnMessage: $isOwnMessage');
|
||||
debugPrint(' Has recipientPublicKey: ${message.recipientPublicKey != null}');
|
||||
if (message.recipientPublicKey != null) {
|
||||
debugPrint(' Recipient key (first 12 hex): ${message.recipientPublicKey!.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join()}');
|
||||
}
|
||||
}
|
||||
|
||||
// Look up contact information for rich display name
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
@@ -557,6 +576,50 @@ class _MessageBubble extends StatelessWidget {
|
||||
? 'You'
|
||||
: message.getRichDisplayName(senderContact);
|
||||
|
||||
// For sent direct messages, look up recipient contact
|
||||
dynamic recipientContact;
|
||||
String? recipientDisplayName;
|
||||
if (isOwnMessage && message.isContactMessage && message.recipientPublicKey != null) {
|
||||
// Find recipient by public key
|
||||
final recipientKeyHex = message.recipientPublicKey!
|
||||
.sublist(0, message.recipientPublicKey!.length < 6 ? message.recipientPublicKey!.length : 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
|
||||
debugPrint('🔍 [MessageBubble] Looking up recipient:');
|
||||
debugPrint(' Recipient key hex: $recipientKeyHex');
|
||||
debugPrint(' Available contacts: ${contactsProvider.contacts.length}');
|
||||
|
||||
// Debug: Print all contact keys for comparison
|
||||
for (final c in contactsProvider.contacts) {
|
||||
debugPrint(' Contact: ${c.displayName ?? c.advName}');
|
||||
debugPrint(' Key: ${c.publicKeyHex}');
|
||||
debugPrint(' First 12 chars: ${c.publicKeyHex.substring(0, c.publicKeyHex.length >= 12 ? 12 : c.publicKeyHex.length)}');
|
||||
debugPrint(' Matches: ${c.publicKeyHex.startsWith(recipientKeyHex)}');
|
||||
}
|
||||
|
||||
recipientContact = contactsProvider.contacts.where((c) {
|
||||
final matches = c.publicKeyHex.startsWith(recipientKeyHex);
|
||||
if (matches) {
|
||||
debugPrint(' ✅ Found match: ${c.displayName ?? c.advName}');
|
||||
}
|
||||
return matches;
|
||||
}).firstOrNull;
|
||||
|
||||
if (recipientContact != null) {
|
||||
// Get rich display name with emoji
|
||||
final roleEmoji = recipientContact.roleEmoji;
|
||||
if (roleEmoji != null && roleEmoji.isNotEmpty) {
|
||||
recipientDisplayName = '$roleEmoji ${recipientContact.displayName}';
|
||||
} else {
|
||||
recipientDisplayName = recipientContact.displayName ?? recipientContact.advName;
|
||||
}
|
||||
debugPrint(' Final recipient name: $recipientDisplayName');
|
||||
} else {
|
||||
debugPrint(' ❌ No recipient contact found');
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: Log message details
|
||||
if (message.text.startsWith('S:')) {
|
||||
debugPrint('🎨 [MessageBubble] Rendering SAR message:');
|
||||
@@ -569,24 +632,29 @@ class _MessageBubble extends StatelessWidget {
|
||||
onTap: onTap,
|
||||
onLongPress: () => _showMessageOptions(context),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSarMarker
|
||||
? _getSarMarkerColor(context, isDarkMode)
|
||||
: _getMessageBubbleColor(context, isOwnMessage, isDarkMode),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isSarMarker
|
||||
? Border.all(
|
||||
color: _getSarMarkerBorderColor(context, isDarkMode),
|
||||
width: 3,
|
||||
width: 2,
|
||||
)
|
||||
: isOwnMessage
|
||||
? Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
|
||||
width: 2,
|
||||
width: 1.5,
|
||||
)
|
||||
: null,
|
||||
: !message.isRead && !message.isSentMessage && !message.isSystemMessage
|
||||
? Border.all(
|
||||
color: Colors.blue,
|
||||
width: 1.5,
|
||||
)
|
||||
: null,
|
||||
boxShadow: isSarMarker
|
||||
? [
|
||||
BoxShadow(
|
||||
@@ -603,6 +671,17 @@ class _MessageBubble extends StatelessWidget {
|
||||
// Header: Sender and time
|
||||
Row(
|
||||
children: [
|
||||
// Unread indicator badge
|
||||
if (!message.isRead && !message.isSentMessage && !message.isSystemMessage && !isSarMarker)
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
if (isSarMarker)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -648,6 +727,23 @@ class _MessageBubble extends StatelessWidget {
|
||||
color: isOwnMessage ? Theme.of(context).colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
// Show recipient for sent direct messages
|
||||
if (isOwnMessage && message.isContactMessage && recipientDisplayName != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.arrow_forward,
|
||||
size: 14,
|
||||
color: Theme.of(context).textTheme.labelSmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
recipientDisplayName,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
const Spacer(),
|
||||
Text(
|
||||
@@ -658,7 +754,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// SAR marker content (simplified design matching message history)
|
||||
if (isSarMarker && message.sarMarkerType != null) ...[
|
||||
@@ -666,23 +762,23 @@ class _MessageBubble extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
message.sarMarkerType!.emoji,
|
||||
style: const TextStyle(fontSize: 32),
|
||||
style: const TextStyle(fontSize: 28),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
message.sarMarkerType!.displayName,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (message.sarGpsCoordinates != null)
|
||||
Text(
|
||||
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
@@ -691,18 +787,11 @@ class _MessageBubble extends StatelessWidget {
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Tap to view on map',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
]
|
||||
// Regular message content
|
||||
else
|
||||
@@ -713,16 +802,16 @@ class _MessageBubble extends StatelessWidget {
|
||||
|
||||
// Delivery status for sent messages
|
||||
if (message.isSentMessage) ...[
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_getDeliveryStatusIcon(message.deliveryStatus),
|
||||
size: 14,
|
||||
size: 12,
|
||||
color: _getDeliveryStatusColor(message.deliveryStatus),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
message.deliveryStatusText,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
@@ -732,11 +821,11 @@ class _MessageBubble extends StatelessWidget {
|
||||
),
|
||||
// Show retry button for failed messages
|
||||
if (message.deliveryStatus == MessageDeliveryStatus.failed) ...[
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 6),
|
||||
GestureDetector(
|
||||
onTap: () => _retryFailedMessage(context, message),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
|
||||
@@ -395,6 +395,15 @@ class MeshCoreBleService {
|
||||
await _commandSender.writeData(FrameBuilder.buildResetPath(contactPublicKey));
|
||||
}
|
||||
|
||||
/// Remove a contact from the companion radio
|
||||
Future<void> removeContact(Uint8List contactPublicKey) async {
|
||||
print('🗑️ [BLE] Removing contact from companion radio:');
|
||||
print(' Public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
||||
|
||||
await _commandSender.writeData(FrameBuilder.buildRemoveContact(contactPublicKey));
|
||||
print('✅ [BLE] CMD_REMOVE_CONTACT sent');
|
||||
}
|
||||
|
||||
/// Clear packet logs
|
||||
void clearPacketLogs() {
|
||||
_commandSender.clearPacketLogs();
|
||||
|
||||
@@ -128,6 +128,7 @@ class MessageStorageService {
|
||||
'recipientPublicKey': message.recipientPublicKey != null
|
||||
? base64Encode(message.recipientPublicKey!)
|
||||
: null,
|
||||
'isRead': message.isRead,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,6 +181,7 @@ class MessageStorageService {
|
||||
? Uint8List.fromList(
|
||||
base64Decode(json['recipientPublicKey'] as String))
|
||||
: null,
|
||||
isRead: json['isRead'] as bool? ?? false,
|
||||
);
|
||||
} catch (e) {
|
||||
print('❌ [MessageStorage] Error parsing message from JSON: $e');
|
||||
|
||||
@@ -235,4 +235,12 @@ class FrameBuilder {
|
||||
writer.writeBytes(contactPublicKey); // 32 bytes
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
/// Build RemoveContact command - removes a contact from the device
|
||||
static Uint8List buildRemoveContact(Uint8List contactPublicKey) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdRemoveContact); // 0x0F (15)
|
||||
writer.writeBytes(contactPublicKey); // 32 bytes
|
||||
return writer.toBytes();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/room_login_state.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../providers/map_provider.dart';
|
||||
import 'direct_message_sheet.dart';
|
||||
import 'room_login_sheet.dart';
|
||||
@@ -66,7 +67,22 @@ class ContactTile extends StatelessWidget {
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
// Room login status indicator badge
|
||||
// New contact indicator badge (top-right)
|
||||
if (contact.isNew)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Room login status indicator badge (bottom-right)
|
||||
if (contact.type == ContactType.room && roomLoginState != null)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
@@ -361,6 +377,62 @@ class ContactTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteConfirmation(BuildContext context, Contact contact) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Contact'),
|
||||
content: Text(
|
||||
'Are you sure you want to delete "${contact.displayName}"?\n\n'
|
||||
'This will remove the contact from both the app and the companion radio device.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context); // Close confirmation dialog
|
||||
Navigator.pop(context); // Close contact details sheet
|
||||
await _deleteContact(context, contact);
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteContact(BuildContext context, Contact contact) async {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
|
||||
try {
|
||||
// Show loading toast
|
||||
ToastLogger.info(context, 'Removing ${contact.displayName}...');
|
||||
|
||||
// Remove contact from provider (which will also remove from device)
|
||||
await contactsProvider.removeContact(
|
||||
contact.publicKeyHex,
|
||||
onRemoveFromDevice: (publicKey) async {
|
||||
if (connectionProvider.deviceInfo.isConnected) {
|
||||
await connectionProvider.removeContact(publicKey);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ToastLogger.success(context, 'Contact "${contact.displayName}" removed');
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ToastLogger.error(context, 'Failed to remove contact: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showContactDetails(BuildContext context, Contact contact) {
|
||||
// Get room login state
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
@@ -684,6 +756,23 @@ class ContactTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
// Delete Contact button (for all contact types except Public Channel)
|
||||
if (contact.advName != 'Public Channel') ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _showDeleteConfirmation(context, contact),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: const Text('Delete Contact'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
side: const BorderSide(color: Colors.red),
|
||||
foregroundColor: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/messages_provider.dart';
|
||||
import '../../utils/toast_logger.dart';
|
||||
|
||||
class DirectMessageSheet extends StatefulWidget {
|
||||
@@ -44,6 +47,7 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
|
||||
if (text.isEmpty) return;
|
||||
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
if (!mounted) return;
|
||||
@@ -52,13 +56,44 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Create message ID
|
||||
final messageId = '${DateTime.now().millisecondsSinceEpoch}_dm_sent';
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
// Get current device's public key (first 6 bytes)
|
||||
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
|
||||
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
|
||||
|
||||
// Create sent message object with recipient public key for retry support
|
||||
final sentMessage = Message(
|
||||
id: messageId,
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: senderPublicKeyPrefix,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp,
|
||||
text: text,
|
||||
receivedAt: DateTime.now(),
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
recipientPublicKey: widget.contact.publicKey, // Store recipient for retry
|
||||
);
|
||||
|
||||
// Add to messages list with "sending" status
|
||||
messagesProvider.addSentMessage(sentMessage);
|
||||
|
||||
// Send direct message to contact (include contact for path logging)
|
||||
await connectionProvider.sendTextMessage(
|
||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: widget.contact.publicKey,
|
||||
text: text,
|
||||
messageId: messageId, // Pass message ID for tracking
|
||||
contact: widget.contact,
|
||||
);
|
||||
|
||||
if (!sentSuccessfully) {
|
||||
// Mark message as failed if sending failed
|
||||
messagesProvider.markMessageFailed(messageId);
|
||||
}
|
||||
|
||||
_textController.clear();
|
||||
_focusNode.unfocus();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user