diff --git a/lib/models/contact.dart b/lib/models/contact.dart index d9f4142..23de813 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -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, ); } diff --git a/lib/models/message.dart b/lib/models/message.dart index d2cda26..14cda9c 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -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, ); } diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 52933a8..d0f6f7d 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -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 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; diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 05ddcc8..dd09e7d 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -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 removeContact( + String publicKeyHex, { + Future 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(); diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 075ff3f..855f0dc 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -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 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}'); diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index 5bad31e..6800976 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -20,6 +20,10 @@ class _ContactsTabState extends State { void initState() { super.initState(); _getCurrentLocation(); + // Mark all contacts as viewed when tab is opened + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().markAllAsViewed(); + }); } Future _getCurrentLocation() async { diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 4422d6d..e1b0e87 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -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 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( + 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 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, + ), + ), + ), + ], + ); + } } diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 24a4c61..7e91ccd 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -40,6 +40,10 @@ class _MessagesTabState extends State { void initState() { super.initState(); _textController.addListener(_updateCharacterCount); + // Mark all messages as read when tab is opened + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().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(); 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(); @@ -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), diff --git a/lib/services/meshcore_ble_service.dart b/lib/services/meshcore_ble_service.dart index f5adf78..4f0da3e 100644 --- a/lib/services/meshcore_ble_service.dart +++ b/lib/services/meshcore_ble_service.dart @@ -395,6 +395,15 @@ class MeshCoreBleService { await _commandSender.writeData(FrameBuilder.buildResetPath(contactPublicKey)); } + /// Remove a contact from the companion radio + Future 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(); diff --git a/lib/services/message_storage_service.dart b/lib/services/message_storage_service.dart index e8d34d8..1a0a024 100644 --- a/lib/services/message_storage_service.dart +++ b/lib/services/message_storage_service.dart @@ -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'); diff --git a/lib/services/protocol/frame_builder.dart b/lib/services/protocol/frame_builder.dart index ca5aae1..84611e9 100644 --- a/lib/services/protocol/frame_builder.dart +++ b/lib/services/protocol/frame_builder.dart @@ -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(); + } } diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index fcd71f5..3ea3c6d 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -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 _deleteContact(BuildContext context, Contact contact) async { + final connectionProvider = context.read(); + final contactsProvider = context.read(); + + 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(); @@ -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, + ), + ), + ), + ], ], ), ), diff --git a/lib/widgets/contacts/direct_message_sheet.dart b/lib/widgets/contacts/direct_message_sheet.dart index 4eaead4..6c48c4f 100644 --- a/lib/widgets/contacts/direct_message_sheet.dart +++ b/lib/widgets/contacts/direct_message_sheet.dart @@ -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 { if (text.isEmpty) return; final connectionProvider = context.read(); + final messagesProvider = context.read(); if (!connectionProvider.deviceInfo.isConnected) { if (!mounted) return; @@ -52,13 +56,44 @@ class _DirectMessageSheetState extends State { } 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();