diff --git a/lib/screens/channel/channel_detail.dart b/lib/screens/channel/channel_detail.dart index 3710466..f906824 100644 --- a/lib/screens/channel/channel_detail.dart +++ b/lib/screens/channel/channel_detail.dart @@ -6,6 +6,7 @@ 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'; +import 'package:solian/widgets/channel/channel_member.dart'; class ChannelDetailScreen extends StatefulWidget { final String realm; @@ -32,6 +33,18 @@ class _ChannelDetailScreenState extends State { }); } + void showMemberList() { + showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + builder: (context) => ChannelMemberListPopup( + channel: widget.channel, + realm: widget.realm, + ), + ); + } + void promptLeaveChannel() async { final did = await showDialog( context: context, @@ -58,6 +71,7 @@ class _ChannelDetailScreenState extends State { final ownerActions = [ ListTile( leading: const Icon(Icons.edit), + trailing: const Icon(Icons.chevron_right), title: Text('channelAdjust'.tr), onTap: () async { AppRouter.instance @@ -116,13 +130,14 @@ class _ChannelDetailScreenState extends State { children: [ ListTile( leading: const Icon(Icons.settings), + trailing: const Icon(Icons.chevron_right), title: Text('channelSettings'.tr), - onTap: () {}, ), ListTile( leading: const Icon(Icons.supervisor_account), + trailing: const Icon(Icons.chevron_right), title: Text('channelMembers'.tr), - onTap: () {}, + onTap: () => showMemberList(), ), ...(_isOwned ? ownerActions : List.empty()), const Divider(thickness: 0.3), diff --git a/lib/translations.dart b/lib/translations.dart index 298a306..78a3f27 100644 --- a/lib/translations.dart +++ b/lib/translations.dart @@ -57,6 +57,7 @@ class SolianMessages extends Translations { 'signupCaption': 'Create an account on Solarpass and then get the access of entire Solar Network!', 'signout': 'Sign out', + 'joinedAt': 'Joined at @date', 'riskDetection': 'Risk Detected', 'matureContent': 'Mature Content', 'matureContentCaption': @@ -100,6 +101,8 @@ class SolianMessages extends Translations { 'channelEncrypted': 'Encrypted Channel', 'channelMember': 'Channel member', 'channelMembers': 'Channel members', + 'channelMembersAdd': 'Add channel members', + 'channelMembersAddHint': 'Into @channel', 'channelType': 'Channel type', 'channelTypeCommon': 'Regular', 'channelTypeDirect': 'DM', @@ -111,7 +114,7 @@ class SolianMessages extends Translations { 'Are you sure to delete channel @channel? This action cannot be undone!', 'messageDecoding': 'Decoding...', 'messageDecodeFailed': 'Unable to decode: @message', - 'messageInputPlaceholder': 'Message @channel...', + 'messageInputPlaceholder': 'Message @channel', }, 'zh_CN': { 'hide': '隐藏', @@ -162,6 +165,7 @@ class SolianMessages extends Translations { 'signupGreeting': '欢迎加入\nSolar Network', 'signupCaption': '在 Solarpass 注册一个账号以获得整个 Solar Network 的存取权!', 'signout': '登出', + 'joinedAt': '加入于 @date', 'riskDetection': '检测到风险', 'matureContent': '评级内容', 'matureContentCaption': '该内容已被评级为家长指导级或以上,这可能说明内容包含一系列不友好的成分', @@ -203,6 +207,8 @@ class SolianMessages extends Translations { 'channelEncrypted': '加密频道', 'channelMember': '频道成员', 'channelMembers': '频道成员', + 'channelMembersAdd': '添加频道成员', + 'channelMembersAddHint': '到 @channel', 'channelType': '频道类型', 'channelTypeCommon': '普通频道', 'channelTypeDirect': '私信聊天', @@ -213,7 +219,7 @@ class SolianMessages extends Translations { 'channelDeletionConfirmCaption': '你确认要删除频道 @channel 吗?该操作不可撤销。', 'messageDecoding': '解码信息中…', 'messageDecodeFailed': '解码信息失败:@message', - 'messageInputPlaceholder': '在 @channel 发信息…', + 'messageInputPlaceholder': '在 @channel 发信息', } }; } diff --git a/lib/widgets/channel/channel_member.dart b/lib/widgets/channel/channel_member.dart new file mode 100644 index 0000000..e4f5cc1 --- /dev/null +++ b/lib/widgets/channel/channel_member.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.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'; +import 'package:solian/widgets/account/account_avatar.dart'; +import 'package:solian/widgets/account/friend_select.dart'; + +class ChannelMemberListPopup extends StatefulWidget { + final Channel channel; + final String realm; + + const ChannelMemberListPopup({ + super.key, + required this.channel, + required this.realm, + }); + + @override + State createState() => _ChannelMemberListPopupState(); +} + +class _ChannelMemberListPopupState extends State { + bool _isBusy = true; + int? _accountId; + + List _members = List.empty(); + + void getProfile() async { + final AuthProvider auth = Get.find(); + if (!await auth.isAuthorized) return; + + final prof = await auth.getProfile(); + setState(() => _accountId = prof.body['id']); + } + + void getMembers() async { + setState(() => _isBusy = true); + + final client = GetConnect(); + client.httpClient.baseUrl = ServiceFinder.services['messaging']; + + final resp = await client + .get('/api/channels/${widget.realm}/${widget.channel.alias}/members'); + if (resp.statusCode == 200) { + setState(() { + _members = resp.body + .map((x) => ChannelMember.fromJson(x)) + .toList() + .cast(); + }); + } else { + context.showErrorDialog(resp.bodyString); + } + + setState(() => _isBusy = false); + } + + void promptAddMember() async { + final input = await showModalBottomSheet( + context: context, + builder: (context) { + return FriendSelect(title: 'channelMembersAdd'.tr); + }, + ); + if (input == null) return; + + addMember(input.name); + } + + void addMember(String username) 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.post( + '/api/channels/${widget.realm}/${widget.channel.alias}/members', + {'target': username}, + ); + if (resp.statusCode == 200) { + getMembers(); + } else { + context.showErrorDialog(resp.bodyString); + } + + setState(() => _isBusy = false); + } + + void removeMember(ChannelMember item) 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.request( + '/api/channels/${widget.realm}/${widget.channel.alias}/members', + 'delete', + body: {'target': item.account.name}, + ); + if (resp.statusCode == 200) { + getMembers(); + } else { + context.showErrorDialog(resp.bodyString); + } + + setState(() => _isBusy = false); + } + + @override + void initState() { + super.initState(); + + getProfile(); + getMembers(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: MediaQuery.of(context).size.height * 0.85, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'channelMembers'.tr, + style: Theme.of(context).textTheme.headlineSmall, + ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), + if (_isBusy) const LinearProgressIndicator().animate().scaleX(), + ListTile( + tileColor: Theme.of(context).colorScheme.surfaceContainerHigh, + contentPadding: const EdgeInsets.symmetric(horizontal: 20), + leading: const Icon(Icons.person_add), + title: Text('channelMembersAdd'.tr), + subtitle: Text( + 'channelMembersAddHint' + .trParams({'channel': '#${widget.channel.alias}'}), + ), + onTap: () => promptAddMember(), + ), + Expanded( + child: ListView.builder( + itemCount: _members.length, + itemBuilder: (context, index) { + var element = _members[index]; + return ListTile( + title: Text(element.account.nick), + subtitle: Text(element.account.name), + leading: AccountAvatar(content: element.account.avatar), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const IconButton( + color: Colors.teal, + icon: Icon(Icons.admin_panel_settings), + onPressed: null, + ), + IconButton( + color: Colors.red, + icon: const Icon(Icons.remove_circle), + onPressed: element.account.externalId == _accountId + ? null + : () => removeMember(element), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +}