import 'dart:math' show max; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:lemmy_api_client/v3.dart'; import '../hooks/infinite_scroll.dart'; import '../hooks/memo_future.dart'; import '../hooks/stores.dart'; import '../l10n/l10n.dart'; import '../stores/config_store.dart'; import '../util/goto.dart'; import '../widgets/bottom_modal.dart'; import '../widgets/cached_network_image.dart'; import '../widgets/infinite_scroll.dart'; import '../widgets/sortable_infinite_list.dart'; import 'inbox.dart'; import 'settings/add_account_page.dart'; /// First thing users sees when opening the app /// Shows list of posts from all or just specific instances class HomeTab extends HookWidget { const HomeTab(); @override Widget build(BuildContext context) { final accStore = useAccountsStore(); final defaultListingType = useStore((ConfigStore store) => store.defaultListingType); final selectedList = useState(_SelectedList( listingType: accStore.hasNoAccount && defaultListingType == PostListingType.subscribed ? PostListingType.all : defaultListingType)); final isc = useInfiniteScrollController(); final theme = Theme.of(context); final instancesIcons = useMemoFuture(() async { final sites = await Future.wait(accStore.instances.map((e) => LemmyApiV3(e) .run(const GetSite()) .catchError((e) => null))); return { for (final site in sites) if (site != null) site.instanceHost: site.siteView?.site.icon }; }); // if the current SelectedList points to something that no longer exists // switch it to something else // cases include: // - listingType == subscribed on an instance that has no longer a logged in account // - instanceHost of a removed instance useEffect(() { if ((selectedList.value.instanceHost == null || accStore.isAnonymousFor(selectedList.value.instanceHost!)) && selectedList.value.listingType == PostListingType.subscribed || !accStore.instances.contains(selectedList.value.instanceHost)) { selectedList.value = _SelectedList( listingType: accStore.hasNoAccount && defaultListingType == PostListingType.subscribed ? PostListingType.all : defaultListingType, ); } return null; }, [ selectedList.value.instanceHost == null || accStore.isAnonymousFor(selectedList.value.instanceHost!), accStore.hasNoAccount, accStore.instances.length, ]); handleListChange() async { final val = await showBottomModal<_SelectedList>( context: context, builder: (context) { pop(_SelectedList thing) => Navigator.of(context).pop(thing); return Column( children: [ const SizedBox(height: 5), const ListTile( title: Text('EVERYTHING'), dense: true, contentPadding: EdgeInsets.zero, visualDensity: VisualDensity(vertical: VisualDensity.minimumDensity), leading: SizedBox.shrink(), ), ListTile( title: Text( L10n.of(context).subscribed, style: TextStyle( color: accStore.hasNoAccount ? theme.textTheme.bodyText1?.color?.withOpacity(0.4) : null, ), ), onTap: accStore.hasNoAccount ? null : () => pop( const _SelectedList( listingType: PostListingType.subscribed, ), ), leading: const SizedBox(width: 20), ), for (final listingType in [ PostListingType.local, PostListingType.all, ]) ListTile( title: Text(listingType.value), leading: const SizedBox(width: 20, height: 20), onTap: () => pop(_SelectedList(listingType: listingType)), ), for (final instance in accStore.instances) ...[ const Padding( padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(), ), ListTile( title: Text( instance.toUpperCase(), style: TextStyle( color: theme.textTheme.bodyText1?.color?.withOpacity(0.7)), ), onTap: () => goToInstance(context, instance), dense: true, contentPadding: EdgeInsets.zero, visualDensity: const VisualDensity( vertical: VisualDensity.minimumDensity), leading: (instancesIcons.hasData && instancesIcons.data![instance] != null) ? Padding( padding: const EdgeInsets.only(left: 20), child: SizedBox( width: 25, height: 25, child: CachedNetworkImage( imageUrl: instancesIcons.data![instance]!, height: 25, width: 25, ), ), ) : const SizedBox(width: 30), ), ListTile( title: Text( L10n.of(context).subscribed, style: TextStyle( color: accStore.isAnonymousFor(instance) ? theme.textTheme.bodyText1?.color?.withOpacity(0.4) : null), ), onTap: accStore.isAnonymousFor(instance) ? () => Navigator.of(context) .push(AddAccountPage.route(instance)) : () => pop(_SelectedList( listingType: PostListingType.subscribed, instanceHost: instance, )), leading: const SizedBox(width: 20), ), ListTile( title: Text(L10n.of(context).local), onTap: () => pop(_SelectedList( listingType: PostListingType.local, instanceHost: instance, )), leading: const SizedBox(width: 20), ), ListTile( title: Text(L10n.of(context).all), onTap: () => pop(_SelectedList( listingType: PostListingType.all, instanceHost: instance, )), leading: const SizedBox(width: 20), ), ], ], ); }, ); if (val != null) { selectedList.value = val; isc.clear(); } } final title = () { final first = selectedList.value.listingType.tr(context); final last = selectedList.value.instanceHost == null ? '' : '@${selectedList.value.instanceHost}'; return '$first$last'; }(); if (accStore.instances.isEmpty) { return Scaffold( body: Column( mainAxisAlignment: MainAxisAlignment.center, children: const [ Center(child: Text('there needs to be at least one instance')), ], ), ); } return Scaffold( // TODO: make appbar autohide when scrolling down appBar: AppBar( actions: [ IconButton( icon: const Icon(Icons.notifications), onPressed: () => goTo(context, (_) => const InboxPage()), ) ], title: TextButton( style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 15), ), onPressed: handleListChange, child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Flexible( child: Text( title, style: theme.appBarTheme.titleTextStyle, overflow: TextOverflow.fade, softWrap: false, ), ), const Icon(Icons.arrow_drop_down), ], ), ), ), body: InfiniteHomeList( controller: isc, selectedList: selectedList.value, ), ); } } /// Infinite list of posts class InfiniteHomeList extends HookWidget { final InfiniteScrollController controller; final _SelectedList selectedList; const InfiniteHomeList({ required this.selectedList, required this.controller, }); @override Widget build(BuildContext context) { final accStore = useAccountsStore(); /// fetches post from many instances at once and combines them into a single /// list /// /// Process of combining them works sort of like zip function in python Future> generalFetcher( int page, int limit, SortType sort, PostListingType listingType, ) async { assert( listingType != PostListingType.community, 'only subscribed or all'); final instances = () { if (listingType == PostListingType.subscribed) { return accStore.loggedInInstances; } return accStore.instances; }(); final futures = [ for (final instanceHost in instances) LemmyApiV3(instanceHost).run(GetPosts( type: listingType, sort: sort, page: page, limit: limit, savedOnly: false, auth: accStore.defaultUserDataFor(instanceHost)?.jwt.raw, )) ]; final instancePosts = await Future.wait(futures); final longest = instancePosts.map((e) => e.length).reduce(max); final newPosts = [ for (var i = 0; i < longest; i++) for (final posts in instancePosts) if (i < posts.length) posts[i] ]; return newPosts; } FetcherWithSorting fetcherFromInstance( String instanceHost, PostListingType listingType) => (page, batchSize, sort) => LemmyApiV3(instanceHost).run(GetPosts( type: listingType, sort: sort, page: page, limit: batchSize, savedOnly: false, auth: accStore.defaultUserDataFor(instanceHost)?.jwt.raw, )); return InfinitePostList( fetcher: selectedList.instanceHost == null ? (page, limit, sort) => generalFetcher(page, limit, sort, selectedList.listingType) : fetcherFromInstance( selectedList.instanceHost!, selectedList.listingType), controller: controller, ); } } class _SelectedList { /// when null it implies the 'EVERYTHING' mode final String? instanceHost; final PostListingType listingType; const _SelectedList({ required this.listingType, this.instanceHost, }); @override String toString() => 'SelectedList(instanceHost: $instanceHost, listingType: $listingType)'; }