feat: Add new contact and message tracking features, including unread status and removal functionality

This commit is contained in:
Janez T
2025-10-15 21:49:33 +02:00
parent 2849ab92aa
commit 6be182e20a
13 changed files with 463 additions and 48 deletions

View File

@@ -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,
); );
} }

View File

@@ -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,
); );
} }

View File

@@ -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;

View File

@@ -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();

View File

@@ -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}');

View File

@@ -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 {

View File

@@ -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,
),
),
),
],
);
}
} }

View File

@@ -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),

View File

@@ -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();

View File

@@ -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');

View File

@@ -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();
}
} }

View File

@@ -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,
),
),
),
],
], ],
), ),
), ),

View File

@@ -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();