mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 08:50:30 +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)
|
// Telemetry data (updated separately)
|
||||||
ContactTelemetry? telemetry;
|
ContactTelemetry? telemetry;
|
||||||
|
|
||||||
|
// UI state tracking
|
||||||
|
final bool isNew; // Whether contact is newly added and not yet viewed
|
||||||
|
|
||||||
Contact({
|
Contact({
|
||||||
required this.publicKey,
|
required this.publicKey,
|
||||||
required this.type,
|
required this.type,
|
||||||
@@ -65,6 +68,7 @@ class Contact {
|
|||||||
required this.advLon,
|
required this.advLon,
|
||||||
required this.lastMod,
|
required this.lastMod,
|
||||||
this.telemetry,
|
this.telemetry,
|
||||||
|
this.isNew = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Get public key as hex string (first 8 bytes)
|
/// Get public key as hex string (first 8 bytes)
|
||||||
@@ -250,6 +254,7 @@ class Contact {
|
|||||||
int? advLon,
|
int? advLon,
|
||||||
int? lastMod,
|
int? lastMod,
|
||||||
ContactTelemetry? telemetry,
|
ContactTelemetry? telemetry,
|
||||||
|
bool? isNew,
|
||||||
}) {
|
}) {
|
||||||
return Contact(
|
return Contact(
|
||||||
publicKey: publicKey ?? this.publicKey,
|
publicKey: publicKey ?? this.publicKey,
|
||||||
@@ -263,6 +268,7 @@ class Contact {
|
|||||||
advLon: advLon ?? this.advLon,
|
advLon: advLon ?? this.advLon,
|
||||||
lastMod: lastMod ?? this.lastMod,
|
lastMod: lastMod ?? this.lastMod,
|
||||||
telemetry: telemetry ?? this.telemetry,
|
telemetry: telemetry ?? this.telemetry,
|
||||||
|
isNew: isNew ?? this.isNew,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ class Message {
|
|||||||
final DateTime? deliveredAt; // When delivery was confirmed
|
final DateTime? deliveredAt; // When delivery was confirmed
|
||||||
final Uint8List? recipientPublicKey; // Full 32-byte public key of recipient (for retry)
|
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({
|
Message({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.messageType,
|
required this.messageType,
|
||||||
@@ -83,6 +86,7 @@ class Message {
|
|||||||
this.roundTripTimeMs,
|
this.roundTripTimeMs,
|
||||||
this.deliveredAt,
|
this.deliveredAt,
|
||||||
this.recipientPublicKey,
|
this.recipientPublicKey,
|
||||||
|
this.isRead = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Get sender public key as hex string
|
/// Get sender public key as hex string
|
||||||
@@ -222,6 +226,7 @@ class Message {
|
|||||||
int? roundTripTimeMs,
|
int? roundTripTimeMs,
|
||||||
DateTime? deliveredAt,
|
DateTime? deliveredAt,
|
||||||
Uint8List? recipientPublicKey,
|
Uint8List? recipientPublicKey,
|
||||||
|
bool? isRead,
|
||||||
}) {
|
}) {
|
||||||
return Message(
|
return Message(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -243,6 +248,7 @@ class Message {
|
|||||||
roundTripTimeMs: roundTripTimeMs ?? this.roundTripTimeMs,
|
roundTripTimeMs: roundTripTimeMs ?? this.roundTripTimeMs,
|
||||||
deliveredAt: deliveredAt ?? this.deliveredAt,
|
deliveredAt: deliveredAt ?? this.deliveredAt,
|
||||||
recipientPublicKey: recipientPublicKey ?? this.recipientPublicKey,
|
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
|
/// Clear error message
|
||||||
void clearError() {
|
void clearError() {
|
||||||
_error = null;
|
_error = null;
|
||||||
|
|||||||
@@ -125,7 +125,18 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return;
|
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();
|
_persistContacts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -242,6 +253,35 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return contacts.where((c) => c.isRecentlySeen).toList();
|
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
|
/// Clear all contacts
|
||||||
void clearContacts() {
|
void clearContacts() {
|
||||||
_contacts.clear();
|
_contacts.clear();
|
||||||
@@ -250,7 +290,21 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a contact
|
/// 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);
|
_contacts.remove(publicKeyHex);
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
bool get isInitialized => _isInitialized;
|
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
|
/// Initialize and load persisted messages
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
if (_isInitialized) return;
|
if (_isInitialized) return;
|
||||||
@@ -287,6 +295,31 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
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
|
/// Delete a specific message by ID
|
||||||
void deleteMessage(String messageId) {
|
void deleteMessage(String messageId) {
|
||||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||||
@@ -412,9 +445,10 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add message with sending status
|
// Add message with sending status and mark as read (sent messages are always read)
|
||||||
final sendingMessage = enhancedMessage.copyWith(
|
final sendingMessage = enhancedMessage.copyWith(
|
||||||
deliveryStatus: MessageDeliveryStatus.sending,
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
|
isRead: true, // Sent messages are always marked as read
|
||||||
);
|
);
|
||||||
_messages.add(sendingMessage);
|
_messages.add(sendingMessage);
|
||||||
print(' ✅ Message added to list at index ${_messages.length - 1}');
|
print(' ✅ Message added to list at index ${_messages.length - 1}');
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_getCurrentLocation();
|
_getCurrentLocation();
|
||||||
|
// Mark all contacts as viewed when tab is opened
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
context.read<ContactsProvider>().markAllAsViewed();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _getCurrentLocation() async {
|
Future<void> _getCurrentLocation() async {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
import '../providers/app_provider.dart';
|
import '../providers/app_provider.dart';
|
||||||
|
import '../providers/messages_provider.dart';
|
||||||
|
import '../providers/contacts_provider.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import 'messages_tab.dart';
|
import 'messages_tab.dart';
|
||||||
import 'contacts_tab.dart';
|
import 'contacts_tab.dart';
|
||||||
@@ -369,7 +371,12 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
const MapTab(),
|
const MapTab(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
bottomNavigationBar: Container(
|
bottomNavigationBar: Consumer2<MessagesProvider, ContactsProvider>(
|
||||||
|
builder: (context, messagesProvider, contactsProvider, child) {
|
||||||
|
final unreadCount = messagesProvider.unreadCount;
|
||||||
|
final newContactsCount = contactsProvider.newContactsCount;
|
||||||
|
|
||||||
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -381,12 +388,26 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
),
|
),
|
||||||
child: TabBar(
|
child: TabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
tabs: const [
|
tabs: [
|
||||||
Tab(icon: Icon(Icons.message), text: 'Messages'),
|
Tab(
|
||||||
Tab(icon: Icon(Icons.contacts), text: 'Contacts'),
|
icon: _buildTabIconWithBadge(
|
||||||
Tab(icon: Icon(Icons.map), text: 'Map'),
|
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;
|
if (rssi > -70) return Colors.orange;
|
||||||
return Colors.red;
|
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() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_textController.addListener(_updateCharacterCount);
|
_textController.addListener(_updateCharacterCount);
|
||||||
|
// Mark all messages as read when tab is opened
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
context.read<MessagesProvider>().markAllAsRead();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -532,10 +536,25 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
final isSarMarker = message.isSarMarker;
|
final isSarMarker = message.isSarMarker;
|
||||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
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 connectionProvider = context.read<ConnectionProvider>();
|
||||||
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
|
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
|
// Look up contact information for rich display name
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
@@ -557,6 +576,50 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
? 'You'
|
? 'You'
|
||||||
: message.getRichDisplayName(senderContact);
|
: 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
|
// Debug: Log message details
|
||||||
if (message.text.startsWith('S:')) {
|
if (message.text.startsWith('S:')) {
|
||||||
debugPrint('🎨 [MessageBubble] Rendering SAR message:');
|
debugPrint('🎨 [MessageBubble] Rendering SAR message:');
|
||||||
@@ -569,22 +632,27 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
onLongPress: () => _showMessageOptions(context),
|
onLongPress: () => _showMessageOptions(context),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSarMarker
|
color: isSarMarker
|
||||||
? _getSarMarkerColor(context, isDarkMode)
|
? _getSarMarkerColor(context, isDarkMode)
|
||||||
: _getMessageBubbleColor(context, isOwnMessage, isDarkMode),
|
: _getMessageBubbleColor(context, isOwnMessage, isDarkMode),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: isSarMarker
|
border: isSarMarker
|
||||||
? Border.all(
|
? Border.all(
|
||||||
color: _getSarMarkerBorderColor(context, isDarkMode),
|
color: _getSarMarkerBorderColor(context, isDarkMode),
|
||||||
width: 3,
|
width: 2,
|
||||||
)
|
)
|
||||||
: isOwnMessage
|
: isOwnMessage
|
||||||
? Border.all(
|
? Border.all(
|
||||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
|
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
|
||||||
width: 2,
|
width: 1.5,
|
||||||
|
)
|
||||||
|
: !message.isRead && !message.isSentMessage && !message.isSystemMessage
|
||||||
|
? Border.all(
|
||||||
|
color: Colors.blue,
|
||||||
|
width: 1.5,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
boxShadow: isSarMarker
|
boxShadow: isSarMarker
|
||||||
@@ -603,6 +671,17 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
// Header: Sender and time
|
// Header: Sender and time
|
||||||
Row(
|
Row(
|
||||||
children: [
|
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)
|
if (isSarMarker)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -648,6 +727,23 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
color: isOwnMessage ? Theme.of(context).colorScheme.primary : null,
|
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(),
|
const Spacer(),
|
||||||
Text(
|
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)
|
// SAR marker content (simplified design matching message history)
|
||||||
if (isSarMarker && message.sarMarkerType != null) ...[
|
if (isSarMarker && message.sarMarkerType != null) ...[
|
||||||
@@ -666,23 +762,23 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
message.sarMarkerType!.emoji,
|
message.sarMarkerType!.emoji,
|
||||||
style: const TextStyle(fontSize: 32),
|
style: const TextStyle(fontSize: 28),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
message.sarMarkerType!.displayName,
|
message.sarMarkerType!.displayName,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (message.sarGpsCoordinates != null)
|
if (message.sarGpsCoordinates != null)
|
||||||
Text(
|
Text(
|
||||||
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
|
'${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',
|
fontFamily: 'monospace',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -691,18 +787,11 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Icon(
|
Icon(
|
||||||
Icons.chevron_right,
|
Icons.chevron_right,
|
||||||
|
size: 18,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
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
|
// Regular message content
|
||||||
else
|
else
|
||||||
@@ -713,16 +802,16 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
|
|
||||||
// Delivery status for sent messages
|
// Delivery status for sent messages
|
||||||
if (message.isSentMessage) ...[
|
if (message.isSentMessage) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 6),
|
||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
_getDeliveryStatusIcon(message.deliveryStatus),
|
_getDeliveryStatusIcon(message.deliveryStatus),
|
||||||
size: 14,
|
size: 12,
|
||||||
color: _getDeliveryStatusColor(message.deliveryStatus),
|
color: _getDeliveryStatusColor(message.deliveryStatus),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 3),
|
||||||
Text(
|
Text(
|
||||||
message.deliveryStatusText,
|
message.deliveryStatusText,
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
@@ -732,11 +821,11 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
// Show retry button for failed messages
|
// Show retry button for failed messages
|
||||||
if (message.deliveryStatus == MessageDeliveryStatus.failed) ...[
|
if (message.deliveryStatus == MessageDeliveryStatus.failed) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 6),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => _retryFailedMessage(context, message),
|
onTap: () => _retryFailedMessage(context, message),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.orange.withOpacity(0.2),
|
color: Colors.orange.withOpacity(0.2),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
|||||||
@@ -395,6 +395,15 @@ class MeshCoreBleService {
|
|||||||
await _commandSender.writeData(FrameBuilder.buildResetPath(contactPublicKey));
|
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
|
/// Clear packet logs
|
||||||
void clearPacketLogs() {
|
void clearPacketLogs() {
|
||||||
_commandSender.clearPacketLogs();
|
_commandSender.clearPacketLogs();
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ class MessageStorageService {
|
|||||||
'recipientPublicKey': message.recipientPublicKey != null
|
'recipientPublicKey': message.recipientPublicKey != null
|
||||||
? base64Encode(message.recipientPublicKey!)
|
? base64Encode(message.recipientPublicKey!)
|
||||||
: null,
|
: null,
|
||||||
|
'isRead': message.isRead,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +181,7 @@ class MessageStorageService {
|
|||||||
? Uint8List.fromList(
|
? Uint8List.fromList(
|
||||||
base64Decode(json['recipientPublicKey'] as String))
|
base64Decode(json['recipientPublicKey'] as String))
|
||||||
: null,
|
: null,
|
||||||
|
isRead: json['isRead'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('❌ [MessageStorage] Error parsing message from JSON: $e');
|
print('❌ [MessageStorage] Error parsing message from JSON: $e');
|
||||||
|
|||||||
@@ -235,4 +235,12 @@ class FrameBuilder {
|
|||||||
writer.writeBytes(contactPublicKey); // 32 bytes
|
writer.writeBytes(contactPublicKey); // 32 bytes
|
||||||
return writer.toBytes();
|
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/contact.dart';
|
||||||
import '../../models/room_login_state.dart';
|
import '../../models/room_login_state.dart';
|
||||||
import '../../providers/connection_provider.dart';
|
import '../../providers/connection_provider.dart';
|
||||||
|
import '../../providers/contacts_provider.dart';
|
||||||
import '../../providers/map_provider.dart';
|
import '../../providers/map_provider.dart';
|
||||||
import 'direct_message_sheet.dart';
|
import 'direct_message_sheet.dart';
|
||||||
import 'room_login_sheet.dart';
|
import 'room_login_sheet.dart';
|
||||||
@@ -66,7 +67,22 @@ class ContactTile extends StatelessWidget {
|
|||||||
color: Colors.white,
|
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)
|
if (contact.type == ContactType.room && roomLoginState != null)
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 0,
|
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) {
|
void _showContactDetails(BuildContext context, Contact contact) {
|
||||||
// Get room login state
|
// Get room login state
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
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/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
|
import '../../models/message.dart';
|
||||||
import '../../providers/connection_provider.dart';
|
import '../../providers/connection_provider.dart';
|
||||||
|
import '../../providers/messages_provider.dart';
|
||||||
import '../../utils/toast_logger.dart';
|
import '../../utils/toast_logger.dart';
|
||||||
|
|
||||||
class DirectMessageSheet extends StatefulWidget {
|
class DirectMessageSheet extends StatefulWidget {
|
||||||
@@ -44,6 +47,7 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
|
|||||||
if (text.isEmpty) return;
|
if (text.isEmpty) return;
|
||||||
|
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
|
|
||||||
if (!connectionProvider.deviceInfo.isConnected) {
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -52,13 +56,44 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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)
|
// Send direct message to contact (include contact for path logging)
|
||||||
await connectionProvider.sendTextMessage(
|
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||||
contactPublicKey: widget.contact.publicKey,
|
contactPublicKey: widget.contact.publicKey,
|
||||||
text: text,
|
text: text,
|
||||||
|
messageId: messageId, // Pass message ID for tracking
|
||||||
contact: widget.contact,
|
contact: widget.contact,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!sentSuccessfully) {
|
||||||
|
// Mark message as failed if sending failed
|
||||||
|
messagesProvider.markMessageFailed(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
_textController.clear();
|
_textController.clear();
|
||||||
_focusNode.unfocus();
|
_focusNode.unfocus();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user