From 846febb82a904f916243afc8c64e1f801e14f211 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 20 Apr 2024 00:01:48 +0800 Subject: [PATCH] :sparkles: Channel creation and management --- lib/i18n/app_en.arb | 9 ++ lib/i18n/app_zh.arb | 2 + lib/router.dart | 15 +- lib/screens/chat/chat.dart | 9 +- lib/screens/chat/index.dart | 8 +- lib/screens/posts/comment_editor.dart | 15 +- lib/screens/posts/moment_editor.dart | 15 +- lib/widgets/chat/channel_action.dart | 53 +++++++ lib/widgets/chat/channel_editor.dart | 188 +++++++++++++++++++++++ lib/widgets/chat/chat_new.dart | 16 +- lib/widgets/posts/attachment_editor.dart | 5 +- manifest.json | 7 + pubspec.lock | 16 ++ pubspec.yaml | 1 + 14 files changed, 334 insertions(+), 25 deletions(-) create mode 100644 lib/widgets/chat/channel_action.dart create mode 100644 lib/widgets/chat/channel_editor.dart create mode 100644 manifest.json diff --git a/lib/i18n/app_en.arb b/lib/i18n/app_en.arb index 7d512f4..373421c 100644 --- a/lib/i18n/app_en.arb +++ b/lib/i18n/app_en.arb @@ -12,11 +12,13 @@ "confirmCancel": "Not sure", "confirmOkay": "OK", "edit": "Edit", + "apply": "Apply", "delete": "Delete", "action": "Action", "cancel": "Cancel", "report": "Report", "reply": "Reply", + "settings": "Settings", "reaction": "Reaction", "reactVerb": "React", "post": "Post", @@ -40,6 +42,13 @@ "chatNew": "New Chat", "chatNewCreate": "Create a channel", "chatNewJoin": "Join a exists channel", + "chatChannelUsage": "Channel", + "chatChannelUsageCaption": "Channel is place to talk with people, one or a lot.", + "chatChannelOrganize": "Organize a channel", + "chatChannelEditNotify": "You are about editing a existing channel.", + "chatChannelAliasLabel": "Channel Alias", + "chatChannelNameLabel": "Channel Name", + "chatChannelDescriptionLabel": "Channel Description", "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 b86e70f..1c04a78 100644 --- a/lib/i18n/app_zh.arb +++ b/lib/i18n/app_zh.arb @@ -14,9 +14,11 @@ "edit": "编辑", "delete": "删除", "action": "操作", + "apply": "应用", "cancel": "取消", "report": "举报", "reply": "回复", + "settings": "设置", "reaction": "反应", "reactVerb": "作出反应", "post": "帖子", diff --git a/lib/router.dart b/lib/router.dart index 4aa73c4..673a8d3 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -1,4 +1,5 @@ import 'package:go_router/go_router.dart'; +import 'package:solian/models/channel.dart'; import 'package:solian/models/post.dart'; import 'package:solian/screens/account.dart'; import 'package:solian/screens/chat/chat.dart'; @@ -7,6 +8,7 @@ import 'package:solian/screens/explore.dart'; import 'package:solian/screens/posts/comment_editor.dart'; import 'package:solian/screens/posts/moment_editor.dart'; import 'package:solian/screens/posts/screen.dart'; +import 'package:solian/widgets/chat/channel_editor.dart'; final router = GoRouter( routes: [ @@ -21,7 +23,12 @@ final router = GoRouter( builder: (context, state) => const ChatIndexScreen(), ), GoRoute( - path: '/chat/:channel', + path: '/chat/create', + name: 'chat.channel.editor', + builder: (context, state) => ChannelEditor(editing: state.extra as Channel?), + ), + GoRoute( + path: '/chat/c/:channel', name: 'chat.channel', builder: (context, state) => ChatScreen(alias: state.pathParameters['channel'] as String), ), @@ -33,16 +40,14 @@ final router = GoRouter( GoRoute( path: '/posts/publish/moments', name: 'posts.moments.editor', - builder: (context, state) => - MomentEditorScreen(editing: state.extra as Post?), + builder: (context, state) => MomentEditorScreen(editing: state.extra as Post?), ), GoRoute( path: '/posts/publish/comments', name: 'posts.comments.editor', builder: (context, state) { final args = state.extra as CommentPostArguments; - return CommentEditorScreen( - editing: args.editing, related: args.related); + return CommentEditorScreen(editing: args.editing, related: args.related); }, ), GoRoute( diff --git a/lib/screens/chat/chat.dart b/lib/screens/chat/chat.dart index a7f7baa..630ac1e 100644 --- a/lib/screens/chat/chat.dart +++ b/lib/screens/chat/chat.dart @@ -8,6 +8,7 @@ import 'package:solian/models/message.dart'; import 'package:solian/models/pagination.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/utils/service_url.dart'; +import 'package:solian/widgets/chat/channel_action.dart'; import 'package:solian/widgets/chat/maintainer.dart'; import 'package:solian/widgets/chat/message.dart'; import 'package:solian/widgets/chat/message_action.dart'; @@ -31,7 +32,7 @@ class _ChatScreenState extends State { final http.Client _client = http.Client(); - Future fetchMetadata(BuildContext context) async { + Future fetchMetadata() async { var uri = getRequestUri('messaging', '/api/channels/${widget.alias}'); var res = await _client.get(uri); if (res.statusCode == 200) { @@ -120,7 +121,7 @@ class _ChatScreenState extends State { @override void initState() { Future.delayed(Duration.zero, () { - fetchMetadata(context); + fetchMetadata(); }); _pagingController.addPageRequestListener((pageKey) => fetchMessages(pageKey, context)); @@ -133,6 +134,9 @@ class _ChatScreenState extends State { return IndentWrapper( hideDrawer: true, title: _channelMeta?.name ?? "Loading...", + appBarActions: [ + _channelMeta != null ? ChannelAction(channel: _channelMeta!, onUpdate: () => fetchMetadata()) : Container(), + ], child: ChatMaintainer( child: Column( children: [ @@ -141,6 +145,7 @@ class _ChatScreenState extends State { reverse: true, pagingController: _pagingController, builderDelegate: PagedChildBuilderDelegate( + noItemsFoundIndicatorBuilder: (_) => Container(), itemBuilder: (context, item, index) { bool isMerged = false, hasMerged = false; if (index > 0) { diff --git a/lib/screens/chat/index.dart b/lib/screens/chat/index.dart index 8c44ba9..32c8825 100644 --- a/lib/screens/chat/index.dart +++ b/lib/screens/chat/index.dart @@ -21,7 +21,7 @@ class ChatIndexScreen extends StatefulWidget { class _ChatIndexScreenState extends State { List _channels = List.empty(); - Future fetchChannels(BuildContext context) async { + Future fetchChannels() async { final auth = context.read(); if (!await auth.isAuthorized()) return; @@ -44,14 +44,14 @@ class _ChatIndexScreenState extends State { void viewNewChatAction() { showModalBottomSheet( context: context, - builder: (context) => const ChatNewAction(), + builder: (context) => ChatNewAction(onUpdate: () => fetchChannels()), ); } @override void initState() { Future.delayed(Duration.zero, () { - fetchChannels(context); + fetchChannels(); }); super.initState(); @@ -85,7 +85,7 @@ class _ChatIndexScreenState extends State { } return RefreshIndicator( - onRefresh: () => fetchChannels(context), + onRefresh: () => fetchChannels(), child: ListView.builder( itemCount: _channels.length, itemBuilder: (context, index) { diff --git a/lib/screens/posts/comment_editor.dart b/lib/screens/posts/comment_editor.dart index 3062af5..1aa19df 100644 --- a/lib/screens/posts/comment_editor.dart +++ b/lib/screens/posts/comment_editor.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:http/http.dart'; import 'package:provider/provider.dart'; import 'package:solian/models/post.dart'; @@ -48,8 +49,13 @@ class _CommentEditorScreenState extends State { } Future applyPost(BuildContext context) async { + setState(() => _isSubmitting = true); + final auth = context.read(); - if (!await auth.isAuthorized()) return; + if (!await auth.isAuthorized()) { + setState(() => _isSubmitting = false); + return; + } final alias = widget.related?.alias ?? 'not-found'; final relatedDataset = '${widget.related?.modelType ?? 'comment'}s'; @@ -65,7 +71,6 @@ class _CommentEditorScreenState extends State { 'attachments': _attachments, }); - setState(() => _isSubmitting = true); var res = await Response.fromStream(await auth.client!.send(req)); if (res.statusCode != 200) { var message = utf8.decode(res.bodyBytes); @@ -81,8 +86,8 @@ class _CommentEditorScreenState extends State { } void cancelEditing() { - if (Navigator.canPop(context)) { - Navigator.pop(context); + if (router.canPop()) { + router.pop(false); } } @@ -129,7 +134,7 @@ class _CommentEditorScreenState extends State { constraints: const BoxConstraints(maxWidth: 640), child: Column( children: [ - _isSubmitting ? const LinearProgressIndicator() : Container(), + _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(), FutureBuilder( future: auth.getProfiles(), builder: (context, snapshot) { diff --git a/lib/screens/posts/moment_editor.dart b/lib/screens/posts/moment_editor.dart index 855a944..dd36f6c 100644 --- a/lib/screens/posts/moment_editor.dart +++ b/lib/screens/posts/moment_editor.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:http/http.dart'; import 'package:provider/provider.dart'; import 'package:solian/models/post.dart'; @@ -40,8 +41,13 @@ class _MomentEditorScreenState extends State { } Future applyPost(BuildContext context) async { + setState(() => _isSubmitting = true); + final auth = context.read(); - if (!await auth.isAuthorized()) return; + if (!await auth.isAuthorized()) { + setState(() => _isSubmitting = false); + return; + } final uri = widget.editing == null ? getRequestUri('interactive', '/api/p/moments') @@ -55,7 +61,6 @@ class _MomentEditorScreenState extends State { 'attachments': _attachments, }); - setState(() => _isSubmitting = true); var res = await Response.fromStream(await auth.client!.send(req)); if (res.statusCode != 200) { var message = utf8.decode(res.bodyBytes); @@ -71,8 +76,8 @@ class _MomentEditorScreenState extends State { } void cancelEditing() { - if (Navigator.canPop(context)) { - Navigator.pop(context); + if (router.canPop()) { + router.pop(false); } } @@ -119,7 +124,7 @@ class _MomentEditorScreenState extends State { constraints: const BoxConstraints(maxWidth: 640), child: Column( children: [ - _isSubmitting ? const LinearProgressIndicator() : Container(), + _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(), FutureBuilder( future: auth.getProfiles(), builder: (context, snapshot) { diff --git a/lib/widgets/chat/channel_action.dart b/lib/widgets/chat/channel_action.dart new file mode 100644 index 0000000..2759b29 --- /dev/null +++ b/lib/widgets/chat/channel_action.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:solian/models/channel.dart'; +import 'package:solian/router.dart'; + +class ChannelAction extends StatelessWidget { + final Channel channel; + final Function onUpdate; + + ChannelAction({super.key, required this.channel, required this.onUpdate}); + + final FocusNode _focusNode = FocusNode(); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + MenuAnchor( + menuChildren: [ + MenuItemButton( + child: Row( + children: [ + const Icon(Icons.settings), + const SizedBox(width: 12), + Text(AppLocalizations.of(context)!.settings), + ], + ), + onPressed: () { + router.pushNamed('chat.channel.editor', extra: channel).then((did) { + if(did == true) onUpdate(); + }); + }, + ), + ], + builder: (BuildContext context, MenuController controller, Widget? child) { + return IconButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + focusNode: _focusNode, + style: TextButton.styleFrom(shape: const CircleBorder()), + icon: const Icon(Icons.more_horiz), + ); + }, + ), + ], + ); + } +} diff --git a/lib/widgets/chat/channel_editor.dart b/lib/widgets/chat/channel_editor.dart new file mode 100644 index 0000000..3e6a488 --- /dev/null +++ b/lib/widgets/chat/channel_editor.dart @@ -0,0 +1,188 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:http/http.dart'; +import 'package:provider/provider.dart'; +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/indent_wrapper.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:uuid/uuid.dart'; + +class ChannelEditor extends StatefulWidget { + final Channel? editing; + + const ChannelEditor({super.key, this.editing}); + + @override + State createState() => _ChannelEditorState(); +} + +class _ChannelEditorState extends State { + final _aliasController = TextEditingController(); + final _nameController = TextEditingController(); + final _descriptionController = TextEditingController(); + + bool _isSubmitting = false; + + Future applyChannel(BuildContext context) async { + setState(() => _isSubmitting = true); + + final auth = context.read(); + if (!await auth.isAuthorized()) { + setState(() => _isSubmitting = false); + return; + } + + final uri = widget.editing == null + ? getRequestUri('messaging', '/api/channels') + : getRequestUri('messaging', '/api/channels/${widget.editing!.id}'); + + final req = Request(widget.editing == null ? "POST" : "PUT", uri); + req.headers['Content-Type'] = 'application/json'; + req.body = jsonEncode({ + 'alias': _aliasController.value.text.toLowerCase(), + 'name': _nameController.value.text, + 'description': _descriptionController.value.text, + }); + + var res = await Response.fromStream(await auth.client!.send(req)); + if (res.statusCode != 200) { + var message = utf8.decode(res.bodyBytes); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Something went wrong... $message")), + ); + } else { + if (router.canPop()) { + router.pop(true); + } + } + setState(() => _isSubmitting = false); + } + + void randomizeAlias() { + _aliasController.text = const Uuid().v4().replaceAll('-', ''); + } + + void cancelEditing() { + if (router.canPop()) { + router.pop(false); + } + } + + @override + void initState() { + if (widget.editing != null) { + _aliasController.text = widget.editing!.alias; + _nameController.text = widget.editing!.name; + _descriptionController.text = widget.editing!.description; + } + + super.initState(); + } + + @override + Widget build(BuildContext context) { + final editingBanner = MaterialBanner( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 20), + leading: const Icon(Icons.edit_note), + backgroundColor: const Color(0xFFE0E0E0), + dividerColor: const Color.fromARGB(1, 0, 0, 0), + content: Text(AppLocalizations.of(context)!.chatChannelEditNotify), + actions: [ + TextButton( + child: Text(AppLocalizations.of(context)!.cancel), + onPressed: () => cancelEditing(), + ), + ], + ); + + return IndentWrapper( + hideDrawer: true, + title: AppLocalizations.of(context)!.chatChannelOrganize, + appBarActions: [ + TextButton( + onPressed: !_isSubmitting ? () => applyChannel(context) : null, + child: Text(AppLocalizations.of(context)!.apply.toUpperCase()), + ), + ], + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 640), + child: Column( + children: [ + _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(), + ListTile( + title: Text(AppLocalizations.of(context)!.chatChannelUsage), + subtitle: Text(AppLocalizations.of(context)!.chatChannelUsageCaption), + leading: const CircleAvatar( + backgroundColor: Colors.teal, + child: Icon(Icons.tag, color: Colors.white), + ), + ), + const Divider(thickness: 0.3), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + child: Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + controller: _aliasController, + decoration: InputDecoration.collapsed( + hintText: AppLocalizations.of(context)!.chatChannelAliasLabel, + ), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + ), + TextButton( + style: TextButton.styleFrom( + shape: const CircleBorder(), + visualDensity: const VisualDensity(horizontal: -2, vertical: -2), + ), + onPressed: () => randomizeAlias(), + child: const Icon(Icons.refresh), + ) + ], + ), + ), + const Divider(thickness: 0.3), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + autocorrect: true, + controller: _nameController, + decoration: InputDecoration.collapsed( + hintText: AppLocalizations.of(context)!.chatChannelNameLabel, + ), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + ), + const Divider(thickness: 0.3), + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: TextField( + minLines: 5, + maxLines: null, + autocorrect: true, + keyboardType: TextInputType.multiline, + controller: _descriptionController, + decoration: InputDecoration.collapsed( + hintText: AppLocalizations.of(context)!.chatChannelDescriptionLabel, + ), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + ), + ), + widget.editing != null ? editingBanner : Container(), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/chat/chat_new.dart b/lib/widgets/chat/chat_new.dart index 2f16509..3df1562 100644 --- a/lib/widgets/chat/chat_new.dart +++ b/lib/widgets/chat/chat_new.dart @@ -1,9 +1,11 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:solian/router.dart'; class ChatNewAction extends StatelessWidget { - const ChatNewAction({super.key}); + final Function onUpdate; + + const ChatNewAction({super.key, required this.onUpdate}); @override Widget build(BuildContext context) { @@ -26,6 +28,16 @@ class ChatNewAction extends StatelessWidget { ListTile( leading: const Icon(Icons.add), title: Text(AppLocalizations.of(context)!.chatNewCreate), + onTap: () { + router.pushNamed('chat.channel.editor').then((did) { + if (did == true) { + onUpdate(); + if (Navigator.canPop(context)) { + Navigator.pop(context); + } + } + }); + }, ), ListTile( leading: const Icon(Icons.travel_explore), diff --git a/lib/widgets/posts/attachment_editor.dart b/lib/widgets/posts/attachment_editor.dart index b2c1c78..5df5733 100755 --- a/lib/widgets/posts/attachment_editor.dart +++ b/lib/widgets/posts/attachment_editor.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:crypto/crypto.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:http/http.dart'; import 'package:image_picker/image_picker.dart'; import 'package:provider/provider.dart'; @@ -184,7 +185,7 @@ class _AttachmentEditorState extends State { return Column( children: [ Container( - padding: const EdgeInsets.only(left: 8, right: 8, top: 20), + padding: const EdgeInsets.only(left: 8, right: 8, top: 20, bottom: 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -215,7 +216,7 @@ class _AttachmentEditorState extends State { ], ), ), - _isSubmitting ? const LinearProgressIndicator() : Container(), + _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(), Expanded( child: ListView.separated( itemCount: _attachments.length, diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..fc822dd --- /dev/null +++ b/manifest.json @@ -0,0 +1,7 @@ +{ + "version": "1.0.0", + "release": { + "title": "The initial release", + "description": "We just moved our frontend technology stack to flutter! It makes a lot of fun and good performance. Now enjoy the Solar Network with seamless experience!" + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 7d55af8..09a6c39 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -182,6 +182,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_animate: + dependency: "direct main" + description: + name: flutter_animate + sha256: "7c8a6594a9252dad30cc2ef16e33270b6248c4dedc3b3d06c86c4f3f4dc05ae5" + url: "https://pub.dev" + source: hosted + version: "4.5.0" flutter_carousel_widget: dependency: "direct main" description: @@ -275,6 +283,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "02750b545c01ff4d8e9bbe8f27a7731aa3778402506c67daa1de7f5fc3f4befe" + url: "https://pub.dev" + source: hosted + version: "0.1.2" flutter_staggered_grid_view: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b96bac0..a3992f5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,7 @@ dependencies: flutter_launcher_icons: ^0.13.1 web_socket_channel: ^2.4.5 badges: ^3.1.2 + flutter_animate: ^4.5.0 dev_dependencies: flutter_test: