Compare commits

...

2 Commits

Author SHA1 Message Date
1fbaac8d88 💄 Optimize chat input a step further 2025-09-27 15:31:57 +08:00
b9dc724f0b 🐛 Fix chat newline on desktop 2025-09-27 00:22:43 +08:00
3 changed files with 380 additions and 373 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,17 +738,15 @@ 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: loading: () => const Center(child: CircularProgressIndicator()),
() => const Center(child: CircularProgressIndicator()),
error: error:
(error, _) => ResponseErrorWidget( (error, _) => ResponseErrorWidget(
error: error, error: error,
@@ -753,7 +754,12 @@ class ChatRoomScreen extends HookConsumerWidget {
), ),
), ),
), ),
chatRoom.when( // Input
Positioned(
bottom: 0,
left: 0,
right: 0,
child: chatRoom.when(
data: data:
(room) => Column( (room) => Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -880,12 +886,12 @@ class ChatRoomScreen extends HookConsumerWidget {
}, },
attachmentProgress: attachmentProgress.value, attachmentProgress: attachmentProgress.value,
), ),
Gap(MediaQuery.of(context).padding.bottom),
], ],
), ),
error: (_, _) => const SizedBox.shrink(), error: (_, _) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(), loading: () => const SizedBox.shrink(),
), ),
],
), ),
Positioned( Positioned(
left: 0, left: 0,

View File

@@ -1,7 +1,5 @@
import "dart:async"; import "dart:async";
import "dart:io";
import "package:easy_localization/easy_localization.dart"; import "package:easy_localization/easy_localization.dart";
import "package:flutter/foundation.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter/services.dart"; import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
@@ -56,10 +54,6 @@ class ChatInput extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final inputFocusNode = useFocusNode(); final inputFocusNode = useFocusNode();
final enterToSend = ref.watch(appSettingsNotifierProvider).enterToSend;
final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);
void send() { void send() {
onSend.call(); onSend.call();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -67,6 +61,18 @@ class ChatInput extends HookConsumerWidget {
}); });
} }
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<void> handlePaste() async { Future<void> handlePaste() async {
final clipboard = await Pasteboard.image; final clipboard = await Pasteboard.image;
if (clipboard == null) return; if (clipboard == null) return;
@@ -80,36 +86,43 @@ class ChatInput extends HookConsumerWidget {
]); ]);
} }
void handleKeyPress( inputFocusNode.onKeyEvent = (node, event) {
BuildContext context, if (event is! KeyDownEvent) return KeyEventResult.ignored;
WidgetRef ref,
RawKeyEvent event,
) {
if (event is! RawKeyDownEvent) return;
final isPaste = event.logicalKey == LogicalKeyboardKey.keyV; final isPaste = event.logicalKey == LogicalKeyboardKey.keyV;
final isModifierPressed = event.isMetaPressed || event.isControlPressed; final isModifierPressed =
HardwareKeyboard.instance.isMetaPressed ||
HardwareKeyboard.instance.isControlPressed;
if (isPaste && isModifierPressed) { if (isPaste && isModifierPressed) {
handlePaste(); handlePaste();
return; return KeyEventResult.handled;
} }
final enterToSend = ref.read(appSettingsNotifierProvider).enterToSend; final enterToSend = ref.read(appSettingsNotifierProvider).enterToSend;
final isEnter = event.logicalKey == LogicalKeyboardKey.enter; final isEnter = event.logicalKey == LogicalKeyboardKey.enter;
if (isEnter) { if (isEnter) {
if (enterToSend && !isModifierPressed) { if (isModifierPressed) {
insertNewLine();
return KeyEventResult.handled;
} else if (enterToSend) {
send(); send();
} else if (!enterToSend && isModifierPressed) { return KeyEventResult.handled;
send();
}
} }
} }
return Material( return KeyEventResult.ignored;
elevation: 8, };
color: Theme.of(context).colorScheme.surface,
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( child: Column(
children: [ children: [
if (attachments.isNotEmpty) if (attachments.isNotEmpty)
@@ -143,12 +156,20 @@ class ChatInput extends HookConsumerWidget {
messageForwardingTo != null || messageForwardingTo != null ||
messageEditingTo != null) messageEditingTo != null)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration( horizontal: 16,
color: Theme.of(context).colorScheme.surfaceContainer, vertical: 4,
borderRadius: BorderRadius.circular(8), ),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(32),
),
margin: const EdgeInsets.only(
left: 8,
right: 8,
top: 8,
bottom: 4,
), ),
margin: const EdgeInsets.only(left: 8, right: 8, top: 8),
child: Row( child: Row(
children: [ children: [
Icon( Icon(
@@ -173,20 +194,18 @@ class ChatInput extends HookConsumerWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
IconButton( SizedBox(
icon: const Icon(Icons.close, size: 20), width: 28,
onPressed: onClear, height: 28,
padding: EdgeInsets.zero, child: InkWell(
style: ButtonStyle( onTap: onClear,
minimumSize: WidgetStatePropertyAll(Size(28, 28)), child: const Icon(Icons.close, size: 20).center(),
), ),
), ),
], ],
), ),
), ),
Padding( Row(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
child: Row(
children: [ children: [
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -260,32 +279,10 @@ class ChatInput extends HookConsumerWidget {
], ],
), ),
Expanded( Expanded(
child: RawKeyboardListener(
focusNode: FocusNode(),
onKey: (event) => handleKeyPress(context, ref, event),
child: TextField( child: TextField(
focusNode: inputFocusNode, focusNode: inputFocusNode,
controller: messageController, controller: messageController,
onSubmitted: keyboardType: TextInputType.multiline,
(enterToSend && isMobile)
? (_) {
send();
}
: null,
keyboardType:
(enterToSend && isMobile)
? TextInputType.text
: TextInputType.multiline,
textInputAction: TextInputAction.send,
inputFormatters: [
if (enterToSend && !isMobile)
TextInputFormatter.withFunction((oldValue, newValue) {
if (newValue.text.endsWith('\n')) {
return oldValue;
}
return newValue;
}),
],
decoration: InputDecoration( decoration: InputDecoration(
hintText: hintText:
(chatRoom.type == 1 && chatRoom.name == null) (chatRoom.type == 1 && chatRoom.name == null)
@@ -314,17 +311,17 @@ class ChatInput extends HookConsumerWidget {
(_) => FocusManager.instance.primaryFocus?.unfocus(), (_) => FocusManager.instance.primaryFocus?.unfocus(),
), ),
), ),
),
IconButton( IconButton(
icon: const Icon(Icons.send), icon: const Icon(Icons.send),
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
onPressed: send, 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,10 +64,11 @@ 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(
child: PrettyDiffText(
oldText: item.meta['previous_content'], oldText: item.meta['previous_content'],
newText: item.content ?? 'Edited a message', newText: item.content ?? 'Edited a message',
defaultTextStyle: Theme.of( defaultTextStyle: Theme.of(
@@ -86,6 +87,7 @@ class MessageContent extends StatelessWidget {
context, context,
).colorScheme.onSurfaceVariant.withOpacity(0.7), ).colorScheme.onSurfaceVariant.withOpacity(0.7),
), ),
),
) )
else else
Text( Text(
@@ -104,11 +106,13 @@ class MessageContent extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MarkdownTextContent( Flexible(
child: MarkdownTextContent(
content: item.content ?? '*${item.type} has no content*', content: item.content ?? '*${item.type} has no content*',
isSelectable: true, isSelectable: true,
linesMargin: EdgeInsets.zero, linesMargin: EdgeInsets.zero,
), ),
),
if (translatedText?.isNotEmpty ?? false) if (translatedText?.isNotEmpty ?? false)
...([ ...([
ConstrainedBox( ConstrainedBox(