From 9a2e0756b82f429dc1d86feb6979d0e2bd5d5881 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 30 May 2024 23:14:29 +0800 Subject: [PATCH] :sparkles: Full functional message chat --- lib/screens/channel/channel_chat.dart | 53 ++++++-- lib/screens/channel/channel_detail.dart | 2 +- lib/screens/realms/realm_detail.dart | 2 +- lib/translations.dart | 7 ++ lib/widgets/channel/channel_deletion.dart | 12 +- lib/widgets/channel/channel_member.dart | 6 +- lib/widgets/chat/chat_message.dart | 15 ++- lib/widgets/chat/chat_message_action.dart | 127 ++++++++++++++++++++ lib/widgets/chat/chat_message_deletion.dart | 73 +++++++++++ lib/widgets/chat/chat_message_input.dart | 109 +++++++++++------ lib/widgets/realms/realm_deletion.dart | 12 +- lib/widgets/realms/realm_member.dart | 6 +- 12 files changed, 352 insertions(+), 72 deletions(-) create mode 100644 lib/widgets/chat/chat_message_action.dart create mode 100644 lib/widgets/chat/chat_message_deletion.dart diff --git a/lib/screens/channel/channel_chat.dart b/lib/screens/channel/channel_chat.dart index 604f36d..92e662d 100644 --- a/lib/screens/channel/channel_chat.dart +++ b/lib/screens/channel/channel_chat.dart @@ -15,6 +15,7 @@ import 'package:solian/router.dart'; import 'package:solian/services.dart'; import 'package:solian/theme.dart'; import 'package:solian/widgets/chat/chat_message.dart'; +import 'package:solian/widgets/chat/chat_message_action.dart'; import 'package:solian/widgets/chat/chat_message_input.dart'; class ChannelChatScreen extends StatefulWidget { @@ -117,17 +118,21 @@ class _ChannelChatScreenState extends State { case 'messages.update': final payload = Message.fromJson(event.payload!); if (payload.channelId == _channel?.id) { - _pagingController.itemList - ?.map((x) => x.id == payload.id ? payload : x) - .toList(); + final idx = _pagingController.itemList + ?.indexWhere((x) => x.uuid == payload.uuid); + if (idx != null) { + _pagingController.itemList?[idx] = payload; + } } break; case 'messages.burnt': final payload = Message.fromJson(event.payload!); if (payload.channelId == _channel?.id) { - _pagingController.itemList = _pagingController.itemList - ?.where((x) => x.id != payload.id) - .toList(); + final idx = _pagingController.itemList + ?.indexWhere((x) => x.uuid != payload.uuid); + if (idx != null) { + _pagingController.itemList?.removeAt(idx - 1); + } } break; } @@ -142,6 +147,9 @@ class _ChannelChatScreenState extends State { return a.createdAt.difference(b.createdAt).inMinutes <= 3; } + Message? _messageToReplying; + Message? _messageToEditing; + Widget chatHistoryBuilder(context, item, index) { bool isMerged = false, hasMerged = false; if (index > 0) { @@ -158,16 +166,31 @@ class _ChannelChatScreenState extends State { } return InkWell( child: Container( - padding: EdgeInsets.only( - top: !isMerged ? 8 : 0, - bottom: !hasMerged ? 8 : 0, - ), child: ChatMessage( item: item, isMerged: isMerged, + ).paddingOnly( + top: !isMerged ? 8 : 0, + bottom: !hasMerged ? 8 : 0, ), ), - onLongPress: () {}, + onLongPress: () { + showModalBottomSheet( + useRootNavigator: true, + context: context, + builder: (context) => ChatMessageAction( + channel: _channel!, + realm: _channel!.realm, + item: item, + onEdit: () { + setState(() => _messageToEditing = item); + }, + onReply: () { + setState(() => _messageToReplying = item); + }, + ), + ); + }, ); } @@ -254,6 +277,8 @@ class _ChannelChatScreenState extends State { left: 16, right: 16, child: ChatMessageInput( + edit: _messageToEditing, + reply: _messageToReplying, realm: widget.realm, placeholder: placeholder, channel: _channel!, @@ -262,6 +287,12 @@ class _ChannelChatScreenState extends State { _pagingController.itemList?.insert(0, item); }); }, + onReset: () { + setState(() { + _messageToReplying = null; + _messageToEditing = null; + }); + }, ), ), ], diff --git a/lib/screens/channel/channel_detail.dart b/lib/screens/channel/channel_detail.dart index be8a6f7..67fc906 100644 --- a/lib/screens/channel/channel_detail.dart +++ b/lib/screens/channel/channel_detail.dart @@ -48,7 +48,7 @@ class _ChannelDetailScreenState extends State { void promptLeaveChannel() async { final did = await showDialog( context: context, - builder: (context) => ChannelDeletion( + builder: (context) => ChannelDeletionDialog( channel: widget.channel, realm: widget.realm, isOwned: _isOwned, diff --git a/lib/screens/realms/realm_detail.dart b/lib/screens/realms/realm_detail.dart index 8fdc3b6..91badae 100644 --- a/lib/screens/realms/realm_detail.dart +++ b/lib/screens/realms/realm_detail.dart @@ -46,7 +46,7 @@ class _RealmDetailScreenState extends State { void promptLeaveChannel() async { final did = await showDialog( context: context, - builder: (context) => RealmDeletion( + builder: (context) => RealmDeletionDialog( realm: widget.realm, isOwned: _isOwned, ), diff --git a/lib/translations.dart b/lib/translations.dart index 615412a..5d6e67e 100644 --- a/lib/translations.dart +++ b/lib/translations.dart @@ -149,6 +149,10 @@ class SolianMessages extends Translations { 'messageDecoding': 'Decoding...', 'messageDecodeFailed': 'Unable to decode: @message', 'messageInputPlaceholder': 'Message @channel', + 'messageActionList': 'Actions of Message', + 'messageDeletionConfirm': 'Confirm message deletion', + 'messageDeletionConfirmCaption': + 'Are your sure to delete message @id? This action cannot be undone!', }, 'zh_CN': { 'hide': '隐藏', @@ -287,6 +291,9 @@ class SolianMessages extends Translations { 'messageDecoding': '解码信息中…', 'messageDecodeFailed': '解码信息失败:@message', 'messageInputPlaceholder': '在 @channel 发信息', + 'messageActionList': '消息的操作', + 'messageDeletionConfirm': '确认删除消息', + 'messageDeletionConfirmCaption': '你确定要删除消息 @id 吗?该操作不可撤销。', } }; } diff --git a/lib/widgets/channel/channel_deletion.dart b/lib/widgets/channel/channel_deletion.dart index dcea19c..7f956a5 100644 --- a/lib/widgets/channel/channel_deletion.dart +++ b/lib/widgets/channel/channel_deletion.dart @@ -5,12 +5,12 @@ import 'package:solian/models/channel.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/services.dart'; -class ChannelDeletion extends StatefulWidget { +class ChannelDeletionDialog extends StatefulWidget { final Channel channel; final String realm; final bool isOwned; - const ChannelDeletion({ + const ChannelDeletionDialog({ super.key, required this.channel, required this.realm, @@ -18,10 +18,10 @@ class ChannelDeletion extends StatefulWidget { }); @override - State createState() => _ChannelDeletionState(); + State createState() => _ChannelDeletionDialogState(); } -class _ChannelDeletionState extends State { +class _ChannelDeletionDialogState extends State { bool _isBusy = false; Future deleteChannel() async { @@ -30,7 +30,7 @@ class _ChannelDeletionState extends State { setState(() => _isBusy = true); - final client = GetConnect(); + final client = GetConnect(maxAuthRetries: 3); client.httpClient.baseUrl = ServiceFinder.services['messaging']; client.httpClient.addAuthenticator(auth.requestAuthenticator); @@ -51,7 +51,7 @@ class _ChannelDeletionState extends State { setState(() => _isBusy = true); - final client = GetConnect(); + final client = GetConnect(maxAuthRetries: 3); client.httpClient.baseUrl = ServiceFinder.services['messaging']; client.httpClient.addAuthenticator(auth.requestAuthenticator); diff --git a/lib/widgets/channel/channel_member.dart b/lib/widgets/channel/channel_member.dart index e4f5cc1..1fbc31e 100644 --- a/lib/widgets/channel/channel_member.dart +++ b/lib/widgets/channel/channel_member.dart @@ -39,7 +39,7 @@ class _ChannelMemberListPopupState extends State { void getMembers() async { setState(() => _isBusy = true); - final client = GetConnect(); + final client = GetConnect(maxAuthRetries: 3); client.httpClient.baseUrl = ServiceFinder.services['messaging']; final resp = await client @@ -76,7 +76,7 @@ class _ChannelMemberListPopupState extends State { setState(() => _isBusy = true); - final client = GetConnect(); + final client = GetConnect(maxAuthRetries: 3); client.httpClient.baseUrl = ServiceFinder.services['messaging']; client.httpClient.addAuthenticator(auth.requestAuthenticator); @@ -99,7 +99,7 @@ class _ChannelMemberListPopupState extends State { setState(() => _isBusy = true); - final client = GetConnect(); + final client = GetConnect(maxAuthRetries: 3); client.httpClient.baseUrl = ServiceFinder.services['messaging']; client.httpClient.addAuthenticator(auth.requestAuthenticator); diff --git a/lib/widgets/chat/chat_message.dart b/lib/widgets/chat/chat_message.dart index ec1c2c2..eb69bdd 100644 --- a/lib/widgets/chat/chat_message.dart +++ b/lib/widgets/chat/chat_message.dart @@ -10,6 +10,7 @@ import 'package:url_launcher/url_launcher_string.dart'; class ChatMessage extends StatelessWidget { final Message item; + final bool isContentPreviewing; final bool isCompact; final bool isMerged; final bool isHasMerged; @@ -17,6 +18,7 @@ class ChatMessage extends StatelessWidget { const ChatMessage({ super.key, required this.item, + this.isContentPreviewing = false, this.isMerged = false, this.isHasMerged = false, this.isCompact = false, @@ -94,7 +96,9 @@ class ChatMessage extends StatelessWidget { @override Widget build(BuildContext context) { Widget widget; - if (isMerged) { + if (isContentPreviewing) { + widget = buildContent(); + } else if (isMerged) { widget = buildContent().paddingOnly(left: 52); } else if (isCompact) { widget = Row( @@ -139,10 +143,11 @@ class ChatMessage extends StatelessWidget { ), ], ).paddingSymmetric(horizontal: 12), - AttachmentList( - parentId: item.uuid, - attachmentsId: item.attachments ?? List.empty(), - ).paddingSymmetric(vertical: 4), + if (item.attachments?.isNotEmpty ?? false) + AttachmentList( + parentId: item.uuid, + attachmentsId: item.attachments ?? List.empty(), + ).paddingSymmetric(vertical: 4), ], ); } diff --git a/lib/widgets/chat/chat_message_action.dart b/lib/widgets/chat/chat_message_action.dart new file mode 100644 index 0000000..31678b3 --- /dev/null +++ b/lib/widgets/chat/chat_message_action.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:get/get.dart'; +import 'package:solian/models/channel.dart'; +import 'package:solian/models/message.dart'; +import 'package:solian/models/realm.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/widgets/chat/chat_message_deletion.dart'; + +class ChatMessageAction extends StatefulWidget { + final Channel channel; + final Realm? realm; + final Message item; + final Function? onEdit; + final Function? onReply; + + const ChatMessageAction({ + super.key, + required this.channel, + required this.realm, + required this.item, + this.onEdit, + this.onReply, + }); + + @override + State createState() => _ChatMessageActionState(); +} + +class _ChatMessageActionState extends State { + bool _isBusy = false; + bool _canModifyContent = false; + + void checkAbleToModifyContent() async { + final AuthProvider provider = Get.find(); + if (!await provider.isAuthorized) return; + + setState(() => _isBusy = true); + + final prof = await provider.getProfile(); + setState(() { + _canModifyContent = + prof.body?['id'] == widget.item.sender.account.externalId; + _isBusy = false; + }); + } + + @override + void initState() { + super.initState(); + + checkAbleToModifyContent(); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'messageActionList'.tr, + style: Theme.of(context).textTheme.headlineSmall, + ), + Text( + '#${widget.item.id.toString().padLeft(8, '0')}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), + if (_isBusy) const LinearProgressIndicator().animate().scaleX(), + Expanded( + child: ListView( + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const FaIcon(FontAwesomeIcons.reply, size: 20), + title: Text('reply'.tr), + onTap: () async { + if (widget.onReply != null) widget.onReply!(); + Navigator.pop(context); + }, + ), + if (_canModifyContent) + const Divider(thickness: 0.3, height: 0.3) + .paddingSymmetric(vertical: 16), + if (_canModifyContent) + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Icons.edit), + title: Text('edit'.tr), + onTap: () async { + if (widget.onEdit != null) widget.onEdit!(); + Navigator.pop(context); + }, + ), + if (_canModifyContent) + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Icons.delete), + title: Text('delete'.tr), + onTap: () async { + final value = await showDialog( + context: context, + builder: (context) => ChatMessageDeletionDialog( + channel: widget.channel, + realm: widget.realm, + item: widget.item, + ), + ); + if (value != null) { + Navigator.pop(context, true); + } + }, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/chat/chat_message_deletion.dart b/lib/widgets/chat/chat_message_deletion.dart new file mode 100644 index 0000000..958f39d --- /dev/null +++ b/lib/widgets/chat/chat_message_deletion.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:solian/exts.dart'; +import 'package:solian/models/channel.dart'; +import 'package:solian/models/message.dart'; +import 'package:solian/models/realm.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/services.dart'; + +class ChatMessageDeletionDialog extends StatefulWidget { + final Channel channel; + final Realm? realm; + final Message item; + + const ChatMessageDeletionDialog({ + super.key, + required this.channel, + required this.realm, + required this.item, + }); + + @override + State createState() => + _ChatMessageDeletionDialogState(); +} + +class _ChatMessageDeletionDialogState extends State { + bool _isBusy = false; + + void performAction() async { + final AuthProvider auth = Get.find(); + if (!await auth.isAuthorized) return; + + final client = GetConnect(maxAuthRetries: 3); + client.httpClient.baseUrl = ServiceFinder.services['messaging']; + client.httpClient.addAuthenticator(auth.requestAuthenticator); + + setState(() => _isBusy = true); + + final scope = (widget.realm?.alias.isNotEmpty ?? false) + ? widget.realm?.alias + : 'global'; + final resp = await client.delete( + '/api/channels/$scope/${widget.channel.alias}/messages/${widget.item.id}', + ); + if (resp.statusCode == 200) { + Navigator.pop(context, resp.body); + } else { + context.showErrorDialog(resp.bodyString); + setState(() => _isBusy = false); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('messageDeletionConfirm'.tr), + content: Text('messageDeletionConfirmCaption'.trParams({ + 'id': '#${widget.item.id}', + })), + actions: [ + TextButton( + onPressed: _isBusy ? null : () => Navigator.pop(context, false), + child: Text('cancel'.tr), + ), + TextButton( + onPressed: _isBusy ? null : performAction, + child: Text('confirm'.tr), + ), + ], + ); + } +} diff --git a/lib/widgets/chat/chat_message_input.dart b/lib/widgets/chat/chat_message_input.dart index eeaee86..070ede1 100644 --- a/lib/widgets/chat/chat_message_input.dart +++ b/lib/widgets/chat/chat_message_input.dart @@ -7,6 +7,7 @@ import 'package:solian/models/message.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/services.dart'; import 'package:solian/widgets/attachments/attachment_publish.dart'; +import 'package:solian/widgets/chat/chat_message.dart'; import 'package:uuid/uuid.dart'; class ChatMessageInput extends StatefulWidget { @@ -16,6 +17,7 @@ class ChatMessageInput extends StatefulWidget { final Channel channel; final String realm; final Function(Message) onSent; + final Function()? onReset; const ChatMessageInput({ super.key, @@ -25,6 +27,7 @@ class ChatMessageInput extends StatefulWidget { required this.channel, required this.realm, required this.onSent, + this.onReset, }); @override @@ -99,7 +102,7 @@ class _ChatMessageInputState extends State { senderId: sender.id, ); - widget.onSent(message); + if (widget.edit == null) widget.onSent(message); resetInput(); Response resp; @@ -121,54 +124,88 @@ class _ChatMessageInputState extends State { } void resetInput() { + if (widget.onReset != null) widget.onReset!(); _textController.clear(); } + void syncWidget() { + if (widget.edit != null) { + _textController.text = widget.edit!.content['value']; + } + } + + @override + void didUpdateWidget(covariant ChatMessageInput oldWidget) { + syncWidget(); + super.didUpdateWidget(oldWidget); + } + @override Widget build(BuildContext context) { - const double height = 56; - const borderRadius = BorderRadius.all(Radius.circular(height / 2)); + const borderRadius = BorderRadius.all(Radius.circular(20)); + + final notifyBannerActions = [ + TextButton( + onPressed: resetInput, + child: Text('cancel'.tr), + ) + ]; return Material( borderRadius: borderRadius, elevation: 2, child: ClipRRect( borderRadius: borderRadius, - child: SizedBox( - height: height, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Expanded( - child: TextField( - controller: _textController, - focusNode: _focusNode, - maxLines: null, - autocorrect: true, - keyboardType: TextInputType.text, - decoration: InputDecoration.collapsed( - hintText: widget.placeholder ?? - 'messageInputPlaceholder'.trParams( - {'channel': '#${widget.channel.alias}'}, - ), - ), - onSubmitted: (_) => sendMessage(), - onTapOutside: (_) => - FocusManager.instance.primaryFocus?.unfocus(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.edit != null) + MaterialBanner( + leading: const Icon(Icons.edit), + dividerColor: Colors.transparent, + content: ChatMessage( + item: widget.edit!, + isContentPreviewing: true, ), + actions: notifyBannerActions, ), - IconButton( - icon: const Icon(Icons.attach_file), - color: Colors.teal, - onPressed: () => showAttachments(), - ), - IconButton( - icon: const Icon(Icons.send), - color: Theme.of(context).colorScheme.primary, - onPressed: () => sendMessage(), - ) - ], - ).paddingOnly(left: 16, right: 4), + SizedBox( + height: 56, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded( + child: TextField( + controller: _textController, + focusNode: _focusNode, + maxLines: null, + autocorrect: true, + keyboardType: TextInputType.text, + decoration: InputDecoration.collapsed( + hintText: widget.placeholder ?? + 'messageInputPlaceholder'.trParams( + {'channel': '#${widget.channel.alias}'}, + ), + ), + onSubmitted: (_) => sendMessage(), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ), + ), + IconButton( + icon: const Icon(Icons.attach_file), + color: Colors.teal, + onPressed: () => showAttachments(), + ), + IconButton( + icon: const Icon(Icons.send), + color: Theme.of(context).colorScheme.primary, + onPressed: () => sendMessage(), + ) + ], + ).paddingOnly(left: 16, right: 4), + ), + ], ), ), ); diff --git a/lib/widgets/realms/realm_deletion.dart b/lib/widgets/realms/realm_deletion.dart index de90f45..5e7db46 100644 --- a/lib/widgets/realms/realm_deletion.dart +++ b/lib/widgets/realms/realm_deletion.dart @@ -5,21 +5,21 @@ import 'package:solian/models/realm.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/services.dart'; -class RealmDeletion extends StatefulWidget { +class RealmDeletionDialog extends StatefulWidget { final Realm realm; final bool isOwned; - const RealmDeletion({ + const RealmDeletionDialog({ super.key, required this.realm, required this.isOwned, }); @override - State createState() => _RealmDeletionState(); + State createState() => _RealmDeletionDialogState(); } -class _RealmDeletionState extends State { +class _RealmDeletionDialogState extends State { bool _isBusy = false; Future deleteChannel() async { @@ -28,7 +28,7 @@ class _RealmDeletionState extends State { setState(() => _isBusy = true); - final client = GetConnect(); + final client = GetConnect(maxAuthRetries: 3); client.httpClient.baseUrl = ServiceFinder.services['passport']; client.httpClient.addAuthenticator(auth.requestAuthenticator); @@ -48,7 +48,7 @@ class _RealmDeletionState extends State { setState(() => _isBusy = true); - final client = GetConnect(); + final client = GetConnect(maxAuthRetries: 3); client.httpClient.baseUrl = ServiceFinder.services['passport']; client.httpClient.addAuthenticator(auth.requestAuthenticator); diff --git a/lib/widgets/realms/realm_member.dart b/lib/widgets/realms/realm_member.dart index 64c5c29..3d8d386 100644 --- a/lib/widgets/realms/realm_member.dart +++ b/lib/widgets/realms/realm_member.dart @@ -37,7 +37,7 @@ class _RealmMemberListPopupState extends State { void getMembers() async { setState(() => _isBusy = true); - final client = GetConnect(); + final client = GetConnect(maxAuthRetries: 3); client.httpClient.baseUrl = ServiceFinder.services['passport']; final resp = await client.get('/api/realms/${widget.realm.alias}/members'); @@ -73,7 +73,7 @@ class _RealmMemberListPopupState extends State { setState(() => _isBusy = true); - final client = GetConnect(); + final client = GetConnect(maxAuthRetries: 3); client.httpClient.baseUrl = ServiceFinder.services['passport']; client.httpClient.addAuthenticator(auth.requestAuthenticator); @@ -96,7 +96,7 @@ class _RealmMemberListPopupState extends State { setState(() => _isBusy = true); - final client = GetConnect(); + final client = GetConnect(maxAuthRetries: 3); client.httpClient.baseUrl = ServiceFinder.services['passport']; client.httpClient.addAuthenticator(auth.requestAuthenticator);