diff --git a/lib/router.dart b/lib/router.dart index 41e261d..dba61f9 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -1,8 +1,10 @@ import 'package:go_router/go_router.dart'; +import 'package:solian/models/channel.dart'; import 'package:solian/screens/account.dart'; import 'package:solian/screens/account/friend.dart'; import 'package:solian/screens/account/personalize.dart'; import 'package:solian/screens/channel/channel_chat.dart'; +import 'package:solian/screens/channel/channel_detail.dart'; import 'package:solian/screens/channel/channel_organize.dart'; import 'package:solian/screens/contact.dart'; import 'package:solian/screens/posts/post_detail.dart'; @@ -64,6 +66,20 @@ abstract class AppRouter { ), ], ), + ShellRoute( + builder: (context, state, child) => + BasicShell(state: state, child: child), + routes: [ + GoRoute( + path: '/chat/:alias/detail', + name: 'channelDetail', + builder: (context, state) => ChannelDetailScreen( + channel: state.extra as Channel, + realm: state.uri.queryParameters['realm'] ?? 'global', + ), + ), + ], + ), GoRoute( path: '/posts/publish', name: 'postPublishing', diff --git a/lib/screens/channel/channel_chat.dart b/lib/screens/channel/channel_chat.dart index 49fa62d..146158a 100644 --- a/lib/screens/channel/channel_chat.dart +++ b/lib/screens/channel/channel_chat.dart @@ -12,6 +12,7 @@ import 'package:solian/models/pagination.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/providers/chat.dart'; import 'package:solian/providers/content/channel.dart'; +import 'package:solian/router.dart'; import 'package:solian/services.dart'; import 'package:solian/theme.dart'; import 'package:solian/widgets/chat/chat_message.dart'; @@ -33,6 +34,7 @@ class ChannelChatScreen extends StatefulWidget { class _ChannelChatScreenState extends State { bool _isBusy = false; + String? _overrideAlias; Channel? _channel; StreamSubscription? _subscription; @@ -40,13 +42,20 @@ class _ChannelChatScreenState extends State { final PagingController _pagingController = PagingController(firstPageKey: 0); - getChannel() async { + getChannel({String? overrideAlias}) async { final ChannelProvider provider = Get.find(); setState(() => _isBusy = true); + if (overrideAlias != null) { + _overrideAlias = overrideAlias; + } + try { - final resp = await provider.getChannel(widget.alias, realm: widget.realm); + final resp = await provider.getChannel( + _overrideAlias ?? widget.alias, + realm: widget.realm, + ); setState(() => _channel = Channel.fromJson(resp.body)); } catch (e) { context.showErrorDialog(e); @@ -184,8 +193,23 @@ class _ChannelChatScreenState extends State { centerTitle: false, actions: [ IconButton( - icon: const Icon(Icons.settings), - onPressed: () {}, + icon: const Icon(Icons.more_vert), + onPressed: () { + AppRouter.instance + .pushNamed( + 'channelDetail', + pathParameters: {'alias': widget.alias}, + queryParameters: {'realm': widget.realm}, + extra: _channel, + ) + .then((value) { + if (value == false) AppRouter.instance.pop(); + if (value != null) { + final resp = Channel.fromJson(value as Map); + getChannel(overrideAlias: resp.alias); + } + }); + }, ), SizedBox( width: SolianTheme.isLargeScreen(context) ? 8 : 16, diff --git a/lib/screens/channel/channel_detail.dart b/lib/screens/channel/channel_detail.dart new file mode 100644 index 0000000..3710466 --- /dev/null +++ b/lib/screens/channel/channel_detail.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:get/get.dart'; +import 'package:solian/models/channel.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/router.dart'; +import 'package:solian/screens/channel/channel_organize.dart'; +import 'package:solian/widgets/channel/channel_deletion.dart'; + +class ChannelDetailScreen extends StatefulWidget { + final String realm; + final Channel channel; + + const ChannelDetailScreen({ + super.key, + required this.channel, + required this.realm, + }); + + @override + State createState() => _ChannelDetailScreenState(); +} + +class _ChannelDetailScreenState extends State { + bool _isOwned = false; + + void checkOwner() async { + final AuthProvider auth = Get.find(); + final prof = await auth.getProfile(); + setState(() { + _isOwned = prof.body['id'] == widget.channel.account.externalId; + }); + } + + void promptLeaveChannel() async { + final did = await showDialog( + context: context, + builder: (context) => ChannelDeletion( + channel: widget.channel, + realm: widget.realm, + isOwned: _isOwned, + ), + ); + if (did == true && AppRouter.instance.canPop()) { + AppRouter.instance.pop(false); + } + } + + @override + void initState() { + super.initState(); + + checkOwner(); + } + + @override + Widget build(BuildContext context) { + final ownerActions = [ + ListTile( + leading: const Icon(Icons.edit), + title: Text('channelAdjust'.tr), + onTap: () async { + AppRouter.instance + .pushNamed( + 'channelOrganizing', + extra: ChannelOrganizeArguments(edit: widget.channel), + queryParameters: + widget.realm != 'global' ? {'realm': widget.realm} : {}, + ) + .then((resp) { + if (resp != null) { + AppRouter.instance.pop(resp); + } + }); + }, + ), + ]; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: Colors.teal, + child: FaIcon( + widget.channel.icon, + color: Colors.white, + size: 18, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.channel.name, + style: Theme.of(context).textTheme.bodyLarge), + Text(widget.channel.description, + style: Theme.of(context).textTheme.bodySmall), + Text( + '#${widget.channel.id.toString().padLeft(8, '0')} · ${widget.channel.alias}', + style: const TextStyle(fontSize: 11), + ), + ], + ), + ) + ], + ), + ), + const Divider(thickness: 0.3), + Expanded( + child: ListView( + children: [ + ListTile( + leading: const Icon(Icons.settings), + title: Text('channelSettings'.tr), + onTap: () {}, + ), + ListTile( + leading: const Icon(Icons.supervisor_account), + title: Text('channelMembers'.tr), + onTap: () {}, + ), + ...(_isOwned ? ownerActions : List.empty()), + const Divider(thickness: 0.3), + ListTile( + leading: _isOwned + ? const Icon(Icons.delete) + : const Icon(Icons.exit_to_app), + title: Text(_isOwned ? 'delete'.tr : 'leave'.tr), + onTap: () => promptLeaveChannel(), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/screens/channel/channel_organize.dart b/lib/screens/channel/channel_organize.dart index 62bbb0b..3dc1147 100644 --- a/lib/screens/channel/channel_organize.dart +++ b/lib/screens/channel/channel_organize.dart @@ -227,7 +227,7 @@ class _ChannelOrganizeScreenState extends State { ).paddingSymmetric(horizontal: 16, vertical: 12), ), const Divider(thickness: 0.3), - if (_channelType == 1) + if (_channelType == 1 && widget.edit == null) ListTile( leading: const Icon(Icons.supervisor_account) .paddingSymmetric(horizontal: 8), @@ -250,6 +250,8 @@ class _ChannelOrganizeScreenState extends State { isExpanded: true, items: channelTypes.entries .map((item) => DropdownMenuItem( + enabled: widget.edit == null || + item.key == widget.edit?.type, value: item.key, child: Text( item.value, diff --git a/lib/translations.dart b/lib/translations.dart index 2df3901..298a306 100644 --- a/lib/translations.dart +++ b/lib/translations.dart @@ -14,6 +14,7 @@ class SolianMessages extends Translations { 'apply': 'Apply', 'cancel': 'Cancel', 'confirm': 'Confirm', + 'leave': 'Leave', 'loading': 'Loading...', 'edit': 'Edit', 'delete': 'Delete', @@ -79,7 +80,7 @@ class SolianMessages extends Translations { 'postRepostingNotify': 'You\'re reposting a post from @username.', 'postDeletionConfirm': 'Confirm post deletion', 'postDeletionConfirmCaption': - 'Are your sure to delete post "@content"? this action cannot be undone!', + 'Are your sure to delete post "@content"? This action cannot be undone!', 'reactAdd': 'React', 'reactCompleted': 'Your reaction has been added', 'reactUncompleted': 'Your reaction has been removed', @@ -98,9 +99,16 @@ class SolianMessages extends Translations { 'channelDescription': 'Description', 'channelEncrypted': 'Encrypted Channel', 'channelMember': 'Channel member', + 'channelMembers': 'Channel members', 'channelType': 'Channel type', 'channelTypeCommon': 'Regular', 'channelTypeDirect': 'DM', + 'channelAdjust': 'Channel Adjustment', + 'channelDetail': 'Channel Detail', + 'channelSettings': 'Channel settings', + 'channelDeletionConfirm': 'Confirm channel deletion', + 'channelDeletionConfirmCaption': + 'Are you sure to delete channel @channel? This action cannot be undone!', 'messageDecoding': 'Decoding...', 'messageDecodeFailed': 'Unable to decode: @message', 'messageInputPlaceholder': 'Message @channel...', @@ -112,6 +120,7 @@ class SolianMessages extends Translations { 'reset': '重置', 'cancel': '取消', 'confirm': '确认', + 'leave': '离开', 'loading': '载入中…', 'edit': '编辑', 'delete': '删除', @@ -174,7 +183,7 @@ class SolianMessages extends Translations { 'postReplyingNotify': '你正在回一个来自 @username 的帖子', 'postRepostingNotify': '你正在转一个来自 @username 的帖子', 'postDeletionConfirm': '确认删除帖子', - 'postDeletionConfirmCaption': '你确定要删除帖子 “@content” 吗?该操作不可不可撤销。', + 'postDeletionConfirmCaption': '你确定要删除帖子 “@content” 吗?该操作不可撤销。', 'reactAdd': '作出反应', 'reactCompleted': '你的反应已被添加', 'reactUncompleted': '你的反应已被移除', @@ -193,9 +202,15 @@ class SolianMessages extends Translations { 'channelDescription': '频道简介', 'channelEncrypted': '加密频道', 'channelMember': '频道成员', + 'channelMembers': '频道成员', 'channelType': '频道类型', 'channelTypeCommon': '普通频道', 'channelTypeDirect': '私信聊天', + 'channelAdjust': '调整频道', + 'channelDetail': '频道详情', + 'channelSettings': '频道设置', + 'channelDeletionConfirm': '确认删除频道', + 'channelDeletionConfirmCaption': '你确认要删除频道 @channel 吗?该操作不可撤销。', 'messageDecoding': '解码信息中…', 'messageDecodeFailed': '解码信息失败:@message', 'messageInputPlaceholder': '在 @channel 发信息…', diff --git a/lib/widgets/channel/channel_deletion.dart b/lib/widgets/channel/channel_deletion.dart new file mode 100644 index 0000000..dcea19c --- /dev/null +++ b/lib/widgets/channel/channel_deletion.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:solian/exts.dart'; +import 'package:solian/models/channel.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/services.dart'; + +class ChannelDeletion extends StatefulWidget { + final Channel channel; + final String realm; + final bool isOwned; + + const ChannelDeletion({ + super.key, + required this.channel, + required this.realm, + required this.isOwned, + }); + + @override + State createState() => _ChannelDeletionState(); +} + +class _ChannelDeletionState extends State { + bool _isBusy = false; + + Future deleteChannel() async { + final AuthProvider auth = Get.find(); + if (!await auth.isAuthorized) return; + + setState(() => _isBusy = true); + + final client = GetConnect(); + client.httpClient.baseUrl = ServiceFinder.services['messaging']; + client.httpClient.addAuthenticator(auth.requestAuthenticator); + + final resp = await client + .delete('/api/channels/${widget.realm}/${widget.channel.id}'); + if (resp.statusCode != 200) { + context.showErrorDialog(resp.bodyString); + } else if (Navigator.canPop(context)) { + Navigator.pop(context, 'OK'); + } + + setState(() => _isBusy = false); + } + + Future leaveChannel() async { + final AuthProvider auth = Get.find(); + if (!await auth.isAuthorized) return; + + setState(() => _isBusy = true); + + final client = GetConnect(); + client.httpClient.baseUrl = ServiceFinder.services['messaging']; + client.httpClient.addAuthenticator(auth.requestAuthenticator); + + final resp = await client.delete( + '/api/channels/${widget.realm}/${widget.channel.alias}/members/me', + ); + if (resp.statusCode != 200) { + context.showErrorDialog(resp.bodyString); + } else if (Navigator.canPop(context)) { + Navigator.pop(context, 'OK'); + } + + setState(() => _isBusy = false); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('channelDeletionConfirm'.tr), + content: Text( + 'channelDeletionConfirmCaption' + .trParams({'channel': '#${widget.channel.alias}'}), + ), + actions: [ + TextButton( + onPressed: _isBusy ? null : () => Navigator.pop(context), + child: Text('cancel'.tr), + ), + TextButton( + onPressed: _isBusy + ? null + : widget.isOwned + ? deleteChannel + : leaveChannel, + child: Text('confirm'.tr), + ), + ], + ); + } +}