From 51b47541825b8f2287594f9edb65e2a338c499da Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Mon, 13 Oct 2025 00:05:22 +0800 Subject: [PATCH] :lipstick: Optimize chat room, chat input :dizzy: More animations in chat input --- assets/i18n/en-US.json | 4 + lib/screens/chat/room.dart | 167 +++++++++---------- lib/widgets/chat/chat_input.dart | 268 ++++++++++++++++++++++--------- 3 files changed, 279 insertions(+), 160 deletions(-) diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 5d285879..29e499d5 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -639,6 +639,10 @@ "chatNotJoined": "You have not joined this chat yet.", "chatUnableJoin": "You can't join this chat due to it's access control settings.", "chatJoin": "Join the Chat", + "chatReplyingTo": "Replying to {}", + "chatForwarding": "Forwarding message", + "chatEditing": "Editing message", + "chatNoContent": "No content", "realmJoin": "Join the Realm", "realmJoinSuccess": "Successfully joined the realm.", "search": "Search", diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 2a6a70dd..7a96a305 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -535,7 +535,7 @@ class ChatRoomScreen extends HookConsumerWidget { listController: listController, padding: EdgeInsets.only( top: 16, - bottom: 80 + MediaQuery.of(context).padding.bottom, + bottom: MediaQuery.of(context).padding.bottom + 16, ), controller: scrollController, reverse: true, // Show newest messages at the bottom @@ -687,91 +687,92 @@ class ChatRoomScreen extends HookConsumerWidget { ), body: Stack( children: [ - // Messages + // Messages and Input in Column 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(), + child: 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(), + ), ), - ), - ), - // Input - Positioned( - bottom: 0, - left: 0, - right: 0, - child: chatRoom.when( - data: - (room) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - 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(); - } - }, - onPickAudio: pickAudioMedia, - onPickGeneralFile: pickGeneralFile, - onLinkAttachment: linkAttachment, - attachments: attachments.value, - onUploadAttachment: uploadAttachment, - onDeleteAttachment: (index) async { - final attachment = attachments.value[index]; - if (attachment.isOnCloud && !attachment.isLink) { - 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, + ), + chatRoom.when( + data: + (room) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + 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(); + } + }, + onPickAudio: pickAudioMedia, + onPickGeneralFile: pickGeneralFile, + onLinkAttachment: linkAttachment, + attachments: attachments.value, + onUploadAttachment: uploadAttachment, + onDeleteAttachment: (index) async { + final attachment = attachments.value[index]; + if (attachment.isOnCloud && !attachment.isLink) { + 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), + ], ), - Gap(MediaQuery.of(context).padding.bottom), - ], - ), - error: (_, _) => const SizedBox.shrink(), - loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + loading: () => const SizedBox.shrink(), + ), + ], ), ), Positioned( diff --git a/lib/widgets/chat/chat_input.dart b/lib/widgets/chat/chat_input.dart index 9f40add7..297dbf48 100644 --- a/lib/widgets/chat/chat_input.dart +++ b/lib/widgets/chat/chat_input.dart @@ -225,86 +225,200 @@ class ChatInput extends HookConsumerWidget { 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, + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (Widget child, Animation animation) { + return SlideTransition( + position: Tween( + begin: const Offset(0, 0.1), + end: Offset.zero, + ).animate(animation), + child: FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axisAlignment: -1.0, + child: child, ), - 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: + attachments.isNotEmpty + ? SizedBox( + key: ValueKey('attachments-${attachments.length}'), + 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) + : const SizedBox.shrink( + key: ValueKey('no-attachments'), ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (Widget child, Animation animation) { + return SlideTransition( + position: Tween( + begin: const Offset(0, -0.2), + end: Offset.zero, + ).animate(animation), + child: FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axisAlignment: -1.0, + child: child, ), - SizedBox( - width: 28, - height: 28, - child: InkWell( - onTap: onClear, - child: const Icon(Icons.close, size: 20).center(), - ), - ), - ], - ), - ), + ), + ); + }, + child: + (messageReplyingTo != null || + messageForwardingTo != null || + messageEditingTo != null) + ? Container( + key: ValueKey( + messageReplyingTo?.id ?? + messageForwardingTo?.id ?? + messageEditingTo?.id ?? + 'action', + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: + Theme.of( + context, + ).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outline.withOpacity(0.2), + width: 1, + ), + ), + margin: const EdgeInsets.only( + left: 8, + right: 8, + top: 8, + bottom: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + messageReplyingTo != null + ? Symbols.reply + : messageForwardingTo != null + ? Symbols.forward + : Symbols.edit, + size: 18, + color: + Theme.of(context).colorScheme.primary, + ), + const Gap(8), + Expanded( + child: Text( + messageReplyingTo != null + ? 'chatReplyingTo'.tr( + args: [ + messageReplyingTo + ?.sender + .account + .nick ?? + 'unknown'.tr(), + ], + ) + : messageForwardingTo != null + ? 'chatForwarding'.tr() + : 'chatEditing'.tr(), + style: Theme.of( + context, + ).textTheme.bodySmall!.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + SizedBox( + width: 24, + height: 24, + child: IconButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.close, size: 18), + onPressed: onClear, + tooltip: 'clear'.tr(), + ), + ), + ], + ), + if (messageReplyingTo != null || + messageForwardingTo != null || + messageEditingTo != null) + Padding( + padding: const EdgeInsets.only( + top: 6, + left: 26, + ), + child: Text( + (messageReplyingTo ?? + messageForwardingTo ?? + messageEditingTo) + ?.content ?? + 'chatNoContent'.tr(), + style: Theme.of( + context, + ).textTheme.bodySmall!.copyWith( + color: + Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ) + : const SizedBox.shrink(key: ValueKey('no-action')), + ), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [