💄 Optimize chat input a step further

This commit is contained in:
2025-09-27 15:31:57 +08:00
parent b9dc724f0b
commit 1fbaac8d88
3 changed files with 379 additions and 357 deletions

View File

@@ -541,7 +541,10 @@ class ChatRoomScreen extends HookConsumerWidget {
Widget chatMessageListWidget(List<LocalChatMessage> messageList) => Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
SuperListView.builder( SuperListView.builder(
listController: listController, listController: listController,
padding: EdgeInsets.symmetric(vertical: 16), padding: EdgeInsets.only(
top: 16,
bottom: 96 + MediaQuery.of(context).padding.bottom,
),
controller: scrollController, controller: scrollController,
reverse: true, // Show newest messages at the bottom reverse: true, // Show newest messages at the bottom
itemCount: messageList.length, itemCount: messageList.length,
@@ -735,157 +738,160 @@ class ChatRoomScreen extends HookConsumerWidget {
), ),
body: Stack( body: Stack(
children: [ children: [
Column( // Messages
children: [ Positioned.fill(
Expanded( child: messages.when(
child: messages.when( data:
data: (messageList) =>
(messageList) => messageList.isEmpty
messageList.isEmpty ? Center(child: Text('No messages yet'.tr()))
? Center(child: Text('No messages yet'.tr())) : chatMessageListWidget(messageList),
: chatMessageListWidget(messageList), loading: () => const Center(child: CircularProgressIndicator()),
loading: error:
() => const Center(child: CircularProgressIndicator()), (error, _) => ResponseErrorWidget(
error: error: error,
(error, _) => ResponseErrorWidget( onRetry: () => messagesNotifier.loadInitial(),
error: error, ),
onRetry: () => messagesNotifier.loadInitial(), ),
), ),
), // Input
), Positioned(
chatRoom.when( bottom: 0,
data: left: 0,
(room) => Column( right: 0,
mainAxisSize: MainAxisSize.min, child: chatRoom.when(
children: [ data:
AnimatedSwitcher( (room) => Column(
duration: const Duration(milliseconds: 150), mainAxisSize: MainAxisSize.min,
switchInCurve: Curves.fastEaseInToSlowEaseOut, children: [
switchOutCurve: Curves.fastEaseInToSlowEaseOut, AnimatedSwitcher(
transitionBuilder: ( duration: const Duration(milliseconds: 150),
Widget child, switchInCurve: Curves.fastEaseInToSlowEaseOut,
Animation<double> animation, switchOutCurve: Curves.fastEaseInToSlowEaseOut,
) { transitionBuilder: (
return SlideTransition( Widget child,
position: Tween<Offset>( Animation<double> animation,
begin: const Offset(0, -0.3), ) {
end: Offset.zero, return SlideTransition(
).animate( position: Tween<Offset>(
CurvedAnimation( begin: const Offset(0, -0.3),
parent: animation, end: Offset.zero,
curve: Curves.easeOutCubic, ).animate(
), CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
), ),
child: SizeTransition( ),
sizeFactor: animation, child: SizeTransition(
axisAlignment: -1.0, sizeFactor: animation,
child: FadeTransition( axisAlignment: -1.0,
opacity: animation, child: FadeTransition(
child: child, opacity: animation,
), child: child,
), ),
); ),
}, );
child: },
typingStatuses.value.isNotEmpty child:
? Container( typingStatuses.value.isNotEmpty
key: const ValueKey('typing-indicator'), ? Container(
width: double.infinity, key: const ValueKey('typing-indicator'),
padding: const EdgeInsets.symmetric( width: double.infinity,
horizontal: 16, padding: const EdgeInsets.symmetric(
vertical: 4, 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: Row(
ChatInput( children: [
messageController: messageController, const Icon(
chatRoom: room!, Symbols.more_horiz,
onSend: sendMessage, size: 16,
onClear: () { ).padding(horizontal: 8),
if (messageEditingTo.value != null) { const Gap(8),
attachments.value.clear(); Expanded(
messageController.clear(); child: Text(
} 'typingHint'.plural(
messageEditingTo.value = null; typingStatuses.value.length,
messageReplyingTo.value = null; args: [
messageForwardingTo.value = null; typingStatuses.value
}, .map(
messageEditingTo: messageEditingTo.value, (x) =>
messageReplyingTo: messageReplyingTo.value, x.nick ??
messageForwardingTo: messageForwardingTo.value, x.account.nick,
onPickFile: (bool isPhoto) { )
if (isPhoto) { .join(', '),
pickPhotoMedia(); ],
} else { ),
pickVideoMedia(); style:
} Theme.of(
}, context,
attachments: attachments.value, ).textTheme.bodySmall,
onUploadAttachment: uploadAttachment, ),
onDeleteAttachment: (index) async { ),
final attachment = attachments.value[index]; ],
if (attachment.isOnCloud) { ),
final client = ref.watch(apiClientProvider); )
await client.delete( : const SizedBox.shrink(
'/drive/files/${attachment.data.id}', key: ValueKey('typing-indicator-none'),
); ),
} ),
final clone = List.of(attachments.value); ChatInput(
clone.removeAt(index); messageController: messageController,
attachments.value = clone; chatRoom: room!,
}, onSend: sendMessage,
onMoveAttachment: (idx, delta) { onClear: () {
if (idx + delta < 0 || if (messageEditingTo.value != null) {
idx + delta >= attachments.value.length) { attachments.value.clear();
return; messageController.clear();
} }
final clone = List.of(attachments.value); messageEditingTo.value = null;
clone.insert(idx + delta, clone.removeAt(idx)); messageReplyingTo.value = null;
attachments.value = clone; messageForwardingTo.value = null;
}, },
onAttachmentsChanged: (newAttachments) { messageEditingTo: messageEditingTo.value,
attachments.value = newAttachments; messageReplyingTo: messageReplyingTo.value,
}, messageForwardingTo: messageForwardingTo.value,
attachmentProgress: attachmentProgress.value, onPickFile: (bool isPhoto) {
), if (isPhoto) {
], pickPhotoMedia();
), } else {
error: (_, _) => const SizedBox.shrink(), pickVideoMedia();
loading: () => const SizedBox.shrink(), }
), },
], 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( Positioned(
left: 0, left: 0,

View File

@@ -115,200 +115,212 @@ class ChatInput extends HookConsumerWidget {
return KeyEventResult.ignored; return KeyEventResult.ignored;
}; };
return Material( return Container(
elevation: 8, margin: const EdgeInsets.all(16),
color: Theme.of(context).colorScheme.surface, child: Material(
child: Column( elevation: 2,
children: [ color: Theme.of(context).colorScheme.surfaceContainerHighest,
if (attachments.isNotEmpty) borderRadius: BorderRadius.circular(32),
SizedBox( child: Padding(
height: 280, padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
child: ListView.separated( child: Column(
padding: EdgeInsets.symmetric(horizontal: 12), children: [
scrollDirection: Axis.horizontal, if (attachments.isNotEmpty)
itemCount: attachments.length, SizedBox(
itemBuilder: (context, idx) { height: 280,
return SizedBox( child: ListView.separated(
height: 280, padding: EdgeInsets.symmetric(horizontal: 12),
width: 280, scrollDirection: Axis.horizontal,
child: AttachmentPreview( itemCount: attachments.length,
item: attachments[idx], itemBuilder: (context, idx) {
progress: attachmentProgress['chat-upload']?[idx], return SizedBox(
onRequestUpload: () => onUploadAttachment(idx), height: 280,
onDelete: () => onDeleteAttachment(idx), width: 280,
onUpdate: (value) { child: AttachmentPreview(
attachments[idx] = value; item: attachments[idx],
onAttachmentsChanged(attachments); progress: attachmentProgress['chat-upload']?[idx],
}, onRequestUpload: () => onUploadAttachment(idx),
onMove: (delta) => onMoveAttachment(idx, delta), onDelete: () => onDeleteAttachment(idx),
), onUpdate: (value) {
); attachments[idx] = value;
}, onAttachmentsChanged(attachments);
separatorBuilder: (_, _) => const Gap(8), },
), onMove: (delta) => onMoveAttachment(idx, delta),
).padding(top: 12), ),
if (messageReplyingTo != null || );
messageForwardingTo != null || },
messageEditingTo != null) separatorBuilder: (_, _) => const Gap(8),
Container( ),
padding: const EdgeInsets.symmetric(horizontal: 16), ).padding(top: 12),
decoration: BoxDecoration( if (messageReplyingTo != null ||
color: Theme.of(context).colorScheme.surfaceContainer, messageForwardingTo != null ||
borderRadius: BorderRadius.circular(8), messageEditingTo != null)
), Container(
margin: const EdgeInsets.only(left: 8, right: 8, top: 8), padding: const EdgeInsets.symmetric(
child: Row( horizontal: 16,
children: [ vertical: 4,
Icon( ),
messageReplyingTo != null decoration: BoxDecoration(
? Symbols.reply color: Theme.of(context).colorScheme.surfaceContainerHigh,
: messageForwardingTo != null borderRadius: BorderRadius.circular(32),
? Symbols.forward ),
: Symbols.edit, margin: const EdgeInsets.only(
size: 20, left: 8,
color: Theme.of(context).colorScheme.primary, 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( Expanded(
child: Text( child: TextField(
messageReplyingTo != null focusNode: inputFocusNode,
? 'Replying to ${messageReplyingTo?.sender.account.nick}' controller: messageController,
: messageForwardingTo != null keyboardType: TextInputType.multiline,
? 'Forwarding message' decoration: InputDecoration(
: 'Editing message', hintText:
style: Theme.of(context).textTheme.bodySmall, (chatRoom.type == 1 && chatRoom.name == null)
maxLines: 1, ? 'chatDirectMessageHint'.tr(
overflow: TextOverflow.ellipsis, 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( IconButton(
icon: const Icon(Icons.close, size: 20), icon: const Icon(Icons.send),
onPressed: onClear, color: Theme.of(context).colorScheme.primary,
padding: EdgeInsets.zero, onPressed: send,
style: ButtonStyle(
minimumSize: WidgetStatePropertyAll(Size(28, 28)),
),
), ),
], ],
), ),
), ],
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),
), ),
], ),
), ),
); );
} }

View File

@@ -56,7 +56,7 @@ class MessageContent extends StatelessWidget {
case 'messages.update.links': case 'messages.update.links':
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon( Icon(
Symbols.edit, Symbols.edit,
@@ -64,27 +64,29 @@ class MessageContent extends StatelessWidget {
color: Theme.of( color: Theme.of(
context, context,
).colorScheme.onSurfaceVariant.withOpacity(0.6), ).colorScheme.onSurfaceVariant.withOpacity(0.6),
), ).padding(top: 2),
const Gap(4), const Gap(4),
if (item.meta['previous_content'] is String) if (item.meta['previous_content'] is String)
PrettyDiffText( Flexible(
oldText: item.meta['previous_content'], child: PrettyDiffText(
newText: item.content ?? 'Edited a message', oldText: item.meta['previous_content'],
defaultTextStyle: Theme.of( newText: item.content ?? 'Edited a message',
context, defaultTextStyle: Theme.of(
).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
addedTextStyle: TextStyle(
backgroundColor: Theme.of(
context, context,
).colorScheme.primaryFixedDim.withOpacity(0.4), ).textTheme.bodyMedium!.copyWith(
), color: Theme.of(context).colorScheme.onSurfaceVariant,
deletedTextStyle: TextStyle( ),
decoration: TextDecoration.lineThrough, addedTextStyle: TextStyle(
color: Theme.of( backgroundColor: Theme.of(
context, context,
).colorScheme.onSurfaceVariant.withOpacity(0.7), ).colorScheme.primaryFixedDim.withOpacity(0.4),
),
deletedTextStyle: TextStyle(
decoration: TextDecoration.lineThrough,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withOpacity(0.7),
),
), ),
) )
else else
@@ -104,10 +106,12 @@ class MessageContent extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MarkdownTextContent( Flexible(
content: item.content ?? '*${item.type} has no content*', child: MarkdownTextContent(
isSelectable: true, content: item.content ?? '*${item.type} has no content*',
linesMargin: EdgeInsets.zero, isSelectable: true,
linesMargin: EdgeInsets.zero,
),
), ),
if (translatedText?.isNotEmpty ?? false) if (translatedText?.isNotEmpty ?? false)
...([ ...([