/ app / lib / screens / groups / group_chat_screen.dart
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  }