import 'dart:async'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:matrix/matrix.dart'; import 'package:path_provider/path_provider.dart'; import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/theme.dart'; import 'package:pyramid/features/chat/attachment_dialog.dart'; import 'package:pyramid/features/chat/emoji_picker.dart'; import 'package:pyramid/features/chat/gif_sticker_picker.dart'; import 'package:pyramid/utils/clipboard_image.dart'; class ChatComposer extends ConsumerStatefulWidget { final Room room; final Event? replyTo; final VoidCallback? onClearReply; final VoidCallback? onOpenEmoji; const ChatComposer({ super.key, required this.room, this.replyTo, this.onClearReply, this.onOpenEmoji, }); @override ConsumerState createState() => _ChatComposerState(); } class _ChatComposerState extends ConsumerState { final _ctrl = TextEditingController(); late final FocusNode _focus; bool _canSend = false; OverlayEntry? _gifOverlay; OverlayEntry? _emojiOverlay; Timer? _typingTimer; bool _isTyping = false; @override void initState() { super.initState(); _focus = FocusNode(onKeyEvent: _handleKeyEvent); _ctrl.addListener(_onTextChanged); } KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.keyV && HardwareKeyboard.instance.isControlPressed) { final bytes = getClipboardImageBytes(); if (bytes != null) { _pasteClipboardImage(bytes); return KeyEventResult.handled; } } return KeyEventResult.ignored; } Future _pasteClipboardImage(Uint8List bytes) async { final dir = await getTemporaryDirectory(); final file = File('${dir.path}/paste_${DateTime.now().millisecondsSinceEpoch}.png'); await file.writeAsBytes(bytes); if (!mounted) return; await AttachmentDialog.show(context, [file], widget.room, replyTo: widget.replyTo); widget.onClearReply?.call(); file.delete().catchError((_) => file); } /// Bilder, die die Android-Tastatur einfügt (Gboard-Clipboard-Chip, /// GIF-Auswahl der Tastatur etc.) — landen im Anhang-Dialog mit Vorschau. Future _handleInsertedContent(KeyboardInsertedContent content) async { final bytes = content.data; if (bytes == null || bytes.isEmpty) return; final ext = content.mimeType.contains('/') ? content.mimeType.split('/').last : 'png'; final dir = await getTemporaryDirectory(); final file = File( '${dir.path}/keyboard_${DateTime.now().millisecondsSinceEpoch}.$ext'); await file.writeAsBytes(bytes); if (!mounted) return; await AttachmentDialog.show(context, [file], widget.room, replyTo: widget.replyTo); widget.onClearReply?.call(); file.delete().catchError((_) => file); } void _onTextChanged() { final has = _ctrl.text.trim().isNotEmpty; if (has != _canSend) setState(() => _canSend = has); _updateTyping(has); } void _updateTyping(bool typing) { final sendTyping = ref.read(privacyTypingProvider); if (!sendTyping) return; if (typing) { // Reset the auto-stop timer on every keystroke. _typingTimer?.cancel(); _typingTimer = Timer(const Duration(seconds: 7), () => _sendTyping(false)); if (!_isTyping) _sendTyping(true); } else { _typingTimer?.cancel(); if (_isTyping) _sendTyping(false); } } void _sendTyping(bool typing) { _isTyping = typing; widget.room .setTyping(typing, timeout: typing ? 10000 : null) .catchError((_) {}); } @override void dispose() { _typingTimer?.cancel(); if (_isTyping) widget.room.setTyping(false).catchError((_) {}); _gifOverlay?.remove(); _emojiOverlay?.remove(); _ctrl.dispose(); _focus.dispose(); super.dispose(); } void _toggleGifPicker() { if (_gifOverlay != null) { _gifOverlay!.remove(); _gifOverlay = null; return; } final platform = Theme.of(context).platform; final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS; if (isMobile) { showModalBottomSheet( context: context, backgroundColor: Colors.transparent, isScrollControlled: true, builder: (ctx) => Padding( padding: EdgeInsets.only( bottom: MediaQuery.viewInsetsOf(ctx).bottom, top: 40, ), child: GifStickerPicker( room: widget.room, onClose: () => Navigator.of(ctx).pop(), ), ), ); return; } final box = context.findRenderObject() as RenderBox?; final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero; final size = box?.size ?? Size.zero; _gifOverlay = OverlayEntry(builder: (ctx) { final screenSize = MediaQuery.sizeOf(ctx); const pickerW = 380.0; const pickerH = 460.0; final left = (offset.dx + size.width - pickerW).clamp(8.0, screenSize.width - pickerW - 8); final top = (offset.dy - pickerH - 8).clamp(8.0, screenSize.height - pickerH - 8); return Material( type: MaterialType.transparency, child: Stack(children: [ GestureDetector( onTap: () { _gifOverlay?.remove(); _gifOverlay = null; }, behavior: HitTestBehavior.translucent, child: const SizedBox.expand(), ), Positioned( left: left, top: top, child: GifStickerPicker( room: widget.room, onClose: () { _gifOverlay?.remove(); _gifOverlay = null; }, ), ), ]), ); }); Overlay.of(context).insert(_gifOverlay!); } void _toggleEmojiPicker() { if (_emojiOverlay != null) { _emojiOverlay!.remove(); _emojiOverlay = null; return; } final platform = Theme.of(context).platform; final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS; void onEmojiSelected(String emoji, BuildContext? ctx) { final pos = _ctrl.selection.baseOffset; final text = _ctrl.text; final before = pos < 0 ? text : text.substring(0, pos); final after = pos < 0 ? '' : text.substring(pos); _ctrl.value = TextEditingValue( text: '$before$emoji$after', selection: TextSelection.collapsed( offset: before.length + emoji.length, ), ); if (isMobile && ctx != null) { Navigator.of(ctx).pop(); } else { _emojiOverlay?.remove(); _emojiOverlay = null; } _focus.requestFocus(); } if (isMobile) { showModalBottomSheet( context: context, backgroundColor: Colors.transparent, isScrollControlled: true, builder: (ctx) => Padding( padding: EdgeInsets.only( bottom: MediaQuery.viewInsetsOf(ctx).bottom, top: 40, ), child: EmojiPicker( onEmojiSelected: (emoji) => onEmojiSelected(emoji, ctx), onClose: () => Navigator.of(ctx).pop(), ), ), ); return; } final box = context.findRenderObject() as RenderBox?; final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero; final size = box?.size ?? Size.zero; _emojiOverlay = OverlayEntry(builder: (ctx) { final screenSize = MediaQuery.sizeOf(ctx); const pickerW = 340.0; const pickerH = 380.0; final left = (offset.dx + size.width - pickerW).clamp(8.0, screenSize.width - pickerW - 8); final top = (offset.dy - pickerH - 8).clamp(8.0, screenSize.height - pickerH - 8); return Material( type: MaterialType.transparency, child: Stack(children: [ GestureDetector( onTap: () { _emojiOverlay?.remove(); _emojiOverlay = null; }, behavior: HitTestBehavior.translucent, child: const SizedBox.expand(), ), Positioned( left: left, top: top, child: EmojiPicker( onEmojiSelected: (emoji) => onEmojiSelected(emoji, null), onClose: () { _emojiOverlay?.remove(); _emojiOverlay = null; }, ), ), ]), ); }); Overlay.of(context).insert(_emojiOverlay!); } Future _pickAttachment() async { final result = await FilePicker.platform.pickFiles(allowMultiple: true); if (result == null || result.files.isEmpty) return; if (!mounted) return; final files = result.files .where((f) => f.path != null) .map((f) => File(f.path!)) .toList(); if (files.isEmpty) return; await AttachmentDialog.show(context, files, widget.room, replyTo: widget.replyTo); widget.onClearReply?.call(); } Future _send() async { final text = _ctrl.text.trim(); if (text.isEmpty) return; _ctrl.clear(); setState(() => _canSend = false); // Stop typing indicator immediately before sending. _typingTimer?.cancel(); if (_isTyping) _sendTyping(false); await widget.room.sendTextEvent(text, inReplyTo: widget.replyTo); widget.onClearReply?.call(); _focus.requestFocus(); } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); final size = MediaQuery.sizeOf(context); final isSmall = size.width < 400; final roomName = widget.room.getLocalizedDisplayname(); final placeholder = widget.room.isDirectChat ? 'Message…' : 'Message #$roomName'; return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ if (widget.replyTo != null) _ReplyBar( event: widget.replyTo!, onClear: widget.onClearReply ?? () {}, pt: pt, ), Padding( // Bottom inset matches the profile block in the rooms list (8px) so // the composer sits flush with it. padding: EdgeInsets.fromLTRB(isSmall ? 8 : 16, 0, isSmall ? 8 : 16, 8), child: AnimatedContainer( duration: const Duration(milliseconds: 180), curve: Curves.easeOutCubic, // Tuned so the single-line composer is flush with the profile // block in the rooms list (~89px on the user's display). Grows for // multiline. constraints: const BoxConstraints(minHeight: 53), decoration: BoxDecoration( color: pt.bg2, borderRadius: BorderRadius.circular(pt.rBase + 4), border: Border.all( color: _focus.hasFocus ? pt.accent : pt.border, ), boxShadow: _focus.hasFocus ? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)] : null, ), child: Focus( onFocusChange: (_) => setState(() {}), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ // Attach button _ComposerIconBtn( icon: Icons.add_rounded, tooltip: 'Datei anhängen', pt: pt, size: size.width < 600 ? 24 : 30, onTap: _pickAttachment, ), // Text input Expanded( child: TextField( controller: _ctrl, focusNode: _focus, minLines: 1, maxLines: 8, style: TextStyle(color: pt.fg, fontSize: 14), cursorColor: pt.accent, decoration: InputDecoration( hintText: placeholder, hintStyle: TextStyle(color: pt.fgDim, fontSize: 14), border: InputBorder.none, contentPadding: EdgeInsets.symmetric( horizontal: size.width < 600 ? 4 : 8, vertical: 10, ), isDense: true, ), inputFormatters: [ if (!(Platform.isAndroid || Platform.isIOS)) _SendOnEnterFormatter(onSend: _send), ], // Meldet der Android-Tastatur, dass dieses Feld Bilder // annimmt — dadurch bietet z.B. Gboard frisch kopierte // Bilder direkt in der Vorschlagsleiste an. contentInsertionConfiguration: Platform.isAndroid ? ContentInsertionConfiguration( allowedMimeTypes: const [ 'image/gif', 'image/png', 'image/jpeg', 'image/webp', ], onContentInserted: _handleInsertedContent, ) : null, ), ), // Inline tools Row( mainAxisSize: MainAxisSize.min, children: [ _ComposerIconBtn( icon: Icons.gif_box_outlined, tooltip: 'GIF', pt: pt, size: size.width < 600 ? 22 : 26, onTap: _toggleGifPicker, ), _ComposerIconBtn( icon: Icons.tag_faces_rounded, tooltip: 'Emoji', pt: pt, size: size.width < 600 ? 22 : 26, onTap: _toggleEmojiPicker, ), if (size.width > 600) _ComposerIconBtn( icon: Icons.alternate_email_rounded, tooltip: 'Mention', pt: pt, size: 26, ), ], ), // Send button — only on mobile if (Platform.isAndroid || Platform.isIOS) Padding( padding: const EdgeInsets.fromLTRB(4, 4, 8, 4), child: _SendButton( canSend: _canSend, pt: pt, onTap: _send, ), ), ], ), ), ), ), ], ), ); } } class _ComposerIconBtn extends StatefulWidget { final IconData icon; final String tooltip; final PyramidTheme pt; final double size; final VoidCallback? onTap; const _ComposerIconBtn({ required this.icon, required this.tooltip, required this.pt, required this.size, this.onTap, }); @override State<_ComposerIconBtn> createState() => _ComposerIconBtnState(); } class _ComposerIconBtnState extends State<_ComposerIconBtn> { bool _hovered = false; @override Widget build(BuildContext context) { final platform = Theme.of(context).platform; final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS; final touchSize = isMobile ? 44.0 : widget.size; return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTap, child: Tooltip( message: widget.tooltip, waitDuration: const Duration(milliseconds: 500), child: SizedBox( width: touchSize, height: touchSize, child: Center( child: AnimatedContainer( duration: const Duration(milliseconds: 150), width: widget.size, height: widget.size, decoration: BoxDecoration( color: _hovered ? widget.pt.bgHover : Colors.transparent, borderRadius: BorderRadius.circular(6), ), child: Center( child: Icon( widget.icon, size: widget.size * 0.55, color: _hovered ? widget.pt.fg : widget.pt.fgMuted, ), ), ), ), ), ), ), ); } } class _SendButton extends StatefulWidget { final bool canSend; final PyramidTheme pt; final VoidCallback onTap; const _SendButton({required this.canSend, required this.pt, required this.onTap}); @override State<_SendButton> createState() => _SendButtonState(); } class _SendButtonState extends State<_SendButton> { bool _hovered = false; @override Widget build(BuildContext context) { final pt = widget.pt; final active = widget.canSend; return MouseRegion( cursor: active ? SystemMouseCursors.click : MouseCursor.defer, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: active ? widget.onTap : null, child: AnimatedContainer( duration: const Duration(milliseconds: 180), curve: Curves.easeOutBack, width: 36, height: 36, decoration: BoxDecoration( color: active ? pt.accent : pt.bg3, borderRadius: BorderRadius.circular(pt.rSm), boxShadow: active && _hovered ? [BoxShadow(color: pt.accentGlow, blurRadius: 12, offset: const Offset(0, 4))] : null, ), transform: active ? (_hovered ? (Matrix4.diagonal3Values(1.1, 1.1, 1.0)..rotateZ(-0.26)) : Matrix4.diagonal3Values(1.05, 1.05, 1.0)) : Matrix4.identity(), transformAlignment: Alignment.center, child: Center( child: Icon( Icons.send_rounded, size: 16, color: active ? pt.accentFg : pt.fgDim, ), ), ), ), ); } } class _ReplyBar extends StatelessWidget { final Event event; final VoidCallback onClear; final PyramidTheme pt; const _ReplyBar({required this.event, required this.onClear, required this.pt}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.fromLTRB(16, 8, 8, 4), decoration: BoxDecoration( border: Border( top: BorderSide(color: pt.border), left: BorderSide(color: pt.accent, width: 3), ), ), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( event.senderFromMemoryOrFallback.calcDisplayname(), style: TextStyle( color: pt.accent, fontSize: 12, fontWeight: FontWeight.w600, ), ), Text( event.body, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: pt.fgMuted, fontSize: 12), ), ], ), ), GestureDetector( onTap: onClear, child: Icon(Icons.close_rounded, size: 16, color: pt.fgDim), ), ], ), ); } } class _SendOnEnterFormatter extends TextInputFormatter { final VoidCallback onSend; _SendOnEnterFormatter({required this.onSend}); @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { if (newValue.text.endsWith('\n') && !oldValue.text.endsWith('\n')) { // Check if shift is pressed (can't detect here, just send on enter) // For multiline we'd need keyboard shortcuts WidgetsBinding.instance.addPostFrameCallback((_) => onSend()); return oldValue; } return newValue; } }