diff --git a/lib/screens/channel/channel_chat.dart b/lib/screens/channel/channel_chat.dart index 0be1b76..2fa89f6 100644 --- a/lib/screens/channel/channel_chat.dart +++ b/lib/screens/channel/channel_chat.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; import 'package:get/get.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:solian/exts.dart'; @@ -221,8 +220,6 @@ class _ChannelChatScreenState extends State { clipBehavior: Clip.none, pagingController: _pagingController, builderDelegate: PagedChildBuilderDelegate( - animateTransitions: true, - transitionDuration: 350.ms, itemBuilder: chatHistoryBuilder, noItemsFoundIndicatorBuilder: (_) => Container(), ), diff --git a/lib/screens/channel/channel_detail.dart b/lib/screens/channel/channel_detail.dart index 29960b8..be8a6f7 100644 --- a/lib/screens/channel/channel_detail.dart +++ b/lib/screens/channel/channel_detail.dart @@ -78,8 +78,6 @@ class _ChannelDetailScreenState extends State { .pushNamed( 'channelOrganizing', extra: ChannelOrganizeArguments(edit: widget.channel), - queryParameters: - widget.realm != 'global' ? {'realm': widget.realm} : {}, ) .then((resp) { if (resp != null) { diff --git a/lib/screens/realms/realm_detail.dart b/lib/screens/realms/realm_detail.dart index 50f7bf1..8fdc3b6 100644 --- a/lib/screens/realms/realm_detail.dart +++ b/lib/screens/realms/realm_detail.dart @@ -1,7 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:get/get.dart'; import 'package:solian/models/realm.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/router.dart'; +import 'package:solian/screens/realms/realm_organize.dart'; +import 'package:solian/widgets/realms/realm_deletion.dart'; +import 'package:solian/widgets/realms/realm_member.dart'; -class RealmDetailScreen extends StatelessWidget { +class RealmDetailScreen extends StatefulWidget { final String alias; final Realm realm; @@ -11,8 +17,132 @@ class RealmDetailScreen extends StatelessWidget { required this.realm, }); + @override + State createState() => _RealmDetailScreenState(); +} + +class _RealmDetailScreenState extends State { + bool _isOwned = false; + + void checkOwner() async { + final AuthProvider auth = Get.find(); + final prof = await auth.getProfile(); + setState(() { + _isOwned = prof.body['id'] == widget.realm.accountId; + }); + } + + void showMemberList() { + showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + builder: (context) => RealmMemberListPopup( + realm: widget.realm, + ), + ); + } + + void promptLeaveChannel() async { + final did = await showDialog( + context: context, + builder: (context) => RealmDeletion( + 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) { - throw UnimplementedError(); + final ownerActions = [ + ListTile( + leading: const Icon(Icons.edit), + trailing: const Icon(Icons.chevron_right), + title: Text('realmAdjust'.tr), + onTap: () async { + AppRouter.instance + .pushNamed( + 'realmOrganizing', + extra: RealmOrganizeArguments(edit: 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: [ + const CircleAvatar( + radius: 28, + backgroundColor: Colors.teal, + child: Icon(Icons.group, color: Colors.white), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.realm.name, + style: Theme.of(context).textTheme.bodyLarge), + Text(widget.realm.description, + style: Theme.of(context).textTheme.bodySmall), + Text( + '#${widget.realm.id.toString().padLeft(8, '0')} · ${widget.realm.alias}', + style: const TextStyle(fontSize: 11), + ), + ], + ), + ) + ], + ), + ), + const Divider(thickness: 0.3), + Expanded( + child: ListView( + children: [ + ListTile( + leading: const Icon(Icons.settings), + trailing: const Icon(Icons.chevron_right), + title: Text('realmSettings'.tr), + ), + ListTile( + leading: const Icon(Icons.supervisor_account), + trailing: const Icon(Icons.chevron_right), + title: Text('realmMembers'.tr), + onTap: () => showMemberList(), + ), + ...(_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/realms/realm_organize.dart b/lib/screens/realms/realm_organize.dart index 8bd0e01..a622745 100644 --- a/lib/screens/realms/realm_organize.dart +++ b/lib/screens/realms/realm_organize.dart @@ -129,7 +129,7 @@ class _RealmOrganizeScreenState extends State { child: Text('cancel'.tr), ), ], - ), + ).paddingOnly(bottom: 6), Row( children: [ Expanded( diff --git a/lib/translations.dart b/lib/translations.dart index 5db7692..ab1de27 100644 --- a/lib/translations.dart +++ b/lib/translations.dart @@ -108,6 +108,16 @@ class SolianMessages extends Translations { 'realmPublic': 'Public Realm', 'realmCommunity': 'Community Realm', 'realmDetail': 'Realm detail', + 'realmMember': 'Realm member', + 'realmMembers': 'Realm members', + 'realmMembersAdd': 'Add realm members', + 'realmMembersAddHint': 'Into @realm', + 'realmAdjust': 'Realm Adjustment', + 'realmSettings': 'Realm settings', + 'realmEditingNotify': 'You\'re editing realm @realm', + 'realmDeletionConfirm': 'Confirm realm deletion', + 'realmDeletionConfirmCaption': + 'Are you sure to delete realm @realm? This action cannot be undone!', 'channelNew': 'Create a new channel', 'channelNewInRealmHint': 'Create channel in realm @realm', 'channelOrganizing': 'Organize a channel', @@ -130,7 +140,7 @@ class SolianMessages extends Translations { 'channelTypeDirect': 'DM', 'channelAdjust': 'Channel Adjustment', 'channelDetail': 'Channel Detail', - 'channelSettings': 'Channel settings', + 'channelSettings': 'Channel Settings', 'channelDeletionConfirm': 'Confirm channel deletion', 'channelDeletionConfirmCaption': 'Are you sure to delete channel @channel? This action cannot be undone!', @@ -236,6 +246,15 @@ class SolianMessages extends Translations { 'realmPublic': '公开领域', 'realmCommunity': '社区领域', 'realmDetail': '领域详情', + 'realmMember': '领域成员', + 'realmMembers': '领域成员', + 'realmMembersAdd': '添加领域成员', + 'realmMembersAddHint': '到 @realm', + 'realmAdjust': '调整领域', + 'realmSettings': '领域设置', + 'realmEditingNotify': '你正在编辑领域 @realm', + 'realmDeletionConfirm': '确认删除领域', + 'realmDeletionConfirmCaption': '你确定要删除领域 @realm 嘛?该操作不可撤销。', 'channelNew': '创建新频道', 'channelNewInRealmHint': '在领域 @realm 里创建新频道', 'channelOrganizing': '组织频道', diff --git a/lib/widgets/chat/chat_message.dart b/lib/widgets/chat/chat_message.dart index 430ef06..ec1c2c2 100644 --- a/lib/widgets/chat/chat_message.dart +++ b/lib/widgets/chat/chat_message.dart @@ -12,11 +12,13 @@ class ChatMessage extends StatelessWidget { final Message item; final bool isCompact; final bool isMerged; + final bool isHasMerged; const ChatMessage({ super.key, required this.item, this.isMerged = false, + this.isHasMerged = false, this.isCompact = false, }); @@ -28,7 +30,6 @@ class ChatMessage extends StatelessWidget { text = content['value']; default: throw Exception('Unsupported algorithm'); - // TODO Impl AES algorithm } } @@ -94,7 +95,7 @@ class ChatMessage extends StatelessWidget { Widget build(BuildContext context) { Widget widget; if (isMerged) { - widget = buildContent().paddingOnly(left: 40); + widget = buildContent().paddingOnly(left: 52); } else if (isCompact) { widget = Row( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/widgets/chat/chat_message_input.dart b/lib/widgets/chat/chat_message_input.dart index 9671d36..3d56baa 100644 --- a/lib/widgets/chat/chat_message_input.dart +++ b/lib/widgets/chat/chat_message_input.dart @@ -47,8 +47,6 @@ class _ChatMessageInputState extends State { } Map encodeMessage(String content) { - // TODO Impl E2EE - return { 'value': content, 'keypair_id': null, diff --git a/lib/widgets/realms/realm_deletion.dart b/lib/widgets/realms/realm_deletion.dart new file mode 100644 index 0000000..de90f45 --- /dev/null +++ b/lib/widgets/realms/realm_deletion.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:solian/exts.dart'; +import 'package:solian/models/realm.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/services.dart'; + +class RealmDeletion extends StatefulWidget { + final Realm realm; + final bool isOwned; + + const RealmDeletion({ + super.key, + required this.realm, + required this.isOwned, + }); + + @override + State createState() => _RealmDeletionState(); +} + +class _RealmDeletionState 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['passport']; + client.httpClient.addAuthenticator(auth.requestAuthenticator); + + final resp = await client.delete('/api/realms/${widget.realm.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['passport']; + client.httpClient.addAuthenticator(auth.requestAuthenticator); + + final resp = + await client.delete('/api/realms/${widget.realm.id}/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('realmDeletionConfirm'.tr), + content: Text( + 'realmDeletionConfirmCaption' + .trParams({'realm': '#${widget.realm.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), + ), + ], + ); + } +} diff --git a/lib/widgets/realms/realm_member.dart b/lib/widgets/realms/realm_member.dart new file mode 100644 index 0000000..64c5c29 --- /dev/null +++ b/lib/widgets/realms/realm_member.dart @@ -0,0 +1,182 @@ +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/realm.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 RealmMemberListPopup extends StatefulWidget { + final Realm realm; + + const RealmMemberListPopup({ + super.key, + required this.realm, + }); + + @override + State createState() => _RealmMemberListPopupState(); +} + +class _RealmMemberListPopupState 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['passport']; + + final resp = await client.get('/api/realms/${widget.realm.alias}/members'); + if (resp.statusCode == 200) { + setState(() { + _members = resp.body + .map((x) => RealmMember.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['passport']; + client.httpClient.addAuthenticator(auth.requestAuthenticator); + + final resp = await client.post( + '/api/realms/${widget.realm.alias}/members', + {'target': username}, + ); + if (resp.statusCode == 200) { + getMembers(); + } else { + context.showErrorDialog(resp.bodyString); + } + + setState(() => _isBusy = false); + } + + void removeMember(RealmMember item) async { + final AuthProvider auth = Get.find(); + if (!await auth.isAuthorized) return; + + setState(() => _isBusy = true); + + final client = GetConnect(); + client.httpClient.baseUrl = ServiceFinder.services['passport']; + client.httpClient.addAuthenticator(auth.requestAuthenticator); + + final resp = await client.request( + '/api/realms/${widget.realm.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( + 'realmMembers'.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('realmMembersAdd'.tr), + subtitle: Text( + 'realmMembersAddHint' + .trParams({'realm': '#${widget.realm.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), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +}