💄 Optimize chat room, chat input

💫 More animations in chat input
This commit is contained in:
2025-10-13 00:05:22 +08:00
parent 8a2b321701
commit 51b4754182
3 changed files with 279 additions and 160 deletions

View File

@@ -639,6 +639,10 @@
"chatNotJoined": "You have not joined this chat yet.", "chatNotJoined": "You have not joined this chat yet.",
"chatUnableJoin": "You can't join this chat due to it's access control settings.", "chatUnableJoin": "You can't join this chat due to it's access control settings.",
"chatJoin": "Join the Chat", "chatJoin": "Join the Chat",
"chatReplyingTo": "Replying to {}",
"chatForwarding": "Forwarding message",
"chatEditing": "Editing message",
"chatNoContent": "No content",
"realmJoin": "Join the Realm", "realmJoin": "Join the Realm",
"realmJoinSuccess": "Successfully joined the realm.", "realmJoinSuccess": "Successfully joined the realm.",
"search": "Search", "search": "Search",

View File

@@ -535,7 +535,7 @@ class ChatRoomScreen extends HookConsumerWidget {
listController: listController, listController: listController,
padding: EdgeInsets.only( padding: EdgeInsets.only(
top: 16, top: 16,
bottom: 80 + MediaQuery.of(context).padding.bottom, bottom: MediaQuery.of(context).padding.bottom + 16,
), ),
controller: scrollController, controller: scrollController,
reverse: true, // Show newest messages at the bottom reverse: true, // Show newest messages at the bottom
@@ -687,91 +687,92 @@ class ChatRoomScreen extends HookConsumerWidget {
), ),
body: Stack( body: Stack(
children: [ children: [
// Messages // Messages and Input in Column
Positioned.fill( Positioned.fill(
child: messages.when( child: Column(
data: children: [
(messageList) => Expanded(
messageList.isEmpty child: messages.when(
? Center(child: Text('No messages yet'.tr())) data:
: chatMessageListWidget(messageList), (messageList) =>
loading: () => const Center(child: CircularProgressIndicator()), messageList.isEmpty
error: ? Center(child: Text('No messages yet'.tr()))
(error, _) => ResponseErrorWidget( : chatMessageListWidget(messageList),
error: error, loading:
onRetry: () => messagesNotifier.loadInitial(), () => const Center(child: CircularProgressIndicator()),
error:
(error, _) => ResponseErrorWidget(
error: error,
onRetry: () => messagesNotifier.loadInitial(),
),
), ),
), ),
), chatRoom.when(
// Input data:
Positioned( (room) => Column(
bottom: 0, mainAxisSize: MainAxisSize.min,
left: 0, children: [
right: 0, ChatInput(
child: chatRoom.when( messageController: messageController,
data: chatRoom: room!,
(room) => Column( onSend: sendMessage,
mainAxisSize: MainAxisSize.min, onClear: () {
children: [ if (messageEditingTo.value != null) {
ChatInput( attachments.value.clear();
messageController: messageController, messageController.clear();
chatRoom: room!, }
onSend: sendMessage, messageEditingTo.value = null;
onClear: () { messageReplyingTo.value = null;
if (messageEditingTo.value != null) { messageForwardingTo.value = null;
attachments.value.clear(); },
messageController.clear(); messageEditingTo: messageEditingTo.value,
} messageReplyingTo: messageReplyingTo.value,
messageEditingTo.value = null; messageForwardingTo: messageForwardingTo.value,
messageReplyingTo.value = null; onPickFile: (bool isPhoto) {
messageForwardingTo.value = null; if (isPhoto) {
}, pickPhotoMedia();
messageEditingTo: messageEditingTo.value, } else {
messageReplyingTo: messageReplyingTo.value, pickVideoMedia();
messageForwardingTo: messageForwardingTo.value, }
onPickFile: (bool isPhoto) { },
if (isPhoto) { onPickAudio: pickAudioMedia,
pickPhotoMedia(); onPickGeneralFile: pickGeneralFile,
} else { onLinkAttachment: linkAttachment,
pickVideoMedia(); attachments: attachments.value,
} onUploadAttachment: uploadAttachment,
}, onDeleteAttachment: (index) async {
onPickAudio: pickAudioMedia, final attachment = attachments.value[index];
onPickGeneralFile: pickGeneralFile, if (attachment.isOnCloud && !attachment.isLink) {
onLinkAttachment: linkAttachment, final client = ref.watch(apiClientProvider);
attachments: attachments.value, await client.delete(
onUploadAttachment: uploadAttachment, '/drive/files/${attachment.data.id}',
onDeleteAttachment: (index) async { );
final attachment = attachments.value[index]; }
if (attachment.isOnCloud && !attachment.isLink) { final clone = List.of(attachments.value);
final client = ref.watch(apiClientProvider); clone.removeAt(index);
await client.delete( attachments.value = clone;
'/drive/files/${attachment.data.id}', },
); onMoveAttachment: (idx, delta) {
} if (idx + delta < 0 ||
final clone = List.of(attachments.value); idx + delta >= attachments.value.length) {
clone.removeAt(index); return;
attachments.value = clone; }
}, final clone = List.of(attachments.value);
onMoveAttachment: (idx, delta) { clone.insert(idx + delta, clone.removeAt(idx));
if (idx + delta < 0 || attachments.value = clone;
idx + delta >= attachments.value.length) { },
return; onAttachmentsChanged: (newAttachments) {
} attachments.value = newAttachments;
final clone = List.of(attachments.value); },
clone.insert(idx + delta, clone.removeAt(idx)); attachmentProgress: attachmentProgress.value,
attachments.value = clone; ),
}, Gap(MediaQuery.of(context).padding.bottom),
onAttachmentsChanged: (newAttachments) { ],
attachments.value = newAttachments;
},
attachmentProgress: attachmentProgress.value,
), ),
Gap(MediaQuery.of(context).padding.bottom), error: (_, _) => const SizedBox.shrink(),
], loading: () => const SizedBox.shrink(),
), ),
error: (_, _) => const SizedBox.shrink(), ],
loading: () => const SizedBox.shrink(),
), ),
), ),
Positioned( Positioned(

View File

@@ -225,86 +225,200 @@ class ChatInput extends HookConsumerWidget {
key: ValueKey('typing-indicator-none'), key: ValueKey('typing-indicator-none'),
), ),
), ),
if (attachments.isNotEmpty) AnimatedSwitcher(
SizedBox( duration: const Duration(milliseconds: 250),
height: 180, switchInCurve: Curves.easeOutCubic,
child: ListView.separated( switchOutCurve: Curves.easeInCubic,
padding: EdgeInsets.symmetric(horizontal: 12), transitionBuilder: (Widget child, Animation<double> animation) {
scrollDirection: Axis.horizontal, return SlideTransition(
itemCount: attachments.length, position: Tween<Offset>(
itemBuilder: (context, idx) { begin: const Offset(0, 0.1),
return SizedBox( end: Offset.zero,
width: 180, ).animate(animation),
child: AttachmentPreview( child: FadeTransition(
isCompact: true, opacity: animation,
item: attachments[idx], child: SizeTransition(
progress: attachmentProgress['chat-upload']?[idx], sizeFactor: animation,
onRequestUpload: () => onUploadAttachment(idx), axisAlignment: -1.0,
onDelete: () => onDeleteAttachment(idx), child: child,
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 child:
? 'Replying to ${messageReplyingTo?.sender.account.nick}' attachments.isNotEmpty
: messageForwardingTo != null ? SizedBox(
? 'Forwarding message' key: ValueKey('attachments-${attachments.length}'),
: 'Editing message', height: 180,
style: Theme.of(context).textTheme.bodySmall, child: ListView.separated(
maxLines: 1, padding: EdgeInsets.symmetric(horizontal: 12),
overflow: TextOverflow.ellipsis, 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<double> animation) {
return SlideTransition(
position: Tween<Offset>(
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( child:
onTap: onClear, (messageReplyingTo != null ||
child: const Icon(Icons.close, size: 20).center(), 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( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [