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'; 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/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'; import 'package:surface/widgets/post/post_item.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; const Map kCategoryIcons = { 'technology': Symbols.tools_wrench, 'gaming': Symbols.gamepad, 'life': Symbols.nightlife, 'arts': Symbols.format_paint, 'sports': Symbols.sports_soccer, 'music': Symbols.music_note, 'news': Symbols.newspaper, 'knowledge': Symbols.library_books, 'literature': Symbols.book, 'funny': Symbols.attractions, }; class ExploreScreen extends StatefulWidget { const ExploreScreen({super.key}); @override State createState() => _ExploreScreenState(); } // You know what? I'm not going to make this a global variable. // Cuz the global key make the selected category not update to child widget when the category is changed. 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>()); final List _categories = List.empty(growable: true); Future _fetchCategories() async { _categories.clear(); try { final sn = context.read(); final resp = await sn.client.get('/cgi/co/categories?take=100'); setState(() { _categories.addAll(resp.data .map((e) => SnPostCategory.fromJson(e)) .cast() ?? []); }); } catch (err) { if (mounted) context.showErrorDialog(err); } } void _clearFilter() { _selectedCategory = null; } @override void initState() { _fetchCategories(); super.initState(); } @override void dispose() { _tabController.dispose(); super.dispose(); } Future refreshPosts() async { await _listKeys[_tabController.index].currentState?.refreshPosts(); } @override Widget build(BuildContext context) { return AppScaffold( floatingActionButtonLocation: ExpandableFab.location, floatingActionButton: ExpandableFab( key: _fabKey, distance: 75, type: ExpandableFabType.up, childrenAnimation: ExpandableFabAnimation.none, overlayStyle: ExpandableFabOverlayStyle( color: Theme.of(context) .colorScheme .surface .withAlpha((255 * 0.5).round()), ), openButtonBuilder: RotateFloatingActionButtonBuilder( child: const Icon(Symbols.add, size: 28), fabSize: ExpandableFabSize.regular, foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor, backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor, shape: const CircleBorder(), ), closeButtonBuilder: DefaultFloatingActionButtonBuilder( child: const Icon(Symbols.close, size: 28), fabSize: ExpandableFabSize.regular, foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor, backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor, shape: const CircleBorder(), ), children: [ Row( children: [ Text('writePostTypeStory').tr(), const Gap(20), FloatingActionButton( heroTag: null, tooltip: 'writePostTypeStory'.tr(), onPressed: () { GoRouter.of(context).pushNamed('postEditor', pathParameters: { 'mode': 'stories', }).then((value) { if (value == true) { refreshPosts(); } }); _fabKey.currentState!.toggle(); }, child: const Icon(Symbols.post_rounded), ), ], ), Row( children: [ Text('writePostTypeArticle').tr(), const Gap(20), FloatingActionButton( heroTag: null, tooltip: 'writePostTypeArticle'.tr(), onPressed: () { GoRouter.of(context).pushNamed('postEditor', pathParameters: { 'mode': 'articles', }).then((value) { if (value == true) { refreshPosts(); } }); _fabKey.currentState!.toggle(); }, child: const Icon(Symbols.news), ), ], ), Row( children: [ Text('writePostTypeQuestion').tr(), const Gap(20), FloatingActionButton( heroTag: null, tooltip: 'writePostTypeQuestion'.tr(), onPressed: () { GoRouter.of(context).pushNamed('postEditor', pathParameters: { 'mode': 'questions', }).then((value) { if (value == true) { refreshPosts(); } }); _fabKey.currentState!.toggle(); }, child: const Icon(Symbols.question_answer), ), ], ), Row( children: [ Text('writePostTypeVideo').tr(), const Gap(20), FloatingActionButton( heroTag: null, tooltip: 'writePostTypeVideo'.tr(), onPressed: () { GoRouter.of(context).pushNamed('postEditor', pathParameters: { 'mode': 'videos', }).then((value) { if (value == true) { refreshPosts(); } }); _fabKey.currentState!.toggle(); }, child: const Icon(Symbols.video_call), ), ], ), ], ), 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), Flexible( child: Text( 'postChannelGlobal', maxLines: 1, ).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), Flexible( child: Text( 'postChannelFriends', maxLines: 1, textAlign: TextAlign.center, ).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), Flexible( child: Text( 'postChannelFollowing', maxLines: 1, ).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), Flexible( child: Text( 'postChannelRealm', maxLines: 1, ).tr().textColor( Theme.of(context).appBarTheme.foregroundColor), ), ], ), ), ], ), ), ), ]; }, 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, ), ], ), ), ); } } 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), Expanded( child: GridView.count( crossAxisCount: 4, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), childAspectRatio: 1, children: categories .map( (ele) => InkWell( onTap: () { _selectedCategory = ele; Navigator.pop(context, ele); }, child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Icon( kCategoryIcons[ele.alias] ?? Symbols.question_mark, color: selected == ele ? Theme.of(context).colorScheme.primary : null, ), const Gap(4), Text( 'postCategory${ele.alias.capitalize()}'.trExists() ? 'postCategory${ele.alias.capitalize()}'.tr() : ele.name, ) .textStyle(Theme.of(context).textTheme.titleMedium!) .textColor(selected == ele ? Theme.of(context).colorScheme.primary : null), ], ), ), ) .toList(), ), ), ], ); } }