import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../utils/platform_info.dart'; import '../helpers/chat_scroll_controller.dart'; import '../connector/meshcore_protocol.dart'; import '../helpers/link_handler.dart'; import '../helpers/reaction_helper.dart'; import '../helpers/utf8_length_limiter.dart'; import '../l10n/l10n.dart'; import '../models/channel.dart'; import '../models/channel_message.dart'; import '../services/app_settings_service.dart'; import '../services/chat_text_scale_service.dart'; import '../utils/emoji_utils.dart'; import '../widgets/chat_zoom_wrapper.dart'; import '../widgets/emoji_picker.dart'; import '../widgets/gif_message.dart'; import '../widgets/jump_to_bottom_button.dart'; import '../widgets/gif_picker.dart'; import '../widgets/message_status_icon.dart'; import 'channel_message_path_screen.dart'; import 'map_screen.dart'; class ChannelChatScreen extends StatefulWidget { final Channel channel; const ChannelChatScreen({super.key, required this.channel}); @override State createState() => _ChannelChatScreenState(); } class _ChannelChatScreenState extends State { final TextEditingController _textController = TextEditingController(); final ChatScrollController _scrollController = ChatScrollController(); final FocusNode _textFieldFocusNode = FocusNode(); ChannelMessage? _replyingToMessage; final Map _messageKeys = {}; bool _isLoadingOlder = false; MeshCoreConnector? _connector; @override void initState() { super.initState(); _textFieldFocusNode.addListener(_onTextFieldFocusChange); _scrollController.onScrollNearTop = _loadOlderMessages; SchedulerBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _connector = context.read(); _connector?.setActiveChannel(widget.channel.index); }); } void _onTextFieldFocusChange() { if (_textFieldFocusNode.hasFocus && mounted) { _scrollController.handleKeyboardOpen(); } } Future _loadOlderMessages() async { if (_isLoadingOlder) return; setState(() => _isLoadingOlder = true); final connector = context.read(); await connector.loadOlderChannelMessages(widget.channel.index); if (mounted) { setState(() => _isLoadingOlder = false); } } @override void dispose() { _connector?.setActiveChannel(null); _textFieldFocusNode.removeListener(_onTextFieldFocusChange); _textFieldFocusNode.dispose(); _textController.dispose(); _scrollController.dispose(); super.dispose(); } void _setReplyingTo(ChannelMessage message) { setState(() { _replyingToMessage = message; }); } void _cancelReply() { setState(() { _replyingToMessage = null; }); } Future _scrollToMessage(String messageId) async { final key = _messageKeys[messageId]; if (key == null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(context.l10n.chat_originalMessageNotFound), duration: const Duration(seconds: 2), ), ); return; } final targetContext = key.currentContext; if (targetContext == null) return; await Scrollable.ensureVisible( targetContext, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, alignment: 0.3, ); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Row( children: [ Icon( widget.channel.isPublicChannel ? Icons.public : Icons.tag, size: 20, ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.channel.name.isEmpty ? context.l10n.channels_channelIndex( widget.channel.index, ) : widget.channel.name, style: const TextStyle(fontSize: 16), ), Consumer( builder: (context, connector, _) { final unreadCount = connector .getUnreadCountForChannelIndex(widget.channel.index); final privacy = widget.channel.isPublicChannel ? context.l10n.channels_public : context.l10n.channels_private; return Text( '$privacy • ${context.l10n.chat_unread(unreadCount)}', overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12), ); }, ), ], ), ), ], ), centerTitle: false, actions: [ PopupMenuButton( icon: const Icon(Icons.more_vert), onSelected: (value) { if (value == 'clearChat') { context.read().clearMessagesForChannel( widget.channel.index, ); } }, itemBuilder: (context) => [ PopupMenuItem( value: 'clearChat', child: Row( children: [ const Icon(Icons.delete, size: 20, color: Colors.red), const SizedBox(width: 12), Text( context.l10n.contact_clearChat, style: const TextStyle(color: Colors.red), ), ], ), ), ], ), ], ), body: SafeArea( top: false, child: Column( children: [ Expanded( child: Consumer( builder: (context, connector, child) { final messages = connector.getChannelMessages(widget.channel); if (messages.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( widget.channel.isPublicChannel ? Icons.public : Icons.tag, size: 64, color: Colors.grey[400], ), const SizedBox(height: 16), Text( context.l10n.chat_noMessages, style: TextStyle( fontSize: 16, color: Colors.grey[600], ), ), const SizedBox(height: 8), Text( context.l10n.chat_sendMessageToStart, style: TextStyle( fontSize: 14, color: Colors.grey[500], ), ), ], ), ); } // Reverse messages so newest appear at bottom with reverse: true final reversedMessages = messages.reversed.toList(); final itemCount = reversedMessages.length + (_isLoadingOlder ? 1 : 0); // Auto-scroll to bottom if user is already at bottom WidgetsBinding.instance.addPostFrameCallback((_) { _scrollController.scrollToBottomIfAtBottom(); }); return Stack( children: [ ChatZoomWrapper( child: ListView.builder( reverse: true, // List grows from bottom up controller: _scrollController, padding: const EdgeInsets.all(8), itemCount: itemCount, itemBuilder: (context, index) { // Loading indicator now appears at end (bottom) of reversed list if (_isLoadingOlder && index == itemCount - 1) { return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, ), ), ), ); } final messageIndex = index; final message = reversedMessages[messageIndex]; if (!_messageKeys.containsKey(message.messageId)) { _messageKeys[message.messageId] = GlobalKey(); } return Container( key: _messageKeys[message.messageId]!, child: Builder( builder: (context) { final textScale = context .select( (service) => service.scale, ); return _buildMessageBubble( message, textScale, ); }, ), ); }, ), ), JumpToBottomButton(scrollController: _scrollController), ], ); }, ), ), _buildMessageComposer(), ], ), ), ); } Widget _buildMessageBubble(ChannelMessage message, double textScale) { final settingsService = context.watch(); final enableTracing = settingsService.settings.enableMessageTracing; final isOutgoing = message.isOutgoing; final gifId = _parseGifId(message.text); final poi = _parsePoiMessage(message.text); final displayPath = message.pathBytes.isNotEmpty ? message.pathBytes : (message.pathVariants.isNotEmpty ? message.pathVariants.first : Uint8List(0)); const maxSwipeOffset = 64.0; const replySwipeThreshold = 64.0; const bodyFontSize = 14.0; final messageBody = Column( crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isOutgoing) ...[ _buildAvatar(message.senderName), const SizedBox(width: 8), ], Flexible( child: GestureDetector( onTap: PlatformInfo.isDesktop ? null : () => _showMessagePathInfo(message), onLongPress: () => _showMessageActions(message), onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => _showMessageActions(message) : null, child: Container( padding: gifId != null ? const EdgeInsets.all(4) : const EdgeInsets.symmetric(horizontal: 12, vertical: 8), constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.65, ), decoration: BoxDecoration( color: isOutgoing ? Theme.of(context).colorScheme.primaryContainer : Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isOutgoing) ...[ Padding( padding: gifId != null ? const EdgeInsets.only( left: 8, top: 4, bottom: 4, ) : EdgeInsets.zero, child: Text( message.senderName, style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary, ), ), ), if (gifId == null) const SizedBox(height: 4), ], if (message.replyToMessageId != null) ...[ _buildReplyPreview(message, textScale), const SizedBox(height: 8), ], if (poi != null) _buildPoiMessage( context, poi, isOutgoing, textScale, trailing: (!enableTracing && isOutgoing) ? Padding( padding: const EdgeInsets.only(bottom: 2), child: MessageStatusIcon( isAcked: message.status == ChannelMessageStatus.sent && displayPath.isNotEmpty, isFailed: message.status == ChannelMessageStatus.failed, ), ) : null, ) else if (gifId != null) Stack( children: [ ClipRRect( borderRadius: BorderRadius.circular(8), child: GifMessage( url: 'https://media.giphy.com/media/$gifId/giphy.gif', backgroundColor: Colors.transparent, fallbackTextColor: isOutgoing ? Theme.of(context) .colorScheme .onPrimaryContainer .withValues(alpha: 0.7) : Theme.of(context).colorScheme.onSurface .withValues(alpha: 0.6), ), ), if (!enableTracing && isOutgoing) Positioned( top: 0, right: 0, child: Container( padding: const EdgeInsets.all(3), decoration: BoxDecoration( color: isOutgoing ? Theme.of( context, ).colorScheme.primaryContainer : Theme.of( context, ).colorScheme.surfaceContainerHighest, borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(10), topRight: Radius.circular(8), ), ), child: MessageStatusIcon( isAcked: message.status == ChannelMessageStatus.sent && displayPath.isNotEmpty, isFailed: message.status == ChannelMessageStatus.failed, ), ), ), ], ) else Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ Flexible( child: LinkHandler.buildLinkifyText( context: context, text: message.text, style: TextStyle( fontSize: bodyFontSize * textScale, ), linkStyle: TextStyle( fontSize: bodyFontSize * textScale, color: Colors.green, decoration: TextDecoration.underline, ), ), ), if (!enableTracing && isOutgoing) ...[ const SizedBox(width: 4), Padding( padding: const EdgeInsets.only(bottom: 2), child: MessageStatusIcon( isAcked: message.status == ChannelMessageStatus.sent && displayPath.isNotEmpty, isFailed: message.status == ChannelMessageStatus.failed, ), ), ], ], ), if (enableTracing) ...[ if (displayPath.isNotEmpty) ...[ const SizedBox(height: 4), Padding( padding: gifId != null ? const EdgeInsets.symmetric(horizontal: 8) : EdgeInsets.zero, child: Text( 'via ${_formatPathPrefixes(displayPath)}', style: TextStyle( fontSize: 11, color: Colors.grey[600], ), ), ), ], const SizedBox(height: 4), Padding( padding: gifId != null ? const EdgeInsets.only( left: 8, right: 8, bottom: 4, ) : EdgeInsets.zero, child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( _formatTime(message.timestamp), style: TextStyle( fontSize: 11, color: Colors.grey[600], ), ), if (message.repeatCount > 0) ...[ const SizedBox(width: 6), Icon( Icons.repeat, size: 12, color: Colors.grey[600], ), const SizedBox(width: 2), Text( '${message.repeatCount}', style: TextStyle( fontSize: 11, color: Colors.grey[600], ), ), ], if (isOutgoing) ...[ const SizedBox(width: 4), Icon( message.status == ChannelMessageStatus.sent ? Icons.check : message.status == ChannelMessageStatus.pending ? Icons.schedule : Icons.error_outline, size: 14, color: message.status == ChannelMessageStatus.failed ? Colors.red : Colors.grey[600], ), ], ], ), ), ], ], ), ), ), ), ], ), if (message.reactions.isNotEmpty) ...[ const SizedBox(height: 4), Padding( padding: EdgeInsets.only(left: isOutgoing ? 0 : 48), child: _buildReactionsDisplay(message), ), ], ], ); if (!isOutgoing && !PlatformInfo.isDesktop) { return _SwipeReplyBubble( maxSwipeOffset: maxSwipeOffset, replySwipeThreshold: replySwipeThreshold, onReplyTriggered: () => _setReplyingTo(message), hintBuilder: ({required isStart}) => _buildReplySwipeHint(isStart: isStart), child: messageBody, ); } else { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: messageBody, ); } } Widget _buildReplySwipeHint({required bool isStart}) { final colorScheme = Theme.of(context).colorScheme; final content = Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.reply, color: colorScheme.primary), const SizedBox(width: 6), Text( context.l10n.chat_reply, style: TextStyle( color: colorScheme.primary, fontWeight: FontWeight.w600, ), ), ], ); return Container( alignment: isStart ? Alignment.centerLeft : Alignment.centerRight, padding: const EdgeInsets.symmetric(horizontal: 16), color: colorScheme.primary.withValues(alpha: 0.08), child: isStart ? content : Row( mainAxisSize: MainAxisSize.min, children: [ Text( context.l10n.chat_reply, style: TextStyle( color: colorScheme.primary, fontWeight: FontWeight.w600, ), ), const SizedBox(width: 6), Icon(Icons.reply, color: colorScheme.primary), ], ), ); } Widget _buildReplyPreview(ChannelMessage message, double textScale) { final connector = context.read(); final isOwnNode = message.replyToSenderName == connector.selfName; final replyText = message.replyToText ?? ''; final colorScheme = Theme.of(context).colorScheme; final previewTextColor = colorScheme.onSurface.withValues(alpha: 0.7); final gifId = _parseGifId(replyText); final poi = _parsePoiMessage(replyText); Widget contentPreview; if (gifId != null) { contentPreview = ClipRRect( borderRadius: BorderRadius.circular(4), child: GifMessage( url: 'https://media.giphy.com/media/$gifId/giphy.gif', backgroundColor: colorScheme.surfaceContainerHighest, fallbackTextColor: previewTextColor, maxSize: 80, ), ); } else if (poi != null) { contentPreview = Row( children: [ Icon(Icons.location_on_outlined, size: 14, color: previewTextColor), const SizedBox(width: 4), Text( context.l10n.chat_location, style: TextStyle(fontSize: 12 * textScale, color: previewTextColor), ), ], ); } else { contentPreview = Text( replyText, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12 * textScale, color: previewTextColor, fontStyle: FontStyle.italic, ), ); } return GestureDetector( onTap: () => _scrollToMessage(message.replyToMessageId!), child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(8), border: Border( left: BorderSide(color: colorScheme.primary, width: 3), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( context.l10n.chat_replyTo(message.replyToSenderName ?? ''), style: TextStyle( fontSize: 11 * textScale, fontWeight: FontWeight.bold, color: isOwnNode ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onSurface, ), ), const SizedBox(height: 2), contentPreview, ], ), ), ); } Widget _buildReactionsDisplay(ChannelMessage message) { return Wrap( spacing: 6, runSpacing: 6, children: message.reactions.entries.map((entry) { final emoji = entry.key; final count = entry.value; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(12), border: Border.all( color: Theme.of( context, ).colorScheme.outline.withValues(alpha: 0.3), width: 1, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text(emoji, style: const TextStyle(fontSize: 16)), if (count > 1) ...[ const SizedBox(width: 4), Text( '$count', style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSecondaryContainer, ), ), ], ], ), ); }).toList(), ); } String? _parseGifId(String text) { final trimmed = text.trim(); final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed); return match?.group(1); } _PoiInfo? _parsePoiMessage(String text) { final trimmed = text.trim(); final match = RegExp( r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|', ).firstMatch(trimmed); if (match == null) return null; final lat = double.tryParse(match.group(1) ?? ''); final lon = double.tryParse(match.group(2) ?? ''); if (lat == null || lon == null) return null; final label = match.group(3) ?? ''; return _PoiInfo(lat: lat, lon: lon, label: label); } Widget _buildPoiMessage( BuildContext context, _PoiInfo poi, bool isOutgoing, double textScale, { Widget? trailing, }) { final colorScheme = Theme.of(context).colorScheme; final textColor = isOutgoing ? colorScheme.onPrimaryContainer : colorScheme.onSurface; final metaColor = textColor.withValues(alpha: 0.7); final channelColor = widget.channel.isPublicChannel ? Colors.orange : Colors.blue; return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( icon: Icon(Icons.location_on_outlined, color: channelColor), padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), onPressed: () { Navigator.push( context, MaterialPageRoute( builder: (context) => MapScreen( highlightPosition: LatLng(poi.lat, poi.lon), highlightLabel: poi.label, ), ), ); }, ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( context.l10n.chat_poiShared, style: TextStyle( color: textColor, fontWeight: FontWeight.w600, fontSize: 14 * textScale, ), ), if (poi.label.isNotEmpty) Text( poi.label, style: TextStyle(color: metaColor, fontSize: 12 * textScale), ), ], ), ), if (trailing != null) ...[const SizedBox(width: 4), trailing], ], ); } void _showGifPicker(BuildContext context) { showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => GifPicker( onGifSelected: (gifId) { _textController.text = 'g:$gifId'; }, ), ); } Widget _buildAvatar(String senderName) { final initial = _getFirstCharacterOrEmoji(senderName); final color = _getColorForName(senderName); return CircleAvatar( radius: 18, backgroundColor: color.withValues(alpha: 0.2), child: Text( initial, style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: color, ), ), ); } String _getFirstCharacterOrEmoji(String name) { if (name.isEmpty) return '?'; final emoji = firstEmoji(name); if (emoji != null) return emoji; final runes = name.runes.toList(); if (runes.isEmpty) return '?'; return String.fromCharCode(runes[0]).toUpperCase(); } Color _getColorForName(String name) { // Generate a consistent color based on the name hash final hash = name.hashCode; final colors = [ Colors.blue, Colors.green, Colors.orange, Colors.purple, Colors.pink, Colors.teal, Colors.indigo, Colors.cyan, Colors.amber, Colors.deepOrange, ]; return colors[hash.abs() % colors.length]; } Widget _buildReplyBanner(double textScale) { final message = _replyingToMessage!; return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondaryContainer, border: Border( bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1), ), ), child: Row( children: [ Icon( Icons.reply, size: 18, color: Theme.of(context).colorScheme.onSecondaryContainer, ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( context.l10n.chat_replyingTo(message.senderName), style: TextStyle( fontSize: 12 * textScale, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSecondaryContainer, ), ), Text( message.text, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11 * textScale, color: Theme.of( context, ).colorScheme.onSecondaryContainer.withValues(alpha: 0.7), ), ), ], ), ), IconButton( icon: const Icon(Icons.close, size: 18), onPressed: _cancelReply, color: Theme.of(context).colorScheme.onSecondaryContainer, padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ], ), ); } Widget _buildMessageComposer() { final connector = context.watch(); final maxBytes = maxChannelMessageBytes(connector.selfName); return Column( mainAxisSize: MainAxisSize.min, children: [ if (_replyingToMessage != null) Builder( builder: (context) { final textScale = context.select( (service) => service.scale, ); return _buildReplyBanner(textScale); }, ), Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.1), blurRadius: 4, offset: const Offset(0, -2), ), ], ), child: Row( children: [ IconButton( icon: const Icon(Icons.gif_box), onPressed: () => _showGifPicker(context), tooltip: context.l10n.chat_sendGif, ), Expanded( child: ValueListenableBuilder( valueListenable: _textController, builder: (context, value, child) { final gifId = _parseGifId(value.text); if (gifId != null) { return Focus( autofocus: true, onKeyEvent: (node, event) { if (event is KeyDownEvent && (event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter)) { _sendMessage(); return KeyEventResult.handled; } return KeyEventResult.ignored; }, child: Row( children: [ Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(12), child: GifMessage( url: 'https://media.giphy.com/media/$gifId/giphy.gif', backgroundColor: Theme.of( context, ).colorScheme.surfaceContainerHighest, fallbackTextColor: Theme.of(context) .colorScheme .onSurface .withValues(alpha: 0.6), maxSize: 160, ), ), ), const SizedBox(width: 8), IconButton( icon: const Icon(Icons.close), onPressed: () { _textController.clear(); _textFieldFocusNode.requestFocus(); }, ), ], ), ); } return TextField( controller: _textController, focusNode: _textFieldFocusNode, inputFormatters: [ Utf8LengthLimitingTextInputFormatter(maxBytes), ], textCapitalization: TextCapitalization.sentences, decoration: InputDecoration( hintText: context.l10n.chat_typeMessage, border: OutlineInputBorder( borderRadius: BorderRadius.circular(24), ), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), ), maxLines: null, textInputAction: TextInputAction.send, onSubmitted: (_) => _sendMessage(), ); }, ), ), const SizedBox(width: 8), IconButton( icon: const Icon(Icons.send), onPressed: _sendMessage, color: Theme.of(context).colorScheme.primary, ), ], ), ), ], ); } void _sendMessage() { final text = _textController.text.trim(); if (text.isEmpty) return; final connector = context.read(); String messageText = text; if (_replyingToMessage != null) { messageText = '@[${_replyingToMessage!.senderName}] $text'; } final maxBytes = maxChannelMessageBytes(connector.selfName); if (utf8.encode(messageText).length > maxBytes) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))), ); return; } connector.sendChannelMessage(widget.channel, messageText); _textController.clear(); _cancelReply(); _textFieldFocusNode.requestFocus(); } String _formatTime(DateTime time) { final now = DateTime.now(); final diff = now.difference(time); if (diff.inDays > 0) { return '${time.day}/${time.month} ${time.hour}:${time.minute.toString().padLeft(2, '0')}'; } else { return '${time.hour}:${time.minute.toString().padLeft(2, '0')}'; } } void _showMessagePathInfo(ChannelMessage message) { Navigator.push( context, MaterialPageRoute( builder: (context) => ChannelMessagePathScreen(message: message, channelMessage: true), ), ); } void _showMessageActions(ChannelMessage message) { showModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.reply), title: Text(context.l10n.chat_reply), onTap: () { Navigator.pop(sheetContext); _setReplyingTo(message); }, ), if (PlatformInfo.isDesktop) ListTile( leading: const Icon(Icons.route), title: Text(context.l10n.chat_path), onTap: () { Navigator.pop(sheetContext); _showMessagePathInfo(message); }, ), // Can't react to your own messages if (!message.isOutgoing) ListTile( leading: const Icon(Icons.add_reaction_outlined), title: Text(context.l10n.chat_addReaction), onTap: () { Navigator.pop(sheetContext); _showEmojiPicker(message); }, ), ListTile( leading: const Icon(Icons.copy), title: Text(context.l10n.common_copy), onTap: () { Navigator.pop(sheetContext); _copyMessageText(message.text); }, ), ListTile( leading: const Icon(Icons.delete_outline), title: Text(context.l10n.common_delete), onTap: () async { Navigator.pop(sheetContext); await _deleteMessage(message); }, ), ListTile( leading: const Icon(Icons.close), title: Text(context.l10n.common_cancel), onTap: () => Navigator.pop(sheetContext), ), ], ), ), ); } void _showEmojiPicker(ChannelMessage message) { showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => EmojiPicker( onEmojiSelected: (emoji) { _sendReaction(message, emoji); }, ), ); } void _sendReaction(ChannelMessage message, String emoji) { final connector = context.read(); final emojiIndex = ReactionHelper.emojiToIndex(emoji); if (emojiIndex == null) return; // Unknown emoji, skip final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000; final hash = ReactionHelper.computeReactionHash( timestampSecs, message.senderName, message.text, ); final reactionText = 'r:$hash:$emojiIndex'; connector.sendChannelMessage(widget.channel, reactionText); } void _copyMessageText(String text) { Clipboard.setData(ClipboardData(text: text)); ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied))); } Future _deleteMessage(ChannelMessage message) async { await context.read().deleteChannelMessage(message); if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted))); } String _formatPathPrefixes(Uint8List pathBytes) { return pathBytes .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) .join(','); } } class _SwipeReplyBubble extends StatefulWidget { final double maxSwipeOffset; final double replySwipeThreshold; final VoidCallback onReplyTriggered; final Widget Function({required bool isStart}) hintBuilder; final Widget child; const _SwipeReplyBubble({ required this.maxSwipeOffset, required this.replySwipeThreshold, required this.onReplyTriggered, required this.hintBuilder, required this.child, }); @override State<_SwipeReplyBubble> createState() => _SwipeReplyBubbleState(); } class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> { Offset? _swipeStartPosition; double _swipeOffset = 0; double _maxSwipeDistance = 0; int? _swipePointerId; bool _swipeLockedToHorizontal = false; void _handleSwipeStart(Offset position) { _swipeStartPosition = position; _maxSwipeDistance = 0; if (_swipeOffset != 0) { setState(() => _swipeOffset = 0); } } void _handleSwipePointerDown(PointerDownEvent event) { _swipePointerId = event.pointer; _swipeLockedToHorizontal = false; _handleSwipeStart(event.position); } void _handleSwipePointerMove(PointerMoveEvent event) { if (_swipePointerId != event.pointer || _swipeStartPosition == null) { return; } final dx = event.position.dx - _swipeStartPosition!.dx; const axisLockThreshold = 12.0; if (!_swipeLockedToHorizontal) { if (-dx < axisLockThreshold) { return; } _swipeLockedToHorizontal = true; } _handleSwipeUpdate(event.position); } void _handleSwipeUpdate(Offset position) { if (_swipeStartPosition == null) return; final dx = position.dx - _swipeStartPosition!.dx; if (dx >= 0) return; if (-dx < 6) return; if (-dx > _maxSwipeDistance) { _maxSwipeDistance = -dx; } final double clamped = dx.clamp(-widget.maxSwipeOffset, 0.0).toDouble(); final adjusted = _applySwipeResistance(clamped, widget.maxSwipeOffset); if (adjusted != _swipeOffset) { setState(() => _swipeOffset = adjusted); } } void _handleSwipePointerUp(Offset position) { if (_swipeLockedToHorizontal && _swipeStartPosition != null) { final dx = position.dx - _swipeStartPosition!.dx; final peak = math.max( _maxSwipeDistance, (-dx).clamp(0.0, double.infinity), ); if (peak >= widget.replySwipeThreshold) { widget.onReplyTriggered(); HapticFeedback.selectionClick(); } } _resetSwipe(); } void _resetSwipe() { if (_swipeOffset != 0) { setState(() => _swipeOffset = 0); } _swipeStartPosition = null; _maxSwipeDistance = 0; _swipePointerId = null; _swipeLockedToHorizontal = false; } double _applySwipeResistance(double rawOffset, double maxOffset) { final abs = rawOffset.abs(); if (abs <= 0) return 0; final norm = (abs / maxOffset).clamp(0.0, 1.0); const deadZone = 0.18; if (norm <= deadZone) { return rawOffset.sign * maxOffset * (norm * 0.08); } final t = ((norm - deadZone) / (1 - deadZone)).clamp(0.0, 1.0); final curved = t < 0.5 ? 16 * math.pow(t, 5) : 1 - math.pow(-2 * t + 2, 5) / 2; const deadZoneEnd = 0.0144; return rawOffset.sign * maxOffset * (deadZoneEnd + curved * (1 - deadZoneEnd)); } @override Widget build(BuildContext context) { return Listener( onPointerDown: _handleSwipePointerDown, onPointerMove: _handleSwipePointerMove, onPointerUp: (event) => _handleSwipePointerUp(event.position), onPointerCancel: (_) => _resetSwipe(), child: Padding( padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), child: Stack( alignment: Alignment.center, children: [ Positioned.fill( child: Opacity( opacity: _swipeOffset.abs() / widget.maxSwipeOffset, child: widget.hintBuilder(isStart: false), ), ), AnimatedContainer( duration: const Duration(milliseconds: 150), transform: Matrix4.translationValues(_swipeOffset, 0, 0), curve: Curves.easeOut, child: widget.child, ), ], ), ), ); } } class _PoiInfo { final double lat; final double lon; final String label; const _PoiInfo({required this.lat, required this.lon, required this.label}); }