group_chat_screen.dart
1 import 'dart:async'; 2 import 'dart:convert'; 3 import 'dart:io'; 4 import 'dart:typed_data'; 5 6 import 'package:file_picker/file_picker.dart'; 7 import 'package:flutter/material.dart'; 8 import 'package:flutter/services.dart'; 9 import 'package:flutter_riverpod/flutter_riverpod.dart'; 10 import 'package:go_router/go_router.dart'; 11 import 'package:image_picker/image_picker.dart'; 12 import 'package:iconify_flutter/icons/majesticons.dart'; 13 14 import '../../core/router.dart'; 15 import '../../core/theme/app_theme.dart'; 16 import '../../providers/contact_metadata_provider.dart'; 17 import '../../providers/contacts_provider.dart'; 18 import '../../providers/groups_provider.dart'; 19 import '../../providers/messages_provider.dart'; 20 import '../../providers/user_profile_provider.dart'; 21 import '../../services/dead_drop_service.dart'; 22 import '../../services/gif_service.dart'; 23 import '../../widgets/gif_picker.dart'; 24 import '../../widgets/emoji_picker.dart'; 25 import '../../widgets/linkified_text.dart'; 26 import '../../widgets/themed_icon.dart'; 27 import '../../src/rust/api.dart' as api; 28 29 /// Format file size in human-readable format 30 String _formatFileSize(int bytes) { 31 if (bytes < 1024) return '$bytes B'; 32 if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; 33 return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; 34 } 35 36 /// Check if content is an image by examining magic bytes 37 bool _isImageContent(List<int> content) { 38 if (content.length < 4) return false; 39 if (content[0] == 0x89 && content[1] == 0x50 && content[2] == 0x4E && content[3] == 0x47) return true; 40 if (content[0] == 0xFF && content[1] == 0xD8 && content[2] == 0xFF) return true; 41 if (content[0] == 0x47 && content[1] == 0x49 && content[2] == 0x46 && content[3] == 0x38) return true; 42 if (content.length >= 12 && 43 content[0] == 0x52 && content[1] == 0x49 && content[2] == 0x46 && content[3] == 0x46 && 44 content[8] == 0x57 && content[9] == 0x45 && content[10] == 0x42 && content[11] == 0x50) return true; 45 if (content[0] == 0x42 && content[1] == 0x4D) return true; 46 return false; 47 } 48 49 /// Check if content looks like binary data 50 bool _isBinaryContent(List<int> content) { 51 if (content.isEmpty) return false; 52 if (_isImageContent(content)) return true; 53 if (content.length >= 4) { 54 if (content[0] == 0x25 && content[1] == 0x50 && content[2] == 0x44 && content[3] == 0x46) return true; 55 if (content[0] == 0x50 && content[1] == 0x4B && content[2] == 0x03 && content[3] == 0x04) return true; 56 } 57 int nonPrintable = 0; 58 final sampleSize = content.length > 100 ? 100 : content.length; 59 for (int i = 0; i < sampleSize; i++) { 60 final byte = content[i]; 61 if (byte != 9 && byte != 10 && byte != 13 && (byte < 32 || byte > 126)) { 62 nonPrintable++; 63 } 64 } 65 return nonPrintable > sampleSize * 0.3; 66 } 67 68 /// Format a Unix timestamp in local 12-hour format 69 String _formatTime(int timestamp) { 70 final dt = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); 71 final hour = dt.hour % 12 == 0 ? 12 : dt.hour % 12; 72 final period = dt.hour < 12 ? 'AM' : 'PM'; 73 return '$hour:${dt.minute.toString().padLeft(2, '0')} $period'; 74 } 75 76 class GroupChatScreen extends ConsumerStatefulWidget { 77 final String groupIdHex; 78 79 const GroupChatScreen({super.key, required this.groupIdHex}); 80 81 @override 82 ConsumerState<GroupChatScreen> createState() => _GroupChatScreenState(); 83 } 84 85 class _GroupChatScreenState extends ConsumerState<GroupChatScreen> { 86 final _messageController = TextEditingController(); 87 final _scrollController = ScrollController(); 88 final _textFieldFocusNode = FocusNode(); 89 90 /// Map of contact ID hex → display name for sender labels 91 final Map<String, String> _contactNames = {}; 92 93 /// Static image cache shared across all group message bubbles. 94 /// Keyed by message ID hex → (ImageProvider, raw bytes). 95 static final Map<String, (ImageProvider, Uint8List)> _imageCache = {}; 96 static const int _imageCacheMaxSize = 50; 97 98 /// Local message list — updated via listener, not ref.watch, 99 /// to avoid full widget tree rebuilds that reset GIF animations. 100 List<MessageInfo> _currentMessages = []; 101 bool _messagesLoaded = false; 102 103 Timer? _refreshTimer; 104 105 @override 106 void initState() { 107 super.initState(); 108 _textFieldFocusNode.addListener(() => setState(() {})); 109 110 WidgetsBinding.instance.addPostFrameCallback((_) { 111 _setupMessageListener(); 112 }); 113 114 // Periodic refresh to catch messages that arrive between provider invalidations 115 _refreshTimer = Timer.periodic(const Duration(seconds: 5), (_) { 116 ref.invalidate(groupMessagesProvider(widget.groupIdHex)); 117 ref.invalidate(groupMembersProvider(widget.groupIdHex)); 118 }); 119 } 120 121 void _setupMessageListener() { 122 // Load initial data 123 final initial = ref.read(groupMessagesProvider(widget.groupIdHex)); 124 initial.whenData((messages) { 125 _onGroupMessagesChanged(messages); 126 }); 127 128 // Listen for future changes — only setState when list actually differs 129 ref.listenManual(groupMessagesProvider(widget.groupIdHex), (previous, next) { 130 next.when( 131 data: (messages) => _onGroupMessagesChanged(messages), 132 loading: () {}, 133 error: (e, _) {}, 134 ); 135 }); 136 } 137 138 static String _msgIdHex(MessageInfo msg) => 139 msg.messageId.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); 140 141 void _onGroupMessagesChanged(List<MessageInfo> messages) { 142 if (!mounted) return; 143 144 // Only trigger full rebuild for structural changes (new/removed messages) 145 final hasStructuralChange = _currentMessages.length != messages.length || 146 _hasStructuralDiff(messages); 147 148 if (hasStructuralChange || !_messagesLoaded) { 149 setState(() { 150 _messagesLoaded = true; 151 _currentMessages = List.of(messages); 152 }); 153 _markGroupMessagesAsRead(); 154 } else { 155 // Silently update in place (delivery states etc.) 156 final newMap = <String, MessageInfo>{}; 157 for (final m in messages) { 158 newMap[_msgIdHex(m)] = m; 159 } 160 for (var i = 0; i < _currentMessages.length; i++) { 161 final id = _msgIdHex(_currentMessages[i]); 162 if (newMap.containsKey(id)) { 163 _currentMessages[i] = newMap[id]!; 164 } 165 } 166 } 167 } 168 169 bool _hasStructuralDiff(List<MessageInfo> newMessages) { 170 if (_currentMessages.length != newMessages.length) return true; 171 for (var i = 0; i < _currentMessages.length; i++) { 172 if (_msgIdHex(_currentMessages[i]) != _msgIdHex(newMessages[i])) return true; 173 } 174 return false; 175 } 176 177 /// Mark all unread inbound messages in this group as read. 178 Future<void> _markGroupMessagesAsRead() async { 179 final unread = _currentMessages 180 .where((m) => !m.isOutbound && m.state == 1) 181 .toList(); 182 if (unread.isEmpty) return; 183 184 final service = DeadDropService.instance; 185 for (final msg in unread) { 186 service.markMessageRead(Uint8List.fromList(msg.messageId)); 187 } 188 ref.invalidate(groupsProvider); 189 ref.invalidate(unreadCountProvider); 190 } 191 192 @override 193 void dispose() { 194 _refreshTimer?.cancel(); 195 // Refresh unread counts when leaving group chat 196 ref.invalidate(groupsProvider); 197 ref.invalidate(contactsProvider); 198 ref.invalidate(unreadCountProvider); 199 _textFieldFocusNode.dispose(); 200 _messageController.dispose(); 201 _scrollController.dispose(); 202 super.dispose(); 203 } 204 205 /// Resolve a sender ID to a display name, caching results. 206 String _getSenderName(List<int>? senderId) { 207 if (senderId == null) return 'Unknown'; 208 final hex = senderId.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); 209 if (_contactNames.containsKey(hex)) return _contactNames[hex]!; 210 211 // Look up from contacts 212 final contacts = ref.read(contactsProvider).valueOrNull ?? []; 213 for (final c in contacts) { 214 final cHex = c.contactId.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); 215 if (cHex == hex) { 216 _contactNames[hex] = c.displayName; 217 return c.displayName; 218 } 219 } 220 _contactNames[hex] = hex.substring(0, 8); 221 return _contactNames[hex]!; 222 } 223 224 /// Generate a deterministic color for a sender based on their ID. 225 Color _getSenderColor(List<int>? senderId) { 226 if (senderId == null || senderId.isEmpty) return Colors.grey; 227 // Use first 4 bytes as a hash seed 228 final hash = senderId[0] * 256 * 256 + senderId[1] * 256 + senderId[2]; 229 const hues = [0, 30, 60, 120, 180, 210, 270, 300]; 230 final hue = hues[hash % hues.length].toDouble(); 231 return HSLColor.fromAHSL(1.0, hue, 0.6, 0.45).toColor(); 232 } 233 234 /// Get sender's avatar picture path from metadata. 235 String? _getSenderPicturePath(List<int>? senderId) { 236 if (senderId == null) return null; 237 final hex = senderId.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); 238 final metadata = ref.read(contactMetadataProvider(hex)).valueOrNull; 239 return metadata?.picturePath; 240 } 241 242 String _formatSystemMessage(String raw) { 243 // Format: __sys:action:detail 244 final parts = raw.substring(6).split(':'); // strip "__sys:" 245 final action = parts.isNotEmpty ? parts[0] : ''; 246 final detail = parts.length > 1 ? parts.sublist(1).join(':') : ''; 247 switch (action) { 248 case 'left': 249 return '$detail left the group'; 250 case 'removed': 251 return '$detail was removed from the group'; 252 case 'you_removed': 253 return 'You removed $detail from the group'; 254 case 'added': 255 return '$detail joined the group'; 256 case 'renamed': 257 return 'Group renamed to "$detail"'; 258 default: 259 return raw; 260 } 261 } 262 263 void _sendMessage() { 264 final text = _messageController.text.trim(); 265 if (text.isEmpty) return; 266 267 _messageController.clear(); 268 ref 269 .read(groupMessagesProvider(widget.groupIdHex).notifier) 270 .sendText(text) 271 .catchError((e) { 272 if (mounted) { 273 ScaffoldMessenger.of(context).showSnackBar( 274 SnackBar(content: Text('Failed to send: $e')), 275 ); 276 } 277 return; 278 }); 279 } 280 281 Future<void> _pickFile() async { 282 final result = await FilePicker.platform.pickFiles( 283 type: FileType.any, 284 withData: true, 285 ); 286 if (result == null || result.files.isEmpty) return; 287 final file = result.files.first; 288 if (file.bytes == null) return; 289 290 final content = Uint8List.fromList(file.bytes!); 291 ref 292 .read(groupMessagesProvider(widget.groupIdHex).notifier) 293 .sendDocument(content, file.name) 294 .catchError((e) { 295 if (mounted) { 296 ScaffoldMessenger.of(context).showSnackBar( 297 SnackBar(content: Text('Failed to send file: $e')), 298 ); 299 } 300 return; 301 }); 302 } 303 304 Future<void> _pickImage(ImageSource source) async { 305 try { 306 final picker = ImagePicker(); 307 final XFile? image = await picker.pickImage(source: source); 308 if (image == null) return; 309 310 final bytes = await File(image.path).readAsBytes(); 311 ref 312 .read(groupMessagesProvider(widget.groupIdHex).notifier) 313 .sendDocument(Uint8List.fromList(bytes), image.name) 314 .catchError((e) { 315 if (mounted) { 316 ScaffoldMessenger.of(context).showSnackBar( 317 SnackBar(content: Text('Failed to send image: $e')), 318 ); 319 } 320 return; 321 }); 322 } catch (e) { 323 if (mounted) { 324 ScaffoldMessenger.of(context).showSnackBar( 325 SnackBar(content: Text('Failed to pick image: $e')), 326 ); 327 } 328 } 329 } 330 331 void _showGifPicker() { 332 final colors = AppColors.of(context); 333 showModalBottomSheet( 334 context: context, 335 isScrollControlled: true, 336 backgroundColor: colors.background, 337 shape: const RoundedRectangleBorder( 338 borderRadius: BorderRadius.vertical(top: Radius.circular(16)), 339 ), 340 builder: (context) => DraggableScrollableSheet( 341 initialChildSize: 0.6, 342 maxChildSize: 0.9, 343 minChildSize: 0.3, 344 expand: false, 345 builder: (context, scrollController) => GifPicker( 346 onGifSelected: (gif) { 347 Navigator.pop(context); 348 _sendGif(gif); 349 }, 350 ), 351 ), 352 ); 353 } 354 355 void _onKeyboardContent(KeyboardInsertedContent content) { 356 if (!content.hasData) return; 357 final bytes = content.data!; 358 final mime = content.mimeType; 359 final ext = mime.contains('gif') ? 'gif' : mime.contains('png') ? 'png' : 'jpg'; 360 final filename = 'keyboard_${DateTime.now().millisecondsSinceEpoch}.$ext'; 361 362 if (bytes.length > 8 * 1024 * 1024) { 363 if (mounted) { 364 ScaffoldMessenger.of(context).showSnackBar( 365 const SnackBar(content: Text('Image is too large (max 8MB)')), 366 ); 367 } 368 return; 369 } 370 371 ref.read(groupMessagesProvider(widget.groupIdHex).notifier) 372 .sendDocument(Uint8List.fromList(bytes), filename) 373 .catchError((e) { 374 if (mounted) { 375 ScaffoldMessenger.of(context).showSnackBar( 376 SnackBar(content: Text('Failed to send: $e')), 377 ); 378 } 379 return; 380 }); 381 } 382 383 Future<void> _sendGif(GifItem gif) async { 384 try { 385 final service = GifService(); 386 final bytes = await service.downloadGif(gif.fullUrl); 387 service.dispose(); 388 389 if (bytes.length > 8 * 1024 * 1024) { 390 if (mounted) { 391 ScaffoldMessenger.of(context).showSnackBar( 392 const SnackBar(content: Text('GIF is too large (max 8MB)')), 393 ); 394 } 395 return; 396 } 397 398 ref 399 .read(groupMessagesProvider(widget.groupIdHex).notifier) 400 .sendDocument(Uint8List.fromList(bytes), 'giphy_${gif.id}.gif') 401 .catchError((e) { 402 if (mounted) { 403 ScaffoldMessenger.of(context).showSnackBar( 404 SnackBar(content: Text('Failed to send GIF: $e')), 405 ); 406 } 407 return; 408 }); 409 } catch (e) { 410 if (mounted) { 411 ScaffoldMessenger.of(context).showSnackBar( 412 SnackBar(content: Text('Failed to download GIF: $e')), 413 ); 414 } 415 } 416 } 417 418 void _showAttachmentMenu() { 419 final colors = AppColors.of(context); 420 showModalBottomSheet( 421 context: context, 422 builder: (context) => SafeArea( 423 child: Padding( 424 padding: const EdgeInsets.symmetric(vertical: 12), 425 child: Column( 426 mainAxisSize: MainAxisSize.min, 427 children: [ 428 Container( 429 width: 32, 430 height: 4, 431 decoration: BoxDecoration( 432 color: colors.outline, 433 borderRadius: BorderRadius.circular(2), 434 ), 435 ), 436 const SizedBox(height: 12), 437 ListTile( 438 leading: ThemedIcon(Majesticons.file_line, color: colors.primary), 439 title: const Text('Files'), 440 onTap: () { 441 Navigator.pop(context); 442 _pickFile(); 443 }, 444 ), 445 ListTile( 446 leading: ThemedIcon(Majesticons.image_multiple_line, color: colors.primary), 447 title: const Text('Photo Album'), 448 onTap: () { 449 Navigator.pop(context); 450 _pickImage(ImageSource.gallery); 451 }, 452 ), 453 ListTile( 454 leading: ThemedIcon(Majesticons.camera_line, color: colors.primary), 455 title: const Text('Camera'), 456 onTap: () { 457 Navigator.pop(context); 458 _pickImage(ImageSource.camera); 459 }, 460 ), 461 ListTile( 462 leading: Text('GIF', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: colors.primary)), 463 title: const Text('Search GIFs'), 464 onTap: () { 465 Navigator.pop(context); 466 _showGifPicker(); 467 }, 468 ), 469 ], 470 ), 471 ), 472 ), 473 ); 474 } 475 476 @override 477 Widget build(BuildContext context) { 478 final colors = AppColors.of(context); 479 final groupsAsync = ref.watch(groupsProvider); 480 final members = ref.watch(groupMembersProvider(widget.groupIdHex)); 481 482 // Detect if we were removed from this group 483 ref.listen<AsyncValue<List<GroupInfo>>>(groupsProvider, (prev, next) { 484 final groups = next.valueOrNull ?? []; 485 final stillExists = groups.any( 486 (g) => bytesToHex(g.groupId) == widget.groupIdHex); 487 if (!stillExists && (prev?.valueOrNull?.isNotEmpty ?? false)) { 488 if (mounted) { 489 ScaffoldMessenger.of(context).showSnackBar( 490 const SnackBar( 491 content: Text('You were removed from this group'), 492 duration: Duration(seconds: 4), 493 ), 494 ); 495 context.go(AppRoutes.contacts); 496 } 497 } 498 }); 499 500 // Find our group 501 final group = groupsAsync.valueOrNull?.firstWhere( 502 (g) => bytesToHex(g.groupId) == widget.groupIdHex, 503 orElse: () => groupsAsync.valueOrNull!.first, 504 ); 505 506 final chatBackground = colors.surfaceContainer; 507 508 return Scaffold( 509 backgroundColor: chatBackground, 510 appBar: AppBar( 511 backgroundColor: chatBackground, 512 automaticallyImplyLeading: !AppResponsive.isWideScreen(context), 513 titleSpacing: AppResponsive.isWideScreen(context) ? 16 : 0, 514 title: GestureDetector( 515 onTap: () => context.push(AppRoutes.groupSettingsPath(widget.groupIdHex)), 516 child: Row( 517 children: [ 518 CircleAvatar( 519 radius: 16, 520 backgroundColor: colors.primary, 521 child: ThemedIcon( 522 Majesticons.user_group_line, 523 size: 16, 524 color: colors.onPrimary, 525 ), 526 ), 527 const SizedBox(width: 8), 528 Expanded( 529 child: Column( 530 crossAxisAlignment: CrossAxisAlignment.start, 531 mainAxisSize: MainAxisSize.min, 532 children: [ 533 Text(group?.name ?? 'Group'), 534 Text( 535 '${members.length} members', 536 style: Theme.of(context).textTheme.labelSmall?.copyWith( 537 color: colors.onBackgroundSecondary, 538 fontSize: 10, 539 ), 540 ), 541 ], 542 ), 543 ), 544 ], 545 ), 546 ), 547 actions: [ 548 _ShareProfileButton( 549 groupIdHex: widget.groupIdHex, 550 members: members, 551 ), 552 IconButton( 553 icon: ThemedIcon(Majesticons.settings_cog_line), 554 onPressed: () => context.push( 555 AppRoutes.groupSettingsPath(widget.groupIdHex), 556 ), 557 ), 558 ], 559 ), 560 body: SafeArea( 561 bottom: false, 562 child: Column( 563 children: [ 564 // Messages list — driven by ref.listenManual, not ref.watch 565 Expanded( 566 child: !_messagesLoaded 567 ? const Center(child: CircularProgressIndicator()) 568 : _currentMessages.isEmpty 569 ? _EmptyGroupChat() 570 : ListView.builder( 571 controller: _scrollController, 572 reverse: true, 573 padding: AppSpacing.paddingMd, 574 physics: const BouncingScrollPhysics( 575 parent: AlwaysScrollableScrollPhysics(), 576 ), 577 itemCount: _currentMessages.length, 578 itemBuilder: (context, index) { 579 final message = _currentMessages[index]; 580 final msgIdHex = _msgIdHex(message); 581 final text = utf8.decode(message.content, allowMalformed: true); 582 if (text.startsWith('__sys:')) { 583 return _SystemMessage( 584 key: ValueKey(msgIdHex), 585 text: _formatSystemMessage(text), 586 ); 587 } 588 return _GroupMessageBubble( 589 key: ValueKey(msgIdHex), 590 message: message, 591 imageCache: _imageCache, 592 imageCacheMaxSize: _imageCacheMaxSize, 593 senderName: message.isOutbound 594 ? null 595 : _getSenderName(message.senderId), 596 senderColor: message.isOutbound 597 ? null 598 : _getSenderColor(message.senderId), 599 senderPicturePath: message.isOutbound 600 ? null 601 : _getSenderPicturePath(message.senderId), 602 onRefresh: () => ref.invalidate(groupMessagesProvider(widget.groupIdHex)), 603 ); 604 }, 605 ), 606 ), 607 608 // Input bar 609 Container( 610 padding: EdgeInsets.only( 611 left: AppSpacing.lg, 612 right: AppSpacing.sm, 613 top: AppSpacing.sm, 614 bottom: 615 MediaQuery.of(context).padding.bottom + AppSpacing.sm, 616 ), 617 decoration: BoxDecoration( 618 color: chatBackground, 619 border: Border(top: BorderSide(color: colors.divider)), 620 ), 621 child: Row( 622 children: [ 623 IconButton( 624 icon: ThemedIcon(Majesticons.plus_line, 625 color: colors.primary), 626 onPressed: _showAttachmentMenu, 627 tooltip: 'Attach', 628 ), 629 Expanded( 630 child: AnimatedContainer( 631 duration: const Duration(milliseconds: 200), 632 decoration: BoxDecoration( 633 color: colors.surfaceContainerLow, 634 borderRadius: BorderRadius.circular(20), 635 ), 636 child: TextField( 637 controller: _messageController, 638 focusNode: _textFieldFocusNode, 639 decoration: InputDecoration( 640 hintText: 'Type a message...', 641 border: OutlineInputBorder( 642 borderRadius: BorderRadius.circular(20), 643 borderSide: BorderSide.none, 644 ), 645 enabledBorder: OutlineInputBorder( 646 borderRadius: BorderRadius.circular(20), 647 borderSide: BorderSide.none, 648 ), 649 focusedBorder: OutlineInputBorder( 650 borderRadius: BorderRadius.circular(20), 651 borderSide: BorderSide( 652 color: colors.primary, 653 width: 1.5, 654 ), 655 ), 656 filled: false, 657 contentPadding: const EdgeInsets.symmetric( 658 horizontal: AppSpacing.md, 659 vertical: AppSpacing.sm + 2, 660 ), 661 ), 662 contentInsertionConfiguration: ContentInsertionConfiguration( 663 allowedMimeTypes: const ['image/gif', 'image/png', 'image/jpeg'], 664 onContentInserted: _onKeyboardContent, 665 ), 666 textCapitalization: TextCapitalization.sentences, 667 maxLines: 4, 668 minLines: 1, 669 onSubmitted: (_) => _sendMessage(), 670 ), 671 ), 672 ), 673 const SizedBox(width: AppSpacing.sm), 674 IconButton( 675 icon: ThemedIcon(Majesticons.send_line, 676 color: colors.primary), 677 onPressed: _sendMessage, 678 ), 679 ], 680 ), 681 ), 682 ], 683 ), 684 ), 685 ); 686 } 687 } 688 689 class _ShareProfileButton extends ConsumerWidget { 690 final String groupIdHex; 691 final List<GroupMemberInfoDto> members; 692 693 const _ShareProfileButton({ 694 required this.groupIdHex, 695 required this.members, 696 }); 697 698 @override 699 Widget build(BuildContext context, WidgetRef ref) { 700 final profile = ref.watch(userProfileProvider); 701 if (profile.picturePath == null) return const SizedBox.shrink(); 702 703 final unsharedMembers = members.where((m) { 704 final hex = bytesToHex(Uint8List.fromList(m.contactId)); 705 return !profile.shareProfileContactIds.contains(hex); 706 }).toList(); 707 708 if (unsharedMembers.isEmpty) return const SizedBox.shrink(); 709 710 return IconButton( 711 icon: ThemedIcon(Majesticons.share_line), 712 tooltip: 'Share Profile Picture', 713 onPressed: () => _shareWithGroupMembers(context, ref, unsharedMembers), 714 ); 715 } 716 717 Future<void> _shareWithGroupMembers( 718 BuildContext context, 719 WidgetRef ref, 720 List<GroupMemberInfoDto> unshared, 721 ) async { 722 final service = ref.read(userProfileServiceProvider); 723 final pictureBytes = await service.getPictureBytes(); 724 if (pictureBytes == null) return; 725 726 final path = await service.picturePath; 727 final ext = path?.split('.').last ?? 'png'; 728 var count = 0; 729 730 for (final member in unshared) { 731 final hex = bytesToHex(Uint8List.fromList(member.contactId)); 732 await ref.read(userProfileProvider.notifier).setShareWith(hex, true); 733 try { 734 await ref.read(messagesProvider(hex).notifier).sendDocument( 735 Uint8List.fromList(pictureBytes), 736 '__profile_picture__.$ext', 737 ); 738 count++; 739 } catch (_) {} 740 } 741 742 if (context.mounted) { 743 ScaffoldMessenger.of(context).showSnackBar( 744 SnackBar(content: Text('Shared with $count member${count == 1 ? '' : 's'}')), 745 ); 746 } 747 } 748 } 749 750 class _EmptyGroupChat extends StatelessWidget { 751 @override 752 Widget build(BuildContext context) { 753 final colors = AppColors.of(context); 754 return Center( 755 child: Padding( 756 padding: AppSpacing.screenPadding, 757 child: Column( 758 mainAxisAlignment: MainAxisAlignment.center, 759 children: [ 760 ThemedIcon( 761 Majesticons.chat_2_line, 762 size: 64, 763 color: colors.onBackgroundSecondary, 764 ), 765 const SizedBox(height: AppSpacing.md), 766 Text( 767 'No messages yet', 768 style: Theme.of(context).textTheme.titleMedium, 769 ), 770 const SizedBox(height: AppSpacing.sm), 771 Text( 772 'Send a message to the group.', 773 style: Theme.of(context).textTheme.bodyMedium?.copyWith( 774 color: colors.onBackgroundSecondary, 775 ), 776 textAlign: TextAlign.center, 777 ), 778 ], 779 ), 780 ), 781 ); 782 } 783 } 784 785 class _GroupMessageBubble extends StatelessWidget { 786 final MessageInfo message; 787 final Map<String, (ImageProvider, Uint8List)> imageCache; 788 final int imageCacheMaxSize; 789 final String? senderName; 790 final Color? senderColor; 791 final String? senderPicturePath; 792 final VoidCallback? onRefresh; 793 794 const _GroupMessageBubble({ 795 super.key, 796 required this.message, 797 required this.imageCache, 798 required this.imageCacheMaxSize, 799 this.senderName, 800 this.senderColor, 801 this.senderPicturePath, 802 this.onRefresh, 803 }); 804 805 String _getDisplayText() { 806 if (message.contentType == ContentType.document && message.filename != null) { 807 return message.filename!; 808 } 809 return utf8.decode(message.content, allowMalformed: true); 810 } 811 812 Widget _buildSenderAvatar(AppColorScheme colors) { 813 if (senderPicturePath != null) { 814 final file = File(senderPicturePath!); 815 if (file.existsSync()) { 816 return CircleAvatar( 817 radius: 8, 818 backgroundImage: FileImage(file), 819 ); 820 } 821 } 822 return CircleAvatar( 823 radius: 8, 824 backgroundColor: senderColor ?? colors.primary, 825 child: Text( 826 (senderName ?? '?')[0].toUpperCase(), 827 style: TextStyle( 828 fontSize: 8, 829 color: colors.onPrimary, 830 fontWeight: FontWeight.w600, 831 ), 832 ), 833 ); 834 } 835 836 @override 837 Widget build(BuildContext context) { 838 final colors = AppColors.of(context); 839 final isOutbound = message.isOutbound; 840 final displayText = _getDisplayText(); 841 final isImage = _isImageContent(message.content); 842 final isDoc = message.contentType == ContentType.document && message.filename != null; 843 final isBinary = !message.isOutbound && message.contentType != ContentType.text && _isBinaryContent(message.content); 844 final hasReactions = message.reactions.isNotEmpty; 845 846 final bubble = GestureDetector( 847 onLongPress: () => _showMessageOptions(context, displayText, colors), 848 child: Container( 849 margin: const EdgeInsets.symmetric(vertical: AppSpacing.xs), 850 constraints: BoxConstraints( 851 maxWidth: AppResponsive.maxBubbleWidth(context), 852 ), 853 decoration: BoxDecoration( 854 color: isOutbound 855 ? colors.messageBubbleOutbound 856 : colors.messageBubbleInbound, 857 borderRadius: BorderRadius.only( 858 topLeft: const Radius.circular(AppRadius.messageBubble), 859 topRight: const Radius.circular(AppRadius.messageBubble), 860 bottomLeft: Radius.circular( 861 isOutbound 862 ? AppRadius.messageBubble 863 : AppRadius.messageBubbleCorner, 864 ), 865 bottomRight: Radius.circular( 866 isOutbound 867 ? AppRadius.messageBubbleCorner 868 : AppRadius.messageBubble, 869 ), 870 ), 871 boxShadow: [ 872 BoxShadow( 873 color: Colors.black.withAlpha(15), 874 blurRadius: 4, 875 offset: const Offset(0, 1), 876 ), 877 ], 878 ), 879 padding: const EdgeInsets.symmetric( 880 horizontal: AppSpacing.sm + 4, 881 vertical: AppSpacing.sm, 882 ), 883 child: Column( 884 crossAxisAlignment: CrossAxisAlignment.start, 885 children: [ 886 // Sender name + avatar (for inbound group messages) 887 if (!isOutbound && senderName != null) 888 Padding( 889 padding: const EdgeInsets.only(bottom: 2), 890 child: Row( 891 mainAxisSize: MainAxisSize.min, 892 children: [ 893 _buildSenderAvatar(colors), 894 const SizedBox(width: 4), 895 Text( 896 senderName!, 897 style: Theme.of(context).textTheme.labelSmall?.copyWith( 898 color: senderColor ?? colors.primary, 899 fontWeight: FontWeight.w600, 900 ), 901 ), 902 ], 903 ), 904 ), 905 906 // Message content 907 if (isImage) 908 _buildImagePreview(context, colors, isOutbound) 909 else if (isDoc || isBinary) 910 _buildFileAttachment(context, colors, isOutbound) 911 else 912 LinkifiedText( 913 displayText, 914 style: Theme.of(context).textTheme.bodyMedium?.copyWith( 915 color: isOutbound 916 ? colors.onPrimaryContainer 917 : colors.onSurface, 918 ), 919 ), 920 921 const SizedBox(height: AppSpacing.xxs), 922 923 // Timestamp + status 924 Row( 925 mainAxisSize: MainAxisSize.min, 926 children: [ 927 Text( 928 _formatTime(message.timestamp.toInt()), 929 style: Theme.of(context).textTheme.labelSmall?.copyWith( 930 color: (isOutbound 931 ? colors.onPrimaryContainer 932 : colors.onBackgroundSecondary) 933 .withAlpha(180), 934 ), 935 ), 936 if (isOutbound) ...[ 937 const SizedBox(width: AppSpacing.xxs), 938 _buildStatusWidget( 939 message.state, 940 message.deliveryTransport, 941 colors.onPrimaryContainer.withAlpha(180), 942 colors.messageBubbleOutbound, 943 ), 944 ], 945 ], 946 ), 947 ], 948 ), 949 ), 950 ); 951 952 if (!hasReactions) { 953 return Align( 954 alignment: isOutbound ? Alignment.centerRight : Alignment.centerLeft, 955 child: bubble, 956 ); 957 } 958 959 return Align( 960 alignment: isOutbound ? Alignment.centerRight : Alignment.centerLeft, 961 child: Stack( 962 clipBehavior: Clip.none, 963 children: [ 964 bubble, 965 // Position reactions at inner corner (closest to center) 966 Positioned( 967 top: -4, 968 left: isOutbound ? -6 : null, 969 right: isOutbound ? null : -6, 970 child: _buildReactionBadges(colors), 971 ), 972 ], 973 ), 974 ); 975 } 976 977 /// Build horizontally card-stacked reaction badges. 978 Widget _buildReactionBadges(AppColorScheme colors) { 979 return Row( 980 mainAxisSize: MainAxisSize.min, 981 children: message.reactions.map((r) { 982 return Container( 983 margin: const EdgeInsets.only(right: 2), 984 padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), 985 decoration: BoxDecoration( 986 color: colors.surface, 987 borderRadius: BorderRadius.circular(8), 988 border: Border.all( 989 color: colors.onSurface.withAlpha(30), 990 width: 0.5, 991 ), 992 boxShadow: [ 993 BoxShadow( 994 color: Colors.black.withAlpha(15), 995 blurRadius: 2, 996 offset: const Offset(0, 1), 997 ), 998 ], 999 ), 1000 child: Row( 1001 mainAxisSize: MainAxisSize.min, 1002 children: [ 1003 Text(r.emoji, style: const TextStyle(fontSize: 13)), 1004 if (r.count > 1) ...[ 1005 const SizedBox(width: 2), 1006 Text( 1007 r.count.toString(), 1008 style: TextStyle( 1009 fontSize: 10, 1010 color: colors.onSurface.withAlpha(180), 1011 fontWeight: FontWeight.w500, 1012 ), 1013 ), 1014 ], 1015 ], 1016 ), 1017 ); 1018 }).toList(), 1019 ); 1020 } 1021 1022 void _showMessageOptions(BuildContext context, String messageText, AppColorScheme colors) { 1023 showModalBottomSheet( 1024 context: context, 1025 builder: (context) => SafeArea( 1026 child: Padding( 1027 padding: const EdgeInsets.symmetric(vertical: 8), 1028 child: Column( 1029 mainAxisSize: MainAxisSize.min, 1030 children: [ 1031 QuickEmojiPicker( 1032 onEmojiSelected: (emoji) { 1033 Navigator.pop(context); 1034 _toggleReaction(emoji); 1035 }, 1036 ), 1037 Divider(color: colors.onSurface.withAlpha(30), height: 1), 1038 ListTile( 1039 leading: ThemedIcon(Majesticons.clipboard_copy_line, color: colors.onSurface), 1040 title: Text('Copy', style: TextStyle(color: colors.onSurface)), 1041 onTap: () { 1042 Clipboard.setData(ClipboardData(text: messageText)); 1043 Navigator.pop(context); 1044 ScaffoldMessenger.of(context).showSnackBar( 1045 SnackBar( 1046 content: const Text('Message copied to clipboard'), 1047 duration: const Duration(seconds: 2), 1048 backgroundColor: colors.primary, 1049 ), 1050 ); 1051 }, 1052 ), 1053 ], 1054 ), 1055 ), 1056 ), 1057 ); 1058 } 1059 1060 void _toggleReaction(String emoji) { 1061 api.toggleReaction(messageId: message.messageId, emoji: emoji); 1062 onRefresh?.call(); 1063 } 1064 1065 Widget _buildImagePreview( 1066 BuildContext context, 1067 AppColorScheme colors, 1068 bool isOutbound, 1069 ) { 1070 final msgIdHex = message.messageId 1071 .map((b) => b.toRadixString(16).padLeft(2, '0')) 1072 .join(); 1073 1074 // Use shared cache to preserve image provider identity across rebuilds 1075 final cached = imageCache[msgIdHex]; 1076 final ImageProvider imageProvider; 1077 1078 if (cached != null) { 1079 imageProvider = cached.$1; 1080 } else { 1081 final bytes = Uint8List.fromList(message.content); 1082 final isGif = bytes.length >= 4 && 1083 bytes[0] == 0x47 && bytes[1] == 0x49 && 1084 bytes[2] == 0x46 && bytes[3] == 0x38; 1085 imageProvider = isGif 1086 ? MemoryImage(bytes) 1087 : ResizeImage(MemoryImage(bytes), width: 500); 1088 1089 if (imageCache.length >= imageCacheMaxSize) { 1090 imageCache.remove(imageCache.keys.first); 1091 } 1092 imageCache[msgIdHex] = (imageProvider, bytes); 1093 } 1094 1095 return ClipRRect( 1096 borderRadius: BorderRadius.circular(8), 1097 child: ConstrainedBox( 1098 constraints: const BoxConstraints(maxWidth: 250, maxHeight: 300), 1099 child: Image( 1100 image: imageProvider, 1101 fit: BoxFit.contain, 1102 gaplessPlayback: true, 1103 errorBuilder: (_, __, ___) => Container( 1104 width: 120, 1105 height: 80, 1106 decoration: BoxDecoration( 1107 color: colors.primary.withAlpha(30), 1108 borderRadius: BorderRadius.circular(8), 1109 ), 1110 child: Center( 1111 child: ThemedIcon(Majesticons.image_off_line, 1112 size: 32, color: colors.primary), 1113 ), 1114 ), 1115 ), 1116 ), 1117 ); 1118 } 1119 1120 Widget _buildFileAttachment( 1121 BuildContext context, 1122 AppColorScheme colors, 1123 bool isOutbound, 1124 ) { 1125 final filename = message.filename ?? 'Document'; 1126 final size = message.content.length; 1127 return Container( 1128 padding: const EdgeInsets.all(AppSpacing.sm), 1129 decoration: BoxDecoration( 1130 color: isOutbound 1131 ? colors.onPrimaryContainer.withAlpha(20) 1132 : colors.primary.withAlpha(15), 1133 borderRadius: BorderRadius.circular(8), 1134 ), 1135 child: Row( 1136 mainAxisSize: MainAxisSize.min, 1137 children: [ 1138 ThemedIcon(Majesticons.file_line, 1139 size: 24, 1140 color: isOutbound 1141 ? colors.onPrimaryContainer 1142 : colors.primary), 1143 const SizedBox(width: AppSpacing.sm), 1144 Flexible( 1145 child: Column( 1146 crossAxisAlignment: CrossAxisAlignment.start, 1147 children: [ 1148 Text( 1149 filename, 1150 style: Theme.of(context).textTheme.bodySmall?.copyWith( 1151 fontWeight: FontWeight.w600, 1152 color: isOutbound 1153 ? colors.onPrimaryContainer 1154 : colors.onSurface, 1155 ), 1156 maxLines: 1, 1157 overflow: TextOverflow.ellipsis, 1158 ), 1159 Text( 1160 _formatFileSize(size), 1161 style: Theme.of(context).textTheme.labelSmall?.copyWith( 1162 color: (isOutbound 1163 ? colors.onPrimaryContainer 1164 : colors.onBackgroundSecondary) 1165 .withAlpha(180), 1166 ), 1167 ), 1168 ], 1169 ), 1170 ), 1171 ], 1172 ), 1173 ); 1174 } 1175 1176 String _getStatusIcon(int state) { 1177 switch (state) { 1178 case 0: return Majesticons.edit_pen_2_line; // Draft 1179 case 1: return Majesticons.clock_line; // Queued 1180 case 2: return Majesticons.cloud_upload_line; // Transmitting 1181 case 3: return Majesticons.check_line; // Delivered 1182 case 4: return Majesticons.badge_check_line; // Acknowledged 1183 case 5: return Majesticons.timer_line; // Expired 1184 case 6: return Majesticons.exclamation_circle_line; // Failed 1185 case 7: return Majesticons.clock_line; // PendingRelay 1186 default: return Majesticons.question_mark_circle_line; 1187 } 1188 } 1189 1190 String? _getTransportIcon(String? transport) { 1191 if (transport == null) return null; 1192 return switch (transport) { 1193 'ble' => Majesticons.bluetooth_line, 1194 'ble-gossip' => Majesticons.sitemap_line, 1195 'dht' => Majesticons.cloud_upload_line, 1196 'relay' => Majesticons.cloud_upload_line, 1197 'tor' => Majesticons.shield_check_line, 1198 'iroh' => Majesticons.sort_horizontal_line, 1199 _ => null, 1200 }; 1201 } 1202 1203 Widget _buildStatusWidget(int state, String? transport, Color iconColor, Color bgColor) { 1204 final transportIcon = _getTransportIcon(transport); 1205 if ((state == 3 || state == 4) && transportIcon != null) { 1206 if (state == 3) { 1207 return ThemedIcon(transportIcon, size: 14, color: iconColor); 1208 } 1209 return SizedBox( 1210 width: 20, 1211 height: 16, 1212 child: Stack( 1213 clipBehavior: Clip.none, 1214 children: [ 1215 ThemedIcon(transportIcon, size: 14, color: iconColor), 1216 Positioned( 1217 right: 0, 1218 bottom: -1, 1219 child: DecoratedBox( 1220 decoration: BoxDecoration(color: bgColor, shape: BoxShape.circle), 1221 child: Icon(Icons.check_circle, size: 9, color: iconColor), 1222 ), 1223 ), 1224 ], 1225 ), 1226 ); 1227 } 1228 return ThemedIcon(_getStatusIcon(state), size: 14, color: iconColor); 1229 } 1230 } 1231 1232 class _SystemMessage extends StatelessWidget { 1233 final String text; 1234 const _SystemMessage({super.key, required this.text}); 1235 1236 @override 1237 Widget build(BuildContext context) { 1238 final colors = AppColors.of(context); 1239 return Center( 1240 child: Padding( 1241 padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), 1242 child: Text( 1243 text, 1244 style: Theme.of(context).textTheme.bodySmall?.copyWith( 1245 color: colors.onBackgroundSecondary, 1246 fontStyle: FontStyle.italic, 1247 ), 1248 ), 1249 ), 1250 ); 1251 } 1252 }