diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 263a337..24cc89e 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -584,6 +584,7 @@ "colorSchemeBlack": "Black", "colorSchemeApplied": "Color scheme has been applied, may need restart the app to take effect.", "postFeaturedComment": "Featured Comment", + "postCategory": "Category", "postCategoryTechnology": "Technology", "postCategoryGaming": "Gaming", "postCategoryLife": "Life", @@ -675,5 +676,12 @@ "postThumbnail": "Post Thumbnail", "accountRealms": "Realms", "postInGlobal": "Global", - "postInGlobalDescription": "Do not link this post with any realm." + "postInGlobalDescription": "Do not link this post with any realm.", + "postChannelGlobal": "Global", + "postChannelFriends": "Friends", + "postChannelFollowing": "Following", + "postChannelRealm": "Realms", + "postFilterReset": "Reset Filter", + "postFilterResetDescription": "Clear filter and show all posts.", + "postFilterWithCategory": "Viewing posts in {}" } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index a75a0d6..6991c3c 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -582,6 +582,7 @@ "colorSchemeBlack": "黑色", "colorSchemeApplied": "主题色已应用,可能需要重启来生效。", "postFeaturedComment": "精选评论", + "postCategory": "分类", "postCategoryTechnology": "技术", "postCategoryGaming": "游戏", "postCategoryLife": "生活", @@ -673,5 +674,12 @@ "postThumbnail": "帖子缩略图", "accountRealms": "领域", "postInGlobal": "全站", - "postInGlobalDescription": "不关联此帖子与任何领域。" + "postInGlobalDescription": "不关联此帖子与任何领域。", + "postChannelGlobal": "全站", + "postChannelFriends": "好友", + "postChannelFollowing": "关注", + "postChannelRealm": "领域", + "postFilterReset": "重置过滤器", + "postFilterResetDescription": "清除过滤器并显示所有帖子。", + "postFilterWithCategory": "查看{}区中的帖子" } diff --git a/assets/translations/zh-HK.json b/assets/translations/zh-HK.json index 1b1a433..aee2f60 100644 --- a/assets/translations/zh-HK.json +++ b/assets/translations/zh-HK.json @@ -582,6 +582,7 @@ "colorSchemeBlack": "黑色", "colorSchemeApplied": "主題色已應用,可能需要重啓來生效。", "postFeaturedComment": "精選評論", + "postCategory": "分類", "postCategoryTechnology": "技術", "postCategoryGaming": "遊戲", "postCategoryLife": "生活", @@ -625,6 +626,7 @@ "realmJoin": "加入領域", "realmCommunityHint": "該領域是一個社區領域,你可以自由加入。", "realmCommunityPublicChannelsHint": "該領域包含的公共頻道", + "realmCommunityPublishersHint": "該領域的發佈者", "realmJoined": "已加入領域 {}。", "join": "加入", "pollEditorNew": "新投票", @@ -669,5 +671,15 @@ "attachmentBillingUploaded": "已佔用的字節數", "attachmentBillingDiscount": "免費的字節數", "attachmentBillingHint": "滑動窗口計價®\n在24小時內上傳的文件大小超出免費空間才會適用扣費。", - "postThumbnail": "帖子縮略圖" + "postThumbnail": "帖子縮略圖", + "accountRealms": "領域", + "postInGlobal": "全站", + "postInGlobalDescription": "不關聯此帖子與任何領域。", + "postChannelGlobal": "全站", + "postChannelFriends": "好友", + "postChannelFollowing": "關注", + "postChannelRealm": "領域", + "postFilterReset": "重置過濾器", + "postFilterResetDescription": "清除過濾器並顯示所有帖子。", + "postFilterWithCategory": "查看{}區中的帖子" } diff --git a/assets/translations/zh-TW.json b/assets/translations/zh-TW.json index e94e6e3..dd1a928 100644 --- a/assets/translations/zh-TW.json +++ b/assets/translations/zh-TW.json @@ -582,6 +582,7 @@ "colorSchemeBlack": "黑色", "colorSchemeApplied": "主題色已應用,可能需要重啟來生效。", "postFeaturedComment": "精選評論", + "postCategory": "分類", "postCategoryTechnology": "技術", "postCategoryGaming": "遊戲", "postCategoryLife": "生活", @@ -625,6 +626,7 @@ "realmJoin": "加入領域", "realmCommunityHint": "該領域是一個社區領域,你可以自由加入。", "realmCommunityPublicChannelsHint": "該領域包含的公共頻道", + "realmCommunityPublishersHint": "該領域的發佈者", "realmJoined": "已加入領域 {}。", "join": "加入", "pollEditorNew": "新投票", @@ -669,5 +671,15 @@ "attachmentBillingUploaded": "已佔用的字節數", "attachmentBillingDiscount": "免費的字節數", "attachmentBillingHint": "滑動窗口計價®\n在24小時內上傳的文件大小超出免費空間才會適用扣費。", - "postThumbnail": "帖子縮略圖" + "postThumbnail": "帖子縮略圖", + "accountRealms": "領域", + "postInGlobal": "全站", + "postInGlobalDescription": "不關聯此帖子與任何領域。", + "postChannelGlobal": "全站", + "postChannelFriends": "好友", + "postChannelFollowing": "關注", + "postChannelRealm": "領域", + "postFilterReset": "重置過濾器", + "postFilterResetDescription": "清除過濾器並顯示所有帖子。", + "postFilterWithCategory": "查看{}區中的帖子" } diff --git a/lib/providers/post.dart b/lib/providers/post.dart index b39f6ee..7c4589e 100644 --- a/lib/providers/post.dart +++ b/lib/providers/post.dart @@ -127,6 +127,7 @@ class SnPostContentProvider { Iterable? categories, Iterable? tags, String? realm, + String? channel, }) async { final resp = await _sn.client.get('/cgi/co/posts', queryParameters: { 'take': take, @@ -136,6 +137,7 @@ class SnPostContentProvider { if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','), if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','), if (realm != null) 'realm': realm, + if (channel != null) 'channel': channel, }); final List out = await _preloadRelatedDataInBatch( List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []), diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index cdfbef7..b16e7a2 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -1,3 +1,4 @@ +import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_expandable_fab/flutter_expandable_fab.dart'; @@ -8,7 +9,10 @@ import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/providers/post.dart'; import 'package:surface/providers/sn_network.dart'; +import 'package:surface/providers/sn_realm.dart'; import 'package:surface/types/post.dart'; +import 'package:surface/types/realm.dart'; +import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/app_bar_leading.dart'; import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart'; @@ -35,61 +39,47 @@ class ExploreScreen extends StatefulWidget { State createState() => _ExploreScreenState(); } -class _ExploreScreenState extends State { +SnPostCategory? _selectedCategory; + +class _ExploreScreenState extends State with SingleTickerProviderStateMixin { + late final TabController _tabController = TabController(length: 4, vsync: this); + final _fabKey = GlobalKey(); + final _listKeys = List.generate(4, (_) => GlobalKey<_PostListWidgetState>()); - bool _isBusy = true; - - final List _posts = List.empty(growable: true); final List _categories = List.empty(growable: true); - int? _postCount; - - String? _selectedCategory; Future _fetchCategories() async { _categories.clear(); try { final sn = context.read(); final resp = await sn.client.get('/cgi/co/categories?take=100'); - _categories.addAll(resp.data.map((e) => SnPostCategory.fromJson(e)).cast() ?? []); + setState(() { + _categories.addAll(resp.data.map((e) => SnPostCategory.fromJson(e)).cast() ?? []); + }); } catch (err) { - if (!mounted) return; - context.showErrorDialog(err); + if (mounted) context.showErrorDialog(err); } } - Future _fetchPosts() async { - if (_postCount != null && _posts.length >= _postCount!) return; - - setState(() => _isBusy = true); - - final pt = context.read(); - final result = await pt.listPosts( - take: 10, - offset: _posts.length, - categories: _selectedCategory != null ? [_selectedCategory!] : null, - ); - final out = result.$1; - - if (!mounted) return; - - _postCount = result.$2; - _posts.addAll(out); - - if (mounted) setState(() => _isBusy = false); - } - - Future _refreshPosts() { - _postCount = null; - _posts.clear(); - return _fetchPosts(); + void _clearFilter() { + _selectedCategory = null; } @override void initState() { - super.initState(); - _fetchPosts(); _fetchCategories(); + super.initState(); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + Future refreshPosts() async { + await _listKeys[_tabController.index].currentState?.refreshPosts(); } @override @@ -131,7 +121,7 @@ class _ExploreScreenState extends State { 'mode': 'stories', }).then((value) { if (value == true) { - _refreshPosts(); + refreshPosts(); } }); _fabKey.currentState!.toggle(); @@ -152,7 +142,7 @@ class _ExploreScreenState extends State { 'mode': 'articles', }).then((value) { if (value == true) { - _refreshPosts(); + refreshPosts(); } }); _fabKey.currentState!.toggle(); @@ -173,7 +163,7 @@ class _ExploreScreenState extends State { 'mode': 'questions', }).then((value) { if (value == true) { - _refreshPosts(); + refreshPosts(); } }); _fabKey.currentState!.toggle(); @@ -194,7 +184,7 @@ class _ExploreScreenState extends State { 'mode': 'videos', }).then((value) { if (value == true) { - _refreshPosts(); + refreshPosts(); } }); _fabKey.currentState!.toggle(); @@ -205,74 +195,116 @@ class _ExploreScreenState extends State { ), ], ), - body: RefreshIndicator( - displacement: 40 + MediaQuery.of(context).padding.top, - onRefresh: () => _refreshPosts(), - child: CustomScrollView( - slivers: [ - SliverAppBar( - leading: AutoAppBarLeading(), - title: Text('screenExplore').tr(), - floating: true, - snap: true, - actions: [ - IconButton( - icon: const Icon(Symbols.search), - onPressed: () { - GoRouter.of(context).pushNamed('postSearch'); - }, - ), - const Gap(8), - ], - bottom: PreferredSize( - preferredSize: const Size.fromHeight(50), - child: SizedBox( - height: 50, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.only(left: 8, right: 8, bottom: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: _categories.map((ele) { - return StyledWidget(ChoiceChip( - avatar: Icon(kCategoryIcons[ele.alias] ?? Symbols.question_mark), - label: Text( - 'postCategory${ele.alias.capitalize()}'.trExists() - ? 'postCategory${ele.alias.capitalize()}'.tr() - : ele.name, - ), - selected: _selectedCategory == ele.alias, - onSelected: (value) { - _selectedCategory = value ? ele.alias : null; - _refreshPosts(); - }, - )).padding(horizontal: 4); - }).toList(), - ), + body: NestedScrollView( + headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { + return [ + SliverOverlapAbsorber( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + sliver: SliverAppBar( + leading: AutoAppBarLeading(), + title: Text('screenExplore').tr(), + floating: true, + snap: true, + actions: [ + IconButton( + icon: const Icon(Symbols.category), + onPressed: () { + showModalBottomSheet( + context: context, + builder: (context) => _PostCategoryPickerPopup( + categories: _categories, + selected: _selectedCategory, + ), + ).then((value) { + if (value != null && context.mounted) { + _selectedCategory = value == false ? null : value; + refreshPosts(); + } + }); + }, ), + IconButton( + icon: const Icon(Symbols.search), + onPressed: () { + GoRouter.of(context).pushNamed('postSearch'); + }, + ), + const Gap(8), + ], + bottom: TabBar( + controller: _tabController, + tabs: [ + Tab( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(Symbols.globe, size: 20, color: Theme.of(context).appBarTheme.foregroundColor), + const Gap(8), + Text('postChannelGlobal').tr().textColor(Theme.of(context).appBarTheme.foregroundColor), + ], + ), + ), + Tab( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(Symbols.group, size: 20, color: Theme.of(context).appBarTheme.foregroundColor), + const Gap(8), + Text('postChannelFriends').tr().textColor(Theme.of(context).appBarTheme.foregroundColor), + ], + ), + ), + Tab( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(Symbols.subscriptions, size: 20, color: Theme.of(context).appBarTheme.foregroundColor), + const Gap(8), + Text('postChannelFollowing').tr().textColor(Theme.of(context).appBarTheme.foregroundColor), + ], + ), + ), + Tab( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(Symbols.workspaces, size: 20, color: Theme.of(context).appBarTheme.foregroundColor), + const Gap(8), + Text('postChannelRealm').tr().textColor(Theme.of(context).appBarTheme.foregroundColor), + ], + ), + ), + ], ), ), ), - const SliverGap(12), - SliverInfiniteList( - itemCount: _posts.length, - isLoading: _isBusy, - centerLoading: true, - hasReachedMax: _postCount != null && _posts.length >= _postCount!, - onFetchData: _fetchPosts, - itemBuilder: (context, idx) { - return OpenablePostItem( - data: _posts[idx], - maxWidth: 640, - onChanged: (data) { - setState(() => _posts[idx] = data); - }, - onDeleted: () { - _refreshPosts(); - }, - ); - }, - separatorBuilder: (_, __) => const Gap(8), + ]; + }, + body: TabBarView( + controller: _tabController, + children: [ + _PostListWidget( + key: _listKeys[0], + onClearFilter: _clearFilter, + ), + _PostListWidget( + key: _listKeys[1], + channel: 'friends', + onClearFilter: _clearFilter, + ), + _PostListWidget( + key: _listKeys[2], + channel: 'following', + onClearFilter: _clearFilter, + ), + _PostListWidget( + key: _listKeys[3], + withRealm: true, + onClearFilter: _clearFilter, ), ], ), @@ -280,3 +312,233 @@ class _ExploreScreenState extends State { ); } } + +class _PostListWidget extends StatefulWidget { + final String? channel; + final bool withRealm; + final Function onClearFilter; + + const _PostListWidget({super.key, this.channel, this.withRealm = false, required this.onClearFilter}); + + @override + State<_PostListWidget> createState() => _PostListWidgetState(); +} + +class _PostListWidgetState extends State<_PostListWidget> { + bool _isBusy = false; + + final List _posts = List.empty(growable: true); + final List _realms = List.empty(growable: true); + SnRealm? _selectedRealm; + int? _postCount; + + Future _fetchRealms() async { + try { + final rels = context.read(); + final out = await rels.listAvailableRealms(); + setState(() { + _realms.addAll(out); + _selectedRealm = out.firstOrNull; + }); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + rethrow; + } + } + + Future _fetchPosts() async { + if (_postCount != null && _posts.length >= _postCount!) return; + + setState(() => _isBusy = true); + + final pt = context.read(); + final result = await pt.listPosts( + take: 10, + offset: _posts.length, + categories: _selectedCategory != null ? [_selectedCategory!.alias] : null, + channel: widget.channel, + realm: _selectedRealm?.alias, + ); + final out = result.$1; + + if (!mounted) return; + + _postCount = result.$2; + _posts.addAll(out); + + if (mounted) setState(() => _isBusy = false); + } + + Future refreshPosts() { + _postCount = null; + _posts.clear(); + return _fetchPosts(); + } + + @override + void initState() { + super.initState(); + if (widget.withRealm) { + _fetchRealms().then((_) { + _fetchPosts(); + }); + } else { + _fetchPosts(); + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + if (_selectedCategory != null) + MaterialBanner( + content: Text( + 'postFilterWithCategory'.tr(args: [ + 'postCategory${_selectedCategory!.alias.capitalize()}'.trExists() + ? 'postCategory${_selectedCategory!.alias.capitalize()}'.tr() + : _selectedCategory!.name, + ]), + ), + leading: Icon(kCategoryIcons[_selectedCategory!.alias] ?? Symbols.question_mark), + actions: [ + IconButton( + icon: const Icon(Symbols.clear), + onPressed: () { + widget.onClearFilter.call(); + refreshPosts(); + }, + ), + ], + padding: const EdgeInsets.only(left: 20, right: 4), + ), + if (widget.withRealm) + DropdownButtonHideUnderline( + child: DropdownButton2( + isExpanded: true, + items: _realms + .map( + (ele) => DropdownMenuItem( + value: ele, + child: Row( + children: [ + AccountImage( + content: ele.avatar, + fallbackWidget: const Icon(Symbols.group, size: 16), + radius: 14, + ), + const Gap(8), + Text( + ele.name, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ) + .toList(), + value: _selectedRealm, + onChanged: (SnRealm? value) { + setState(() => _selectedRealm = value); + refreshPosts(); + }, + buttonStyleData: const ButtonStyleData( + padding: EdgeInsets.only(left: 4, right: 12), + ), + menuItemStyleData: const MenuItemStyleData( + height: 48, + ), + ), + ), + if (widget.withRealm) const Divider(height: 1), + Expanded( + child: MediaQuery.removePadding( + context: context, + removeTop: true, + child: RefreshIndicator( + displacement: 40 + MediaQuery.of(context).padding.top, + onRefresh: () => refreshPosts(), + child: InfiniteList( + itemCount: _posts.length, + isLoading: _isBusy, + centerLoading: true, + hasReachedMax: _postCount != null && _posts.length >= _postCount!, + onFetchData: _fetchPosts, + itemBuilder: (context, idx) { + return OpenablePostItem( + data: _posts[idx], + maxWidth: 640, + onChanged: (data) { + setState(() => _posts[idx] = data); + }, + onDeleted: () { + refreshPosts(); + }, + ); + }, + separatorBuilder: (_, __) => const Gap(8), + ), + ), + ).padding(top: 8), + ), + ], + ); + } +} + +class _PostCategoryPickerPopup extends StatelessWidget { + final List categories; + final SnPostCategory? selected; + + const _PostCategoryPickerPopup({required this.categories, this.selected}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(Symbols.category, size: 24), + const Gap(16), + Text('postCategory').tr().textStyle(Theme.of(context).textTheme.titleLarge!), + ], + ).padding(horizontal: 20, top: 16, bottom: 12), + ListTile( + leading: const Icon(Symbols.clear), + title: Text('postFilterReset').tr(), + subtitle: Text('postFilterResetDescription').tr(), + contentPadding: const EdgeInsets.symmetric(horizontal: 20), + onTap: () { + Navigator.pop(context, false); + }, + ), + const Divider(height: 1), + Wrap( + spacing: 4, + runSpacing: 4, + children: categories + .map( + (ele) => ChoiceChip( + avatar: Icon(kCategoryIcons[ele.alias] ?? Symbols.question_mark), + label: Text( + 'postCategory${ele.alias.capitalize()}'.trExists() + ? 'postCategory${ele.alias.capitalize()}'.tr() + : ele.name, + ), + selected: ele == selected, + onSelected: (value) { + if (value) { + Navigator.pop(context, ele); + } + }, + ), + ) + .toList(), + ).padding(horizontal: 12), + ], + ); + } +}