import "dart:async"; import "package:easy_localization/easy_localization.dart"; import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:gap/gap.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:image_picker/image_picker.dart"; import "package:island/models/chat.dart"; import "package:island/models/file.dart"; import "package:island/pods/config.dart"; import "package:island/services/responsive.dart"; import "package:island/widgets/content/attachment_preview.dart"; import "package:island/widgets/shared/upload_menu.dart"; import "package:material_symbols_icons/material_symbols_icons.dart"; import "package:pasteboard/pasteboard.dart"; import "package:styled_widget/styled_widget.dart"; import "package:material_symbols_icons/symbols.dart"; import "package:island/widgets/stickers/picker.dart"; import "package:island/pods/chat/chat_subscribe.dart"; class ChatInput extends HookConsumerWidget { final TextEditingController messageController; final SnChatRoom chatRoom; final VoidCallback onSend; final VoidCallback onClear; final Function(bool isPhoto) onPickFile; final VoidCallback onPickAudio; final VoidCallback onPickGeneralFile; final VoidCallback? onLinkAttachment; final SnChatMessage? messageReplyingTo; final SnChatMessage? messageForwardingTo; final SnChatMessage? messageEditingTo; final List attachments; final Function(int) onUploadAttachment; final Function(int) onDeleteAttachment; final Function(int, int) onMoveAttachment; final Function(List) onAttachmentsChanged; final Map> attachmentProgress; const ChatInput({ super.key, required this.messageController, required this.chatRoom, required this.onSend, required this.onClear, required this.onPickFile, required this.onPickAudio, required this.onPickGeneralFile, this.onLinkAttachment, required this.messageReplyingTo, required this.messageForwardingTo, required this.messageEditingTo, required this.attachments, required this.onUploadAttachment, required this.onDeleteAttachment, required this.onMoveAttachment, required this.onAttachmentsChanged, required this.attachmentProgress, }); @override Widget build(BuildContext context, WidgetRef ref) { final inputFocusNode = useFocusNode(); final chatSubscribe = ref.watch(chatSubscribeNotifierProvider(chatRoom.id)); void send() { onSend.call(); WidgetsBinding.instance.addPostFrameCallback((_) { inputFocusNode.requestFocus(); }); } void insertNewLine() { final text = messageController.text; final selection = messageController.selection; final start = selection.start >= 0 ? selection.start : text.length; final end = selection.end >= 0 ? selection.end : text.length; final newText = text.replaceRange(start, end, '\n'); messageController.value = TextEditingValue( text: newText, selection: TextSelection.collapsed(offset: start + 1), ); } Future handlePaste() async { final image = await Pasteboard.image; if (image != null) { onAttachmentsChanged([ ...attachments, UniversalFile( data: XFile.fromData(image, mimeType: "image/jpeg"), type: UniversalFileType.image, ), ]); return; } final textData = await Clipboard.getData(Clipboard.kTextPlain); if (textData != null && textData.text != null) { final text = messageController.text; final selection = messageController.selection; final start = selection.start >= 0 ? selection.start : text.length; final end = selection.end >= 0 ? selection.end : text.length; final newText = text.replaceRange(start, end, textData.text!); messageController.value = TextEditingValue( text: newText, selection: TextSelection.collapsed( offset: start + textData.text!.length, ), ); } } inputFocusNode.onKeyEvent = (node, event) { if (event is! KeyDownEvent) return KeyEventResult.ignored; final isPaste = event.logicalKey == LogicalKeyboardKey.keyV; final isModifierPressed = HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed; if (isPaste && isModifierPressed) { handlePaste(); return KeyEventResult.handled; } final enterToSend = ref.read(appSettingsNotifierProvider).enterToSend; final isEnter = event.logicalKey == LogicalKeyboardKey.enter; if (isEnter) { if (isModifierPressed) { insertNewLine(); return KeyEventResult.handled; } else if (enterToSend) { send(); return KeyEventResult.handled; } } return KeyEventResult.ignored; }; final double leftMargin = isWideScreen(context) ? 8 : 16; final double rightMargin = isWideScreen(context) ? leftMargin + 8 : 16; const double bottomMargin = 16; return Container( margin: EdgeInsets.only( left: leftMargin, right: rightMargin, bottom: bottomMargin, ), child: Material( elevation: 2, color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(32), child: Padding( padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), child: Column( children: [ AnimatedSwitcher( duration: const Duration(milliseconds: 150), switchInCurve: Curves.fastEaseInToSlowEaseOut, switchOutCurve: Curves.fastEaseInToSlowEaseOut, transitionBuilder: (Widget child, Animation animation) { return SlideTransition( position: Tween( begin: const Offset(0, -0.3), end: Offset.zero, ).animate( CurvedAnimation( parent: animation, curve: Curves.easeOutCubic, ), ), child: SizeTransition( sizeFactor: animation, axisAlignment: -1.0, child: FadeTransition(opacity: animation, child: child), ), ); }, child: chatSubscribe.isNotEmpty ? Container( key: const ValueKey('typing-indicator'), width: double.infinity, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 4, ), child: Row( children: [ const Icon( Symbols.more_horiz, size: 16, ).padding(horizontal: 8), const Gap(8), Expanded( child: Text( 'typingHint'.plural( chatSubscribe.length, args: [ chatSubscribe .map((x) => x.nick ?? x.account.nick) .join(', '), ], ), style: Theme.of(context).textTheme.bodySmall, ), ), ], ), ) : const SizedBox.shrink( key: ValueKey('typing-indicator-none'), ), ), if (attachments.isNotEmpty) SizedBox( height: 180, child: ListView.separated( padding: EdgeInsets.symmetric(horizontal: 12), scrollDirection: Axis.horizontal, itemCount: attachments.length, itemBuilder: (context, idx) { return SizedBox( width: 180, child: AttachmentPreview( isCompact: true, item: attachments[idx], progress: attachmentProgress['chat-upload']?[idx], onRequestUpload: () => onUploadAttachment(idx), onDelete: () => onDeleteAttachment(idx), onUpdate: (value) { attachments[idx] = value; onAttachmentsChanged(attachments); }, onMove: (delta) => onMoveAttachment(idx, delta), ), ); }, separatorBuilder: (_, _) => const Gap(8), ), ).padding(vertical: 12), if (messageReplyingTo != null || messageForwardingTo != null || messageEditingTo != null) Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 4, ), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(32), ), margin: const EdgeInsets.only( left: 8, right: 8, top: 8, bottom: 4, ), child: Row( children: [ Icon( messageReplyingTo != null ? Symbols.reply : messageForwardingTo != null ? Symbols.forward : Symbols.edit, size: 20, color: Theme.of(context).colorScheme.primary, ), const Gap(8), Expanded( child: Text( messageReplyingTo != null ? 'Replying to ${messageReplyingTo?.sender.account.nick}' : messageForwardingTo != null ? 'Forwarding message' : 'Editing message', style: Theme.of(context).textTheme.bodySmall, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), SizedBox( width: 28, height: 28, child: InkWell( onTap: onClear, child: const Icon(Icons.close, size: 20).center(), ), ), ], ), ), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: 'stickers'.tr(), icon: const Icon(Symbols.add_reaction), onPressed: () { final size = MediaQuery.of(context).size; showStickerPickerPopover( context, Offset( 20, size.height - 480 - MediaQuery.of(context).padding.bottom, ), onPick: (placeholder) { // Insert placeholder at current cursor position final text = messageController.text; final selection = messageController.selection; final start = selection.start >= 0 ? selection.start : text.length; final end = selection.end >= 0 ? selection.end : text.length; final newText = text.replaceRange( start, end, placeholder, ); messageController.value = TextEditingValue( text: newText, selection: TextSelection.collapsed( offset: start + placeholder.length, ), ); }, ); }, ), UploadMenu( items: [ MenuItemData( Symbols.add_a_photo, 'addPhoto', () => onPickFile(true), ), MenuItemData( Symbols.videocam, 'addVideo', () => onPickFile(false), ), MenuItemData(Symbols.mic, 'addAudio', onPickAudio), MenuItemData( Symbols.file_upload, 'uploadFile', onPickGeneralFile, ), if (onLinkAttachment != null) MenuItemData( Symbols.attach_file, 'linkAttachment', onLinkAttachment!, ), ], iconColor: Colors.white, ), ], ), Expanded( child: TextField( focusNode: inputFocusNode, controller: messageController, keyboardType: TextInputType.multiline, decoration: InputDecoration( hintMaxLines: 1, hintText: (chatRoom.type == 1 && chatRoom.name == null) ? 'chatDirectMessageHint'.tr( args: [ chatRoom.members! .map((e) => e.account.nick) .join(', '), ], ) : 'chatMessageHint'.tr(args: [chatRoom.name!]), border: InputBorder.none, isDense: true, contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 12, ), counterText: messageController.text.length > 1024 ? '${messageController.text.length}/4096' : null, ), maxLines: 3, minLines: 1, onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ), ), IconButton( icon: const Icon(Icons.send), color: Theme.of(context).colorScheme.primary, onPressed: send, ), ], ), ], ), ), ), ); } }