✨ Chat message attachments
This commit is contained in:
		| @@ -37,6 +37,7 @@ | ||||
|   "postEditNotify": "You are about editing a post that already published.", | ||||
|   "reactionAdded": "Your reaction has been added.", | ||||
|   "reactionRemoved": "Your reaction has been removed.", | ||||
|   "chatNew": "New Chat", | ||||
|   "chatMessagePlaceholder": "Write a message...", | ||||
|   "chatMessageEditNotify": "You are about editing a message.", | ||||
|   "chatMessageReplyNotify": "You are about replying a message.", | ||||
|   | ||||
| @@ -37,6 +37,7 @@ | ||||
|   "postEditNotify": "你正在修改一个已经发布了的帖子。", | ||||
|   "reactionAdded": "你的反应已被添加。", | ||||
|   "reactionRemoved": "你的反应已被移除。", | ||||
|   "chatNew": "新聊天", | ||||
|   "chatMessagePlaceholder": "发条消息……", | ||||
|   "chatMessageEditNotify": "你正在编辑信息中……", | ||||
|   "chatMessageReplyNotify": "你正在回复消息中……", | ||||
|   | ||||
| @@ -74,6 +74,7 @@ class _ChatScreenState extends State<ChatScreen> { | ||||
|   } | ||||
|  | ||||
|   bool getMessageMergeable(Message? a, Message? b) { | ||||
|     if (a?.replyTo != null || b?.replyTo != null) return false; | ||||
|     if (a == null || b == null) return false; | ||||
|     if (a.senderId != b.senderId) return false; | ||||
|     return a.createdAt.difference(b.createdAt).inMinutes <= 5; | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import 'package:solian/models/channel.dart'; | ||||
| import 'package:solian/providers/auth.dart'; | ||||
| import 'package:solian/router.dart'; | ||||
| import 'package:solian/utils/service_url.dart'; | ||||
| import 'package:solian/widgets/chat/chat_new.dart'; | ||||
| import 'package:solian/widgets/indent_wrapper.dart'; | ||||
| import 'package:flutter_gen/gen_l10n/app_localizations.dart'; | ||||
| import 'package:solian/widgets/signin_required.dart'; | ||||
| @@ -40,6 +41,13 @@ class _ChatIndexScreenState extends State<ChatIndexScreen> { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void viewNewChatAction() { | ||||
|     showModalBottomSheet( | ||||
|       context: context, | ||||
|       builder: (context) => const ChatNewAction(), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     Future.delayed(Duration.zero, () { | ||||
| @@ -55,6 +63,20 @@ class _ChatIndexScreenState extends State<ChatIndexScreen> { | ||||
|  | ||||
|     return IndentWrapper( | ||||
|       title: AppLocalizations.of(context)!.chat, | ||||
|       floatingActionButton: FutureBuilder( | ||||
|         future: auth.isAuthorized(), | ||||
|         builder: (context, snapshot) { | ||||
|           if (snapshot.hasData && snapshot.data!) { | ||||
|             return FloatingActionButton.extended( | ||||
|               icon: const Icon(Icons.add), | ||||
|               label: Text(AppLocalizations.of(context)!.chatNew), | ||||
|               onPressed: () => viewNewChatAction(), | ||||
|             ); | ||||
|           } else { | ||||
|             return Container(); | ||||
|           } | ||||
|         }, | ||||
|       ), | ||||
|       child: FutureBuilder( | ||||
|           future: auth.isAuthorized(), | ||||
|           builder: (context, snapshot) { | ||||
|   | ||||
| @@ -40,6 +40,7 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> { | ||||
|     showModalBottomSheet( | ||||
|       context: context, | ||||
|       builder: (context) => AttachmentEditor( | ||||
|         provider: 'interactive', | ||||
|         current: _attachments, | ||||
|         onUpdate: (value) => _attachments = value, | ||||
|       ), | ||||
| @@ -151,8 +152,7 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> { | ||||
|               const Divider(thickness: 0.3), | ||||
|               Expanded( | ||||
|                 child: Container( | ||||
|                   padding: | ||||
|                       const EdgeInsets.symmetric(horizontal: 16, vertical: 8), | ||||
|                   padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), | ||||
|                   child: TextField( | ||||
|                     maxLines: null, | ||||
|                     autofocus: true, | ||||
| @@ -160,9 +160,9 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> { | ||||
|                     keyboardType: TextInputType.multiline, | ||||
|                     controller: _textController, | ||||
|                     decoration: InputDecoration.collapsed( | ||||
|                       hintText: | ||||
|                           AppLocalizations.of(context)!.postContentPlaceholder, | ||||
|                       hintText: AppLocalizations.of(context)!.postContentPlaceholder, | ||||
|                     ), | ||||
|                     onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|   | ||||
| @@ -32,6 +32,7 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> { | ||||
|     showModalBottomSheet( | ||||
|       context: context, | ||||
|       builder: (context) => AttachmentEditor( | ||||
|         provider: 'interactive', | ||||
|         current: _attachments, | ||||
|         onUpdate: (value) => _attachments = value, | ||||
|       ), | ||||
| @@ -151,6 +152,7 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> { | ||||
|                     decoration: InputDecoration.collapsed( | ||||
|                       hintText: AppLocalizations.of(context)!.postContentPlaceholder, | ||||
|                     ), | ||||
|                     onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|   | ||||
							
								
								
									
										26
									
								
								lib/widgets/chat/chat_new.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								lib/widgets/chat/chat_new.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_gen/gen_l10n/app_localizations.dart'; | ||||
|  | ||||
| class ChatNewAction extends StatelessWidget { | ||||
|   const ChatNewAction({super.key}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return SizedBox( | ||||
|       height: 320, | ||||
|       width: double.infinity, | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           Container( | ||||
|             padding: const EdgeInsets.only(left: 20, top: 20, bottom: 8), | ||||
|             child: Text( | ||||
|               AppLocalizations.of(context)!.chatNew, | ||||
|               style: Theme.of(context).textTheme.headlineSmall, | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -1,6 +1,4 @@ | ||||
| import 'package:flutter/cupertino.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/widgets.dart'; | ||||
| import 'package:solian/models/message.dart'; | ||||
| import 'package:solian/widgets/chat/content.dart'; | ||||
| import 'package:solian/widgets/posts/content/attachment.dart'; | ||||
| @@ -25,14 +23,11 @@ class ChatMessage extends StatelessWidget { | ||||
|   } | ||||
|  | ||||
|   Widget renderReply() { | ||||
|     final padding = | ||||
|         underMerged ? const EdgeInsets.only(left: 14, right: 8, top: 4) : const EdgeInsets.only(left: 8, right: 8); | ||||
|  | ||||
|     if (item.replyTo != null) { | ||||
|       return Row( | ||||
|         children: [ | ||||
|           Container( | ||||
|             padding: padding, | ||||
|             padding: const EdgeInsets.only(left: 8, right: 8), | ||||
|             child: Row( | ||||
|                 mainAxisAlignment: MainAxisAlignment.center, | ||||
|                 crossAxisAlignment: CrossAxisAlignment.center, | ||||
|   | ||||
| @@ -5,8 +5,11 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:solian/models/message.dart'; | ||||
| import 'package:solian/models/post.dart'; | ||||
| import 'package:solian/providers/auth.dart'; | ||||
| import 'package:solian/utils/service_url.dart'; | ||||
| import 'package:solian/widgets/posts/attachment_editor.dart'; | ||||
| import 'package:badges/badges.dart' as badge; | ||||
|  | ||||
| class ChatMessageEditor extends StatefulWidget { | ||||
|   final String channel; | ||||
| @@ -25,6 +28,19 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> { | ||||
|  | ||||
|   bool _isSubmitting = false; | ||||
|  | ||||
|   List<Attachment> _attachments = List.empty(growable: true); | ||||
|  | ||||
|   void viewAttachments(BuildContext context) { | ||||
|     showModalBottomSheet( | ||||
|       context: context, | ||||
|       builder: (context) => AttachmentEditor( | ||||
|         provider: 'messaging', | ||||
|         current: _attachments, | ||||
|         onUpdate: (value) => _attachments = value, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Future<void> sendMessage(BuildContext context) async { | ||||
|     if (_isSubmitting) return; | ||||
|  | ||||
| @@ -39,6 +55,7 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> { | ||||
|     req.headers['Content-Type'] = 'application/json'; | ||||
|     req.body = jsonEncode(<String, dynamic>{ | ||||
|       'content': _textController.value.text, | ||||
|       'attachments': _attachments, | ||||
|       'reply_to': widget.replying?.id, | ||||
|     }); | ||||
|  | ||||
| @@ -57,6 +74,7 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> { | ||||
|  | ||||
|   void reset() { | ||||
|     _textController.clear(); | ||||
|     _attachments.clear(); | ||||
|  | ||||
|     if (widget.onReset != null) widget.onReset!(); | ||||
|   } | ||||
| @@ -65,6 +83,7 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> { | ||||
|     if (widget.editing != null) { | ||||
|       setState(() { | ||||
|         _textController.text = widget.editing!.content; | ||||
|         _attachments = widget.editing!.attachments ?? List.empty(growable: true); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| @@ -116,7 +135,7 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> { | ||||
|         widget.replying != null ? replyingBanner : Container(), | ||||
|         Container( | ||||
|           height: 56, | ||||
|           padding: const EdgeInsets.only(top: 4, left: 16, right: 8), | ||||
|           padding: const EdgeInsets.only(top: 4, bottom: 4, right: 8), | ||||
|           decoration: const BoxDecoration( | ||||
|             border: Border( | ||||
|               top: BorderSide(width: 0.3, color: Color(0xffdedede)), | ||||
| @@ -125,6 +144,16 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> { | ||||
|           child: Row( | ||||
|             mainAxisAlignment: MainAxisAlignment.start, | ||||
|             children: [ | ||||
|               badge.Badge( | ||||
|                 showBadge: _attachments.isNotEmpty, | ||||
|                 badgeContent: Text(_attachments.length.toString(), style: const TextStyle(color: Colors.white)), | ||||
|                 position: badge.BadgePosition.custom(top: -2, end: 8), | ||||
|                 child: TextButton( | ||||
|                   style: TextButton.styleFrom(shape: const CircleBorder(), padding: const EdgeInsets.all(4)), | ||||
|                   onPressed: !_isSubmitting ? () => viewAttachments(context) : null, | ||||
|                   child: const Icon(Icons.attach_file), | ||||
|                 ), | ||||
|               ), | ||||
|               Expanded( | ||||
|                 child: TextField( | ||||
|                   controller: _textController, | ||||
| @@ -136,6 +165,7 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> { | ||||
|                     hintText: AppLocalizations.of(context)!.chatMessagePlaceholder, | ||||
|                   ), | ||||
|                   onSubmitted: (_) => sendMessage(context), | ||||
|                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                 ), | ||||
|               ), | ||||
|               TextButton( | ||||
|   | ||||
| @@ -13,10 +13,16 @@ import 'package:solian/utils/service_url.dart'; | ||||
| import 'package:flutter_gen/gen_l10n/app_localizations.dart'; | ||||
|  | ||||
| class AttachmentEditor extends StatefulWidget { | ||||
|   final String provider; | ||||
|   final List<Attachment> current; | ||||
|   final void Function(List<Attachment> data) onUpdate; | ||||
|  | ||||
|   const AttachmentEditor({super.key, required this.current, required this.onUpdate}); | ||||
|   const AttachmentEditor({ | ||||
|     super.key, | ||||
|     required this.provider, | ||||
|     required this.current, | ||||
|     required this.onUpdate, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<AttachmentEditor> createState() => _AttachmentEditorState(); | ||||
| @@ -97,10 +103,8 @@ class _AttachmentEditorState extends State<AttachmentEditor> { | ||||
|  | ||||
|   Future<void> uploadAttachment(File file, String hashcode) async { | ||||
|     final auth = context.read<AuthProvider>(); | ||||
|     final req = MultipartRequest( | ||||
|       'POST', | ||||
|       getRequestUri('interactive', '/api/attachments'), | ||||
|     ); | ||||
|  | ||||
|     final req = MultipartRequest('POST', getRequestUri(widget.provider, '/api/attachments')); | ||||
|     req.files.add(await MultipartFile.fromPath('attachment', file.path)); | ||||
|     req.fields['hashcode'] = hashcode; | ||||
|  | ||||
| @@ -119,10 +123,7 @@ class _AttachmentEditorState extends State<AttachmentEditor> { | ||||
|   Future<void> disposeAttachment(BuildContext context, Attachment item, int index) async { | ||||
|     final auth = context.read<AuthProvider>(); | ||||
|  | ||||
|     final req = MultipartRequest( | ||||
|       'DELETE', | ||||
|       getRequestUri('interactive', '/api/attachments/${item.id}'), | ||||
|     ); | ||||
|     final req = MultipartRequest('DELETE', getRequestUri(widget.provider, '/api/attachments/${item.id}')); | ||||
|  | ||||
|     setState(() => _isSubmitting = true); | ||||
|     var res = await auth.client!.send(req); | ||||
| @@ -293,14 +294,16 @@ class AttachmentEditorMethodPopup extends StatelessWidget { | ||||
|             ), | ||||
|           ), | ||||
|           Expanded( | ||||
|             child: Row( | ||||
|               mainAxisAlignment: MainAxisAlignment.spaceAround, | ||||
|             child: GridView.count( | ||||
|               primary: false, | ||||
|               crossAxisSpacing: 10, | ||||
|               mainAxisSpacing: 10, | ||||
|               crossAxisCount: 4, | ||||
|               children: [ | ||||
|                 InkWell( | ||||
|                   borderRadius: BorderRadius.circular(8), | ||||
|                   onTap: () => pickImage(), | ||||
|                   child: Padding( | ||||
|                     padding: const EdgeInsets.all(8), | ||||
|                   child: Center( | ||||
|                     child: Column( | ||||
|                       mainAxisSize: MainAxisSize.min, | ||||
|                       children: [ | ||||
| @@ -314,8 +317,7 @@ class AttachmentEditorMethodPopup extends StatelessWidget { | ||||
|                 InkWell( | ||||
|                   borderRadius: BorderRadius.circular(8), | ||||
|                   onTap: () => takeImage(), | ||||
|                   child: Padding( | ||||
|                     padding: const EdgeInsets.all(8), | ||||
|                   child: Center( | ||||
|                     child: Column( | ||||
|                       mainAxisSize: MainAxisSize.min, | ||||
|                       children: [ | ||||
| @@ -329,12 +331,11 @@ class AttachmentEditorMethodPopup extends StatelessWidget { | ||||
|                 InkWell( | ||||
|                   borderRadius: BorderRadius.circular(8), | ||||
|                   onTap: () => pickVideo(), | ||||
|                   child: Padding( | ||||
|                     padding: const EdgeInsets.all(8), | ||||
|                   child: Center( | ||||
|                     child: Column( | ||||
|                       mainAxisSize: MainAxisSize.min, | ||||
|                       children: [ | ||||
|                         const Icon(Icons.camera, color: Colors.indigo), | ||||
|                         const Icon(Icons.camera, color: Colors.teal), | ||||
|                         const SizedBox(height: 8), | ||||
|                         Text(AppLocalizations.of(context)!.pickVideo), | ||||
|                       ], | ||||
| @@ -344,12 +345,11 @@ class AttachmentEditorMethodPopup extends StatelessWidget { | ||||
|                 InkWell( | ||||
|                   borderRadius: BorderRadius.circular(8), | ||||
|                   onTap: () => takeVideo(), | ||||
|                   child: Padding( | ||||
|                     padding: const EdgeInsets.all(8), | ||||
|                   child: Center( | ||||
|                     child: Column( | ||||
|                       mainAxisSize: MainAxisSize.min, | ||||
|                       children: [ | ||||
|                         const Icon(Icons.video_call, color: Colors.indigo), | ||||
|                         const Icon(Icons.video_call, color: Colors.teal), | ||||
|                         const SizedBox(height: 8), | ||||
|                         Text(AppLocalizations.of(context)!.takeVideo), | ||||
|                       ], | ||||
|   | ||||
							
								
								
									
										12
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								pubspec.lock
									
									
									
									
									
								
							| @@ -25,6 +25,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.11.0" | ||||
|   badges: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: badges | ||||
|       sha256: a7b6bbd60dce418df0db3058b53f9d083c22cdb5132a052145dc267494df0b84 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.1.2" | ||||
|   boolean_selector: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -697,10 +705,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: pointycastle | ||||
|       sha256: "70fe966348fe08c34bf929582f1d8247d9d9408130723206472b4687227e4333" | ||||
|       sha256: "79fbafed02cfdbe85ef3fd06c7f4bc2cbcba0177e61b765264853d4253b21744" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.8.0" | ||||
|     version: "3.9.0" | ||||
|   provider: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|   | ||||
| @@ -59,6 +59,7 @@ dependencies: | ||||
|   hive_flutter: ^1.1.0 | ||||
|   flutter_launcher_icons: ^0.13.1 | ||||
|   web_socket_channel: ^2.4.5 | ||||
|   badges: ^3.1.2 | ||||
|  | ||||
| dev_dependencies: | ||||
|   flutter_test: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user