diff --git a/lib/i18n/app_en.arb b/lib/i18n/app_en.arb index c238c9f..80735ee 100644 --- a/lib/i18n/app_en.arb +++ b/lib/i18n/app_en.arb @@ -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.", diff --git a/lib/i18n/app_zh.arb b/lib/i18n/app_zh.arb index 48cecba..95c1dc9 100644 --- a/lib/i18n/app_zh.arb +++ b/lib/i18n/app_zh.arb @@ -37,6 +37,7 @@ "postEditNotify": "你正在修改一个已经发布了的帖子。", "reactionAdded": "你的反应已被添加。", "reactionRemoved": "你的反应已被移除。", + "chatNew": "新聊天", "chatMessagePlaceholder": "发条消息……", "chatMessageEditNotify": "你正在编辑信息中……", "chatMessageReplyNotify": "你正在回复消息中……", diff --git a/lib/screens/chat/chat.dart b/lib/screens/chat/chat.dart index 2e3bb90..a7f7baa 100644 --- a/lib/screens/chat/chat.dart +++ b/lib/screens/chat/chat.dart @@ -74,6 +74,7 @@ class _ChatScreenState extends State { } 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; diff --git a/lib/screens/chat/index.dart b/lib/screens/chat/index.dart index 89567ac..8c44ba9 100644 --- a/lib/screens/chat/index.dart +++ b/lib/screens/chat/index.dart @@ -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 { } } + void viewNewChatAction() { + showModalBottomSheet( + context: context, + builder: (context) => const ChatNewAction(), + ); + } + @override void initState() { Future.delayed(Duration.zero, () { @@ -55,6 +63,20 @@ class _ChatIndexScreenState extends State { 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) { diff --git a/lib/screens/posts/comment_editor.dart b/lib/screens/posts/comment_editor.dart index e76d9eb..3062af5 100644 --- a/lib/screens/posts/comment_editor.dart +++ b/lib/screens/posts/comment_editor.dart @@ -40,6 +40,7 @@ class _CommentEditorScreenState extends State { showModalBottomSheet( context: context, builder: (context) => AttachmentEditor( + provider: 'interactive', current: _attachments, onUpdate: (value) => _attachments = value, ), @@ -151,8 +152,7 @@ class _CommentEditorScreenState extends State { 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 { keyboardType: TextInputType.multiline, controller: _textController, decoration: InputDecoration.collapsed( - hintText: - AppLocalizations.of(context)!.postContentPlaceholder, + hintText: AppLocalizations.of(context)!.postContentPlaceholder, ), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ), ), ), diff --git a/lib/screens/posts/moment_editor.dart b/lib/screens/posts/moment_editor.dart index dc92296..855a944 100644 --- a/lib/screens/posts/moment_editor.dart +++ b/lib/screens/posts/moment_editor.dart @@ -32,6 +32,7 @@ class _MomentEditorScreenState extends State { showModalBottomSheet( context: context, builder: (context) => AttachmentEditor( + provider: 'interactive', current: _attachments, onUpdate: (value) => _attachments = value, ), @@ -151,6 +152,7 @@ class _MomentEditorScreenState extends State { decoration: InputDecoration.collapsed( hintText: AppLocalizations.of(context)!.postContentPlaceholder, ), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ), ), ), diff --git a/lib/widgets/chat/chat_new.dart b/lib/widgets/chat/chat_new.dart new file mode 100644 index 0000000..c6c9b51 --- /dev/null +++ b/lib/widgets/chat/chat_new.dart @@ -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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/chat/message.dart b/lib/widgets/chat/message.dart index cc2d458..374c84f 100644 --- a/lib/widgets/chat/message.dart +++ b/lib/widgets/chat/message.dart @@ -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, diff --git a/lib/widgets/chat/message_editor.dart b/lib/widgets/chat/message_editor.dart index 3b7d10d..9c09a36 100644 --- a/lib/widgets/chat/message_editor.dart +++ b/lib/widgets/chat/message_editor.dart @@ -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 { bool _isSubmitting = false; + List _attachments = List.empty(growable: true); + + void viewAttachments(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (context) => AttachmentEditor( + provider: 'messaging', + current: _attachments, + onUpdate: (value) => _attachments = value, + ), + ); + } + Future sendMessage(BuildContext context) async { if (_isSubmitting) return; @@ -39,6 +55,7 @@ class _ChatMessageEditorState extends State { req.headers['Content-Type'] = 'application/json'; req.body = jsonEncode({ 'content': _textController.value.text, + 'attachments': _attachments, 'reply_to': widget.replying?.id, }); @@ -57,6 +74,7 @@ class _ChatMessageEditorState extends State { void reset() { _textController.clear(); + _attachments.clear(); if (widget.onReset != null) widget.onReset!(); } @@ -65,6 +83,7 @@ class _ChatMessageEditorState extends State { 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 { 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 { 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 { hintText: AppLocalizations.of(context)!.chatMessagePlaceholder, ), onSubmitted: (_) => sendMessage(context), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ), ), TextButton( diff --git a/lib/widgets/posts/attachment_editor.dart b/lib/widgets/posts/attachment_editor.dart index 60402fe..b2c1c78 100755 --- a/lib/widgets/posts/attachment_editor.dart +++ b/lib/widgets/posts/attachment_editor.dart @@ -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 current; final void Function(List 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 createState() => _AttachmentEditorState(); @@ -97,10 +103,8 @@ class _AttachmentEditorState extends State { Future uploadAttachment(File file, String hashcode) async { final auth = context.read(); - 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 { Future disposeAttachment(BuildContext context, Attachment item, int index) async { final auth = context.read(); - 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), ], diff --git a/pubspec.lock b/pubspec.lock index bd915bf..7d55af8 100644 --- a/pubspec.lock +++ b/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: diff --git a/pubspec.yaml b/pubspec.yaml index 25cdbd0..b96bac0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: