From b3254e0f2f42f0053085b0244311ead310c5a702 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 11 Feb 2025 21:31:53 +0800 Subject: [PATCH] :sparkles: Realm discovery --- assets/translations/en-US.json | 8 +- assets/translations/zh-CN.json | 8 +- assets/translations/zh-HK.json | 10 +- assets/translations/zh-TW.json | 10 +- lib/router.dart | 6 + lib/screens/realm.dart | 6 + lib/screens/realm/realm_discovery.dart | 291 +++++++++++++++++++++++ lib/widgets/navigation/app_scaffold.dart | 1 + 8 files changed, 336 insertions(+), 4 deletions(-) create mode 100644 lib/screens/realm/realm_discovery.dart diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 040de1d..5aa6d0c 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -27,6 +27,7 @@ "screenChatNew": "New Channel", "screenRealm": "Realm", "screenRealmManage": "Edit Realm", + "screenRealmDiscovery": "Realm Discovery", "screenRealmNew": "New Realm", "screenNotification": "Notification", "screenPostSearch": "Search Posts", @@ -619,5 +620,10 @@ "postQuestionAnswered": "Answered Question", "postQuestionAnswerSelect": "Select as Answer", "postQuestionAnswerSelected": "Answer has been selected, reward has been applied.", - "postVideoUpload": "Upload Video" + "postVideoUpload": "Upload Video", + "realmJoin": "Join Realm", + "realmCommunityHint": "This realm is a community realm, you can freely join.", + "realmCommunityPublicChannelsHint": "The public channels in this realm", + "realmJoined": "Joined realm {}.", + "join": "Join" } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index 3acb3df..d1ecb7b 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -25,6 +25,7 @@ "screenChatNew": "新建聊天频道", "screenRealm": "领域", "screenRealmManage": "编辑领域", + "screenRealmDiscovery": "发现领域", "screenRealmNew": "新建领域", "screenNotification": "通知", "screenPostSearch": "搜索帖子", @@ -618,5 +619,10 @@ "postQuestionAnswerTitle": "精选解答", "postQuestionAnswerSelect": "选择解答", "postQuestionAnswerSelected": "解答已选择,奖励已发放。", - "postVideoUpload": "上传视频" + "postVideoUpload": "上传视频", + "realmJoin": "加入领域", + "realmCommunityHint": "该领域是一个社区领域,你可以自由加入。", + "realmCommunityPublicChannelsHint": "该领域包含的公共频道", + "realmJoined": "已加入领域 {}。", + "join": "加入" } diff --git a/assets/translations/zh-HK.json b/assets/translations/zh-HK.json index 84d1e90..b0eddae 100644 --- a/assets/translations/zh-HK.json +++ b/assets/translations/zh-HK.json @@ -25,6 +25,7 @@ "screenChatNew": "新建聊天頻道", "screenRealm": "領域", "screenRealmManage": "編輯領域", + "screenRealmDiscovery": "發現領域", "screenRealmNew": "新建領域", "screenNotification": "通知", "screenPostSearch": "搜索帖子", @@ -139,6 +140,7 @@ "writePostTypeStory": "發動態", "writePostTypeArticle": "寫文章", "writePostTypeQuestion": "提問題", + "writePostTypeVideo": "發視頻", "fieldPostPublisher": "帖子發佈者", "fieldPostContent": "發生什麼事了?!", "fieldPostTitle": "標題", @@ -616,5 +618,11 @@ "postQuestionAnswered": "已解答的問題", "postQuestionAnswerTitle": "精選解答", "postQuestionAnswerSelect": "選擇解答", - "postQuestionAnswerSelected": "解答已選擇,獎勵已發放。" + "postQuestionAnswerSelected": "解答已選擇,獎勵已發放。", + "postVideoUpload": "上傳視頻", + "realmJoin": "加入領域", + "realmCommunityHint": "該領域是一個社區領域,你可以自由加入。", + "realmCommunityPublicChannelsHint": "該領域包含的公共頻道", + "realmJoined": "已加入領域 {}。", + "join": "加入" } diff --git a/assets/translations/zh-TW.json b/assets/translations/zh-TW.json index c2798e8..7f728e6 100644 --- a/assets/translations/zh-TW.json +++ b/assets/translations/zh-TW.json @@ -25,6 +25,7 @@ "screenChatNew": "新建聊天頻道", "screenRealm": "領域", "screenRealmManage": "編輯領域", + "screenRealmDiscovery": "發現領域", "screenRealmNew": "新建領域", "screenNotification": "通知", "screenPostSearch": "搜索帖子", @@ -139,6 +140,7 @@ "writePostTypeStory": "發動態", "writePostTypeArticle": "寫文章", "writePostTypeQuestion": "提問題", + "writePostTypeVideo": "發視頻", "fieldPostPublisher": "帖子發佈者", "fieldPostContent": "發生什麼事了?!", "fieldPostTitle": "標題", @@ -616,5 +618,11 @@ "postQuestionAnswered": "已解答的問題", "postQuestionAnswerTitle": "精選解答", "postQuestionAnswerSelect": "選擇解答", - "postQuestionAnswerSelected": "解答已選擇,獎勵已發放。" + "postQuestionAnswerSelected": "解答已選擇,獎勵已發放。", + "postVideoUpload": "上傳視頻", + "realmJoin": "加入領域", + "realmCommunityHint": "該領域是一個社區領域,你可以自由加入。", + "realmCommunityPublicChannelsHint": "該領域包含的公共頻道", + "realmJoined": "已加入領域 {}。", + "join": "加入" } diff --git a/lib/router.dart b/lib/router.dart index c90d89a..276b4c4 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -31,6 +31,7 @@ import 'package:surface/screens/post/post_search.dart'; import 'package:surface/screens/realm.dart'; import 'package:surface/screens/realm/manage.dart'; import 'package:surface/screens/realm/realm_detail.dart'; +import 'package:surface/screens/realm/realm_discovery.dart'; import 'package:surface/screens/settings.dart'; import 'package:surface/screens/sharing.dart'; import 'package:surface/screens/wallet.dart'; @@ -199,6 +200,11 @@ final _appRoutes = [ editingRealmAlias: state.uri.queryParameters['editing'], ), ), + GoRoute( + path: '/discovery', + name: 'realmDiscovery', + builder: (context, state) => const RealmDiscoveryScreen(), + ), GoRoute( path: '/:alias', name: 'realmDetail', diff --git a/lib/screens/realm.dart b/lib/screens/realm.dart index 7346547..f8fd16a 100644 --- a/lib/screens/realm.dart +++ b/lib/screens/realm.dart @@ -100,6 +100,12 @@ class _RealmScreenState extends State { leading: AutoAppBarLeading(), title: Text('screenRealm').tr(), actions: [ + IconButton( + icon: const Icon(Symbols.globe), + onPressed: () { + GoRouter.of(context).pushNamed('realmDiscovery'); + }, + ), IconButton( icon: !_isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module), onPressed: () { diff --git a/lib/screens/realm/realm_discovery.dart b/lib/screens/realm/realm_discovery.dart new file mode 100644 index 0000000..49eb486 --- /dev/null +++ b/lib/screens/realm/realm_discovery.dart @@ -0,0 +1,291 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:surface/providers/sn_network.dart'; +import 'package:surface/providers/userinfo.dart'; +import 'package:surface/types/chat.dart'; +import 'package:surface/types/realm.dart'; +import 'package:surface/widgets/account/account_image.dart'; +import 'package:surface/widgets/dialog.dart'; +import 'package:surface/widgets/loading_indicator.dart'; +import 'package:surface/widgets/navigation/app_scaffold.dart'; +import 'package:surface/widgets/universal_image.dart'; + +class RealmDiscoveryScreen extends StatefulWidget { + const RealmDiscoveryScreen({super.key}); + + @override + State createState() => _RealmDiscoveryScreenState(); +} + +class _RealmDiscoveryScreenState extends State { + List? _realms; + bool _isBusy = false; + + Future _fetchRealms() async { + try { + setState(() => _isBusy = true); + final sn = context.read(); + final resp = await sn.client.get('/cgi/id/realms'); + _realms = List.from( + resp.data?.map((e) => SnRealm.fromJson(e)) ?? [], + ); + } catch (err) { + if (mounted) context.showErrorDialog(err); + rethrow; + } finally { + setState(() => _isBusy = false); + } + } + + @override + void initState() { + super.initState(); + _fetchRealms(); + } + + @override + Widget build(BuildContext context) { + final sn = context.read(); + + return AppScaffold( + appBar: AppBar( + title: Text('screenRealmDiscovery').tr(), + ), + body: Column( + children: [ + LoadingIndicator(isActive: _isBusy), + Expanded( + child: RefreshIndicator( + onRefresh: _fetchRealms, + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: _realms?.length ?? 0, + itemBuilder: (context, idx) { + final realm = _realms![idx]; + return Container( + constraints: BoxConstraints(maxWidth: 640), + child: Card( + margin: const EdgeInsets.all(12), + child: InkWell( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 16 / 7, + child: Stack( + clipBehavior: Clip.none, + fit: StackFit.expand, + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: Container( + color: Theme.of(context).colorScheme.surfaceContainer, + child: (realm.banner?.isEmpty ?? true) + ? const SizedBox.shrink() + : AutoResizeUniversalImage( + sn.getAttachmentUrl(realm.banner!), + fit: BoxFit.cover, + ), + ), + ), + Positioned( + bottom: -30, + left: 18, + child: AccountImage( + content: realm.avatar, + radius: 24, + fallbackWidget: const Icon(Symbols.group, size: 24), + ), + ), + ], + ), + ), + const Gap(20 + 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(realm.name).textStyle(Theme.of(context).textTheme.titleMedium!), + Text(realm.description).textStyle(Theme.of(context).textTheme.bodySmall!), + ], + ).padding(horizontal: 24, bottom: 14), + ], + ), + onTap: () { + showModalBottomSheet( + context: context, + builder: (context) => _RealmJoinPopup(realm: realm), + ); + }, + ), + ), + ).center(); + }, + ), + ), + ), + ], + ), + ); + } +} + +class _RealmJoinPopup extends StatefulWidget { + final SnRealm realm; + + const _RealmJoinPopup({required this.realm}); + + @override + State<_RealmJoinPopup> createState() => _RealmJoinPopupState(); +} + +class _RealmJoinPopupState extends State<_RealmJoinPopup> { + final List _planJoinChannels = List.empty(growable: true); + + List? _channels; + bool _isBusy = false; + bool _isJoining = false; + + Future _fetchPublicChannels() async { + try { + setState(() => _isBusy = true); + final sn = context.read(); + final resp = await sn.client.get('/cgi/im/channels/${widget.realm.alias}'); + final out = List.from( + resp.data.map((e) => SnChannel.fromJson(e)).cast(), + ); + setState(() => _channels = out); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } finally { + setState(() => _isBusy = false); + } + } + + Future _joinRealm() async { + try { + setState(() => _isJoining = true); + final sn = context.read(); + final ua = context.read(); + await sn.client.post('/cgi/id/realms/${widget.realm.alias}/members', data: { + 'related': ua.user?.name, + }); + await _joinSelectedChannels(); + if (!mounted) return; + context.showSnackbar('realmJoined'.tr(args: [widget.realm.name])); + Navigator.pop(context); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } finally { + setState(() => _isJoining = false); + } + } + + Future _joinSelectedChannels() async { + if (_planJoinChannels.isEmpty) return; + for (final channel in _planJoinChannels) { + try { + final sn = context.read(); + final ua = context.read(); + await sn.client.post('/cgi/im/channels/${widget.realm.alias}/$channel/members', data: { + 'related': ua.user?.name, + }); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } + } + } + + @override + void initState() { + super.initState(); + _fetchPublicChannels(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(Symbols.group_add, size: 24), + const Gap(16), + Text('realmJoin', style: Theme.of(context).textTheme.titleLarge).tr(), + ], + ).padding(horizontal: 20, top: 16, bottom: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.realm.name, + style: Theme.of(context).textTheme.titleMedium, + ), + Text( + widget.realm.description, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ElevatedButton( + onPressed: _isJoining ? null : () => _joinRealm(), + child: Text('join'.tr()), + ), + ], + ).padding(horizontal: 24, bottom: 12), + const Divider(height: 1), + LoadingIndicator(isActive: _isBusy), + Container( + width: double.infinity, + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: Text('realmCommunityPublicChannelsHint'.tr(), style: Theme.of(context).textTheme.bodyMedium) + .padding(horizontal: 24, vertical: 8), + ), + Expanded( + child: ListView.builder( + itemCount: _channels?.length ?? 0, + itemBuilder: (context, index) { + final channel = _channels![index]; + return CheckboxListTile( + value: _planJoinChannels.contains(channel.alias) ?? false, + title: Text(channel.name), + subtitle: Text( + channel.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + secondary: AccountImage( + content: null, + fallbackWidget: const Icon(Symbols.chat, size: 20), + ), + onChanged: (value) { + value ??= false; + if (value) { + setState(() => _planJoinChannels.add(channel.alias)); + } else { + setState(() => _planJoinChannels.remove(channel.alias)); + } + }, + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/navigation/app_scaffold.dart b/lib/widgets/navigation/app_scaffold.dart index 4bf00ec..3e38a8a 100644 --- a/lib/widgets/navigation/app_scaffold.dart +++ b/lib/widgets/navigation/app_scaffold.dart @@ -58,6 +58,7 @@ class AppScaffold extends StatelessWidget { backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: SizedBox.expand( child: AppBackground( + isRoot: true, child: Column( children: [ IgnorePointer(child: SizedBox(height: appBar != null ? appBarHeight + safeTop : 0)),