From 1fbaac8d886fcb8a1a5790c11957928e4a78903d Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 27 Sep 2025 15:31:57 +0800 Subject: [PATCH] :lipstick: Optimize chat input a step further --- lib/screens/chat/room.dart | 304 ++++++++++---------- lib/widgets/chat/chat_input.dart | 382 +++++++++++++------------- lib/widgets/chat/message_content.dart | 50 ++-- 3 files changed, 379 insertions(+), 357 deletions(-) diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 395bb30d..0334de42 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -541,7 +541,10 @@ class ChatRoomScreen extends HookConsumerWidget { Widget chatMessageListWidget(List messageList) => SuperListView.builder( listController: listController, - padding: EdgeInsets.symmetric(vertical: 16), + padding: EdgeInsets.only( + top: 16, + bottom: 96 + MediaQuery.of(context).padding.bottom, + ), controller: scrollController, reverse: true, // Show newest messages at the bottom itemCount: messageList.length, @@ -735,157 +738,160 @@ class ChatRoomScreen extends HookConsumerWidget { ), body: Stack( children: [ - Column( - children: [ - Expanded( - child: messages.when( - data: - (messageList) => - messageList.isEmpty - ? Center(child: Text('No messages yet'.tr())) - : chatMessageListWidget(messageList), - loading: - () => const Center(child: CircularProgressIndicator()), - error: - (error, _) => ResponseErrorWidget( - error: error, - onRetry: () => messagesNotifier.loadInitial(), - ), - ), - ), - chatRoom.when( - data: - (room) => Column( - mainAxisSize: MainAxisSize.min, - 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, - ), + // Messages + Positioned.fill( + child: messages.when( + data: + (messageList) => + messageList.isEmpty + ? Center(child: Text('No messages yet'.tr())) + : chatMessageListWidget(messageList), + loading: () => const Center(child: CircularProgressIndicator()), + error: + (error, _) => ResponseErrorWidget( + error: error, + onRetry: () => messagesNotifier.loadInitial(), + ), + ), + ), + // Input + Positioned( + bottom: 0, + left: 0, + right: 0, + child: chatRoom.when( + data: + (room) => Column( + mainAxisSize: MainAxisSize.min, + 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: SizeTransition( + sizeFactor: animation, + axisAlignment: -1.0, + child: FadeTransition( + opacity: animation, + child: child, ), - ); - }, - child: - typingStatuses.value.isNotEmpty - ? Container( - key: const ValueKey('typing-indicator'), - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 4, - ), - child: Row( - children: [ - const Icon( - Symbols.more_horiz, - size: 16, - ).padding(horizontal: 8), - const Gap(8), - Expanded( - child: Text( - 'typingHint'.plural( - typingStatuses.value.length, - args: [ - typingStatuses.value - .map( - (x) => - x.nick ?? - x.account.nick, - ) - .join(', '), - ], - ), - style: - Theme.of( - context, - ).textTheme.bodySmall, - ), - ), - ], - ), - ) - : const SizedBox.shrink( - key: ValueKey('typing-indicator-none'), + ), + ); + }, + child: + typingStatuses.value.isNotEmpty + ? Container( + key: const ValueKey('typing-indicator'), + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, ), - ), - ChatInput( - messageController: messageController, - chatRoom: room!, - onSend: sendMessage, - onClear: () { - if (messageEditingTo.value != null) { - attachments.value.clear(); - messageController.clear(); - } - messageEditingTo.value = null; - messageReplyingTo.value = null; - messageForwardingTo.value = null; - }, - messageEditingTo: messageEditingTo.value, - messageReplyingTo: messageReplyingTo.value, - messageForwardingTo: messageForwardingTo.value, - onPickFile: (bool isPhoto) { - if (isPhoto) { - pickPhotoMedia(); - } else { - pickVideoMedia(); - } - }, - attachments: attachments.value, - onUploadAttachment: uploadAttachment, - onDeleteAttachment: (index) async { - final attachment = attachments.value[index]; - if (attachment.isOnCloud) { - final client = ref.watch(apiClientProvider); - await client.delete( - '/drive/files/${attachment.data.id}', - ); - } - final clone = List.of(attachments.value); - clone.removeAt(index); - attachments.value = clone; - }, - onMoveAttachment: (idx, delta) { - if (idx + delta < 0 || - idx + delta >= attachments.value.length) { - return; - } - final clone = List.of(attachments.value); - clone.insert(idx + delta, clone.removeAt(idx)); - attachments.value = clone; - }, - onAttachmentsChanged: (newAttachments) { - attachments.value = newAttachments; - }, - attachmentProgress: attachmentProgress.value, - ), - ], - ), - error: (_, _) => const SizedBox.shrink(), - loading: () => const SizedBox.shrink(), - ), - ], + child: Row( + children: [ + const Icon( + Symbols.more_horiz, + size: 16, + ).padding(horizontal: 8), + const Gap(8), + Expanded( + child: Text( + 'typingHint'.plural( + typingStatuses.value.length, + args: [ + typingStatuses.value + .map( + (x) => + x.nick ?? + x.account.nick, + ) + .join(', '), + ], + ), + style: + Theme.of( + context, + ).textTheme.bodySmall, + ), + ), + ], + ), + ) + : const SizedBox.shrink( + key: ValueKey('typing-indicator-none'), + ), + ), + ChatInput( + messageController: messageController, + chatRoom: room!, + onSend: sendMessage, + onClear: () { + if (messageEditingTo.value != null) { + attachments.value.clear(); + messageController.clear(); + } + messageEditingTo.value = null; + messageReplyingTo.value = null; + messageForwardingTo.value = null; + }, + messageEditingTo: messageEditingTo.value, + messageReplyingTo: messageReplyingTo.value, + messageForwardingTo: messageForwardingTo.value, + onPickFile: (bool isPhoto) { + if (isPhoto) { + pickPhotoMedia(); + } else { + pickVideoMedia(); + } + }, + attachments: attachments.value, + onUploadAttachment: uploadAttachment, + onDeleteAttachment: (index) async { + final attachment = attachments.value[index]; + if (attachment.isOnCloud) { + final client = ref.watch(apiClientProvider); + await client.delete( + '/drive/files/${attachment.data.id}', + ); + } + final clone = List.of(attachments.value); + clone.removeAt(index); + attachments.value = clone; + }, + onMoveAttachment: (idx, delta) { + if (idx + delta < 0 || + idx + delta >= attachments.value.length) { + return; + } + final clone = List.of(attachments.value); + clone.insert(idx + delta, clone.removeAt(idx)); + attachments.value = clone; + }, + onAttachmentsChanged: (newAttachments) { + attachments.value = newAttachments; + }, + attachmentProgress: attachmentProgress.value, + ), + Gap(MediaQuery.of(context).padding.bottom), + ], + ), + error: (_, _) => const SizedBox.shrink(), + loading: () => const SizedBox.shrink(), + ), ), Positioned( left: 0, diff --git a/lib/widgets/chat/chat_input.dart b/lib/widgets/chat/chat_input.dart index c72593f5..ccfc67c2 100644 --- a/lib/widgets/chat/chat_input.dart +++ b/lib/widgets/chat/chat_input.dart @@ -115,200 +115,212 @@ class ChatInput extends HookConsumerWidget { return KeyEventResult.ignored; }; - return Material( - elevation: 8, - color: Theme.of(context).colorScheme.surface, - child: Column( - children: [ - if (attachments.isNotEmpty) - SizedBox( - height: 280, - child: ListView.separated( - padding: EdgeInsets.symmetric(horizontal: 12), - scrollDirection: Axis.horizontal, - itemCount: attachments.length, - itemBuilder: (context, idx) { - return SizedBox( - height: 280, - width: 280, - child: AttachmentPreview( - 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(top: 12), - if (messageReplyingTo != null || - messageForwardingTo != null || - messageEditingTo != null) - Container( - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(8), - ), - margin: const EdgeInsets.only(left: 8, right: 8, top: 8), - child: Row( - children: [ - Icon( - messageReplyingTo != null - ? Symbols.reply - : messageForwardingTo != null - ? Symbols.forward - : Symbols.edit, - size: 20, - color: Theme.of(context).colorScheme.primary, + return Container( + margin: const EdgeInsets.all(16), + 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: [ + if (attachments.isNotEmpty) + SizedBox( + height: 280, + child: ListView.separated( + padding: EdgeInsets.symmetric(horizontal: 12), + scrollDirection: Axis.horizontal, + itemCount: attachments.length, + itemBuilder: (context, idx) { + return SizedBox( + height: 280, + width: 280, + child: AttachmentPreview( + 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(top: 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( + 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, + ), + ); + }, + ); + }, + ), + PopupMenuButton( + icon: const Icon(Symbols.photo_library), + itemBuilder: + (context) => [ + PopupMenuItem( + onTap: () => onPickFile(true), + child: Row( + spacing: 12, + children: [ + const Icon(Symbols.photo), + Text('addPhoto').tr(), + ], + ), + ), + PopupMenuItem( + onTap: () => onPickFile(false), + child: Row( + spacing: 12, + children: [ + const Icon(Symbols.video_call), + Text('addVideo').tr(), + ], + ), + ), + ], + ), + ], ), - 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, + child: TextField( + focusNode: inputFocusNode, + controller: messageController, + keyboardType: TextInputType.multiline, + decoration: InputDecoration( + 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: 4, + ), + counterText: + messageController.text.length > 1024 + ? '${messageController.text.length}/4096' + : null, + ), + maxLines: 3, + minLines: 1, + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), ), ), IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: onClear, - padding: EdgeInsets.zero, - style: ButtonStyle( - minimumSize: WidgetStatePropertyAll(Size(28, 28)), - ), + icon: const Icon(Icons.send), + color: Theme.of(context).colorScheme.primary, + onPressed: send, ), ], ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), - child: Row( - 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, - ), - ); - }, - ); - }, - ), - PopupMenuButton( - icon: const Icon(Symbols.photo_library), - itemBuilder: - (context) => [ - PopupMenuItem( - onTap: () => onPickFile(true), - child: Row( - spacing: 12, - children: [ - const Icon(Symbols.photo), - Text('addPhoto').tr(), - ], - ), - ), - PopupMenuItem( - onTap: () => onPickFile(false), - child: Row( - spacing: 12, - children: [ - const Icon(Symbols.video_call), - Text('addVideo').tr(), - ], - ), - ), - ], - ), - ], - ), - Expanded( - child: TextField( - focusNode: inputFocusNode, - controller: messageController, - keyboardType: TextInputType.multiline, - decoration: InputDecoration( - 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: 4, - ), - 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, - ), - ], - ).padding(bottom: MediaQuery.of(context).padding.bottom), + ], ), - ], + ), ), ); } diff --git a/lib/widgets/chat/message_content.dart b/lib/widgets/chat/message_content.dart index 2ad6e2fc..36c99690 100644 --- a/lib/widgets/chat/message_content.dart +++ b/lib/widgets/chat/message_content.dart @@ -56,7 +56,7 @@ class MessageContent extends StatelessWidget { case 'messages.update.links': return Row( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( Symbols.edit, @@ -64,27 +64,29 @@ class MessageContent extends StatelessWidget { color: Theme.of( context, ).colorScheme.onSurfaceVariant.withOpacity(0.6), - ), + ).padding(top: 2), const Gap(4), if (item.meta['previous_content'] is String) - PrettyDiffText( - oldText: item.meta['previous_content'], - newText: item.content ?? 'Edited a message', - defaultTextStyle: Theme.of( - context, - ).textTheme.bodyMedium!.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - addedTextStyle: TextStyle( - backgroundColor: Theme.of( + Flexible( + child: PrettyDiffText( + oldText: item.meta['previous_content'], + newText: item.content ?? 'Edited a message', + defaultTextStyle: Theme.of( context, - ).colorScheme.primaryFixedDim.withOpacity(0.4), - ), - deletedTextStyle: TextStyle( - decoration: TextDecoration.lineThrough, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant.withOpacity(0.7), + ).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + addedTextStyle: TextStyle( + backgroundColor: Theme.of( + context, + ).colorScheme.primaryFixedDim.withOpacity(0.4), + ), + deletedTextStyle: TextStyle( + decoration: TextDecoration.lineThrough, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withOpacity(0.7), + ), ), ) else @@ -104,10 +106,12 @@ class MessageContent extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - MarkdownTextContent( - content: item.content ?? '*${item.type} has no content*', - isSelectable: true, - linesMargin: EdgeInsets.zero, + Flexible( + child: MarkdownTextContent( + content: item.content ?? '*${item.type} has no content*', + isSelectable: true, + linesMargin: EdgeInsets.zero, + ), ), if (translatedText?.isNotEmpty ?? false) ...([