From 95d8ee7fa7f67981bfec908122a3caed2beb339e Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Thu, 25 Nov 2021 18:12:36 +0100 Subject: [PATCH] refactor community page to use mobx (#299) --- lib/pages/community.dart | 535 ------------------ lib/pages/community/community.dart | 186 ++++++ lib/pages/community/community_about_tab.dart | 127 +++++ .../community/community_follow_button.dart | 63 +++ lib/pages/community/community_more_menu.dart | 73 +++ lib/pages/community/community_overview.dart | 144 +++++ lib/pages/community/community_store.dart | 84 +++ lib/pages/community/community_store.g.dart | 32 ++ lib/pages/full_post/comment_section.dart | 2 +- lib/pages/full_post/full_post.dart | 24 +- lib/pages/instance.dart | 12 +- lib/pages/users_list.dart | 2 +- lib/url_launcher.dart | 9 +- lib/util/async_store.dart | 63 ++- lib/util/async_store.freezed.dart | 67 ++- lib/util/async_store.g.dart | 23 +- lib/util/goto.dart | 16 +- lib/widgets/failed_to_load.dart | 24 + lib/widgets/post/post_info_section.dart | 13 +- lib/widgets/user_tile.dart | 33 ++ test/util/async_store_test.dart | 102 ++++ 21 files changed, 998 insertions(+), 636 deletions(-) delete mode 100644 lib/pages/community.dart create mode 100644 lib/pages/community/community.dart create mode 100644 lib/pages/community/community_about_tab.dart create mode 100644 lib/pages/community/community_follow_button.dart create mode 100644 lib/pages/community/community_more_menu.dart create mode 100644 lib/pages/community/community_overview.dart create mode 100644 lib/pages/community/community_store.dart create mode 100644 lib/pages/community/community_store.g.dart create mode 100644 lib/widgets/failed_to_load.dart create mode 100644 lib/widgets/user_tile.dart create mode 100644 test/util/async_store_test.dart diff --git a/lib/pages/community.dart b/lib/pages/community.dart deleted file mode 100644 index afe7357..0000000 --- a/lib/pages/community.dart +++ /dev/null @@ -1,535 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:lemmy_api_client/v3.dart'; -import 'package:url_launcher/url_launcher.dart' as ul; - -import '../hooks/delayed_loading.dart'; -import '../hooks/logged_in_action.dart'; -import '../hooks/memo_future.dart'; -import '../hooks/stores.dart'; -import '../l10n/l10n.dart'; -import '../util/extensions/api.dart'; -import '../util/extensions/spaced.dart'; -import '../util/goto.dart'; -import '../util/icons.dart'; -import '../util/intl.dart'; -import '../util/share.dart'; -import '../widgets/avatar.dart'; -import '../widgets/bottom_modal.dart'; -import '../widgets/bottom_safe.dart'; -import '../widgets/cached_network_image.dart'; -import '../widgets/fullscreenable_image.dart'; -import '../widgets/info_table_popup.dart'; -import '../widgets/markdown_text.dart'; -import '../widgets/reveal_after_scroll.dart'; -import '../widgets/sortable_infinite_list.dart'; -import 'create_post.dart'; -import 'modlog_page.dart'; - -/// Displays posts, comments, and general info about the given community -class CommunityPage extends HookWidget { - final CommunityView? _community; - final String instanceHost; - final String? communityName; - final int? communityId; - - const CommunityPage.fromName({ - required String this.communityName, - required this.instanceHost, - }) : communityId = null, - _community = null; - const CommunityPage.fromId({ - required int this.communityId, - required this.instanceHost, - }) : communityName = null, - _community = null; - CommunityPage.fromCommunityView(CommunityView this._community) - : instanceHost = _community.instanceHost, - communityId = _community.community.id, - communityName = _community.community.name; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final accountsStore = useAccountsStore(); - final scrollController = useScrollController(); - - final fullCommunitySnap = useMemoFuture(() { - final token = accountsStore.defaultUserDataFor(instanceHost)?.jwt; - - if (communityId != null) { - return LemmyApiV3(instanceHost).run(GetCommunity( - id: communityId, - auth: token?.raw, - )); - } else { - return LemmyApiV3(instanceHost).run(GetCommunity( - name: communityName, - auth: token?.raw, - )); - } - }); - - final community = () { - if (fullCommunitySnap.hasData) { - return fullCommunitySnap.data!.communityView; - } else if (_community != null) { - return _community; - } else { - return null; - } - }(); - - // FALLBACK - - if (community == null) { - return Scaffold( - appBar: AppBar(), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (fullCommunitySnap.hasError) ...[ - const Icon(Icons.error), - Padding( - padding: const EdgeInsets.all(8), - child: Text('ERROR: ${fullCommunitySnap.error}'), - ) - ] else - const CircularProgressIndicator.adaptive( - semanticsLabel: 'loading') - ], - ), - ), - ); - } - - // FUNCTIONS - void _share() => share(community.community.actorId, context: context); - - void _openMoreMenu() { - showBottomModal( - context: context, - builder: (context) => Column( - children: [ - ListTile( - leading: const Icon(Icons.open_in_browser), - title: const Text('Open in browser'), - onTap: () async => await ul.canLaunch(community.community.actorId) - ? ul.launch(community.community.actorId) - : ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("can't open in browser"))), - ), - ListTile( - leading: const Icon(Icons.info_outline), - title: const Text('Nerd stuff'), - onTap: () { - showInfoTablePopup(context: context, table: community.toJson()); - }, - ), - ], - ), - ); - } - - return Scaffold( - floatingActionButton: CreatePostFab(community: community), - body: DefaultTabController( - length: 3, - child: NestedScrollView( - controller: scrollController, - headerSliverBuilder: (context, innerBoxIsScrolled) => [ - SliverAppBar( - expandedHeight: community.community.icon == null ? 220 : 300, - pinned: true, - backgroundColor: theme.cardColor, - title: RevealAfterScroll( - scrollController: scrollController, - after: community.community.icon == null ? 110 : 190, - fade: true, - child: Text( - community.community.preferredName, - overflow: TextOverflow.fade, - softWrap: false, - ), - ), - actions: [ - IconButton(icon: Icon(shareIcon), onPressed: _share), - IconButton(icon: Icon(moreIcon), onPressed: _openMoreMenu), - ], - flexibleSpace: FlexibleSpaceBar( - background: _CommunityOverview( - community: community, - instanceHost: instanceHost, - onlineUsers: fullCommunitySnap.data?.online, - ), - ), - bottom: PreferredSize( - preferredSize: const TabBar(tabs: []).preferredSize, - child: Material( - color: theme.cardColor, - child: TabBar( - tabs: [ - Tab(text: L10n.of(context).posts), - Tab(text: L10n.of(context).comments), - const Tab(text: 'About'), - ], - ), - ), - ), - ), - ], - body: TabBarView( - children: [ - InfinitePostList( - fetcher: (page, batchSize, sort) => - LemmyApiV3(community.instanceHost).run(GetPosts( - type: PostListingType.community, - sort: sort, - communityId: community.community.id, - page: page, - limit: batchSize, - savedOnly: false, - )), - ), - InfiniteCommentList( - fetcher: (page, batchSize, sortType) => - LemmyApiV3(community.instanceHost).run(GetComments( - communityId: community.community.id, - auth: accountsStore - .defaultUserDataFor(community.instanceHost) - ?.jwt - .raw, - type: CommentListingType.community, - sort: sortType, - limit: batchSize, - page: page, - savedOnly: false, - ))), - _AboutTab( - community: community, - moderators: fullCommunitySnap.data?.moderators, - onlineUsers: fullCommunitySnap.data?.online, - ), - ], - ), - ), - ), - ); - } -} - -class _CommunityOverview extends StatelessWidget { - final CommunityView community; - final String instanceHost; - final int? onlineUsers; - - const _CommunityOverview({ - required this.community, - required this.instanceHost, - required this.onlineUsers, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final shadow = BoxShadow(color: theme.canvasColor, blurRadius: 5); - - final icon = community.community.icon != null - ? Stack( - alignment: Alignment.center, - children: [ - Container( - width: 90, - height: 90, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.7), - blurRadius: 3, - ), - ], - ), - ), - FullscreenableImage( - url: community.community.icon!, - child: Avatar( - url: community.community.icon, - radius: 83 / 2, - alwaysShow: true, - ), - ), - ], - ) - : null; - - return Stack( - children: [ - if (community.community.banner != null) - FullscreenableImage( - url: community.community.banner!, - child: CachedNetworkImage( - imageUrl: community.community.banner!, - errorBuilder: (_, ___) => const SizedBox.shrink(), - ), - ), - SafeArea( - child: Column( - children: [ - const SizedBox(height: 45), - if (icon != null) icon, - const SizedBox(height: 10), - // NAME - RichText( - overflow: TextOverflow.ellipsis, - text: TextSpan( - style: theme.textTheme.subtitle1?.copyWith(shadows: [shadow]), - children: [ - const TextSpan( - text: '!', - style: TextStyle(fontWeight: FontWeight.w200), - ), - TextSpan( - text: community.community.name, - style: const TextStyle(fontWeight: FontWeight.w600), - ), - const TextSpan( - text: '@', - style: TextStyle(fontWeight: FontWeight.w200), - ), - TextSpan( - text: community.community.originInstanceHost, - style: const TextStyle(fontWeight: FontWeight.w600), - recognizer: TapGestureRecognizer() - ..onTap = () => goToInstance( - context, - community.community.originInstanceHost, - ), - ), - ], - ), - ), - // TITLE/MOTTO - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text( - community.community.title, - textAlign: TextAlign.center, - style: TextStyle( - fontWeight: FontWeight.w300, - shadows: [shadow], - ), - ), - ), - const SizedBox(height: 20), - Stack( - alignment: Alignment.center, - children: [ - // INFO ICONS - Row( - children: [ - const Spacer(), - const Icon(Icons.people, size: 20), - const SizedBox(width: 3), - Text(compactNumber(community.counts.subscribers)), - const Spacer(flex: 4), - const Icon(Icons.record_voice_over, size: 20), - const SizedBox(width: 3), - Text(onlineUsers == null - ? 'xx' - : compactNumber(onlineUsers!)), - const Spacer(), - ], - ), - _FollowButton(community), - ], - ), - ], - ), - ), - ], - ); - } -} - -class _AboutTab extends StatelessWidget { - final CommunityView community; - final List? moderators; - final int? onlineUsers; - - const _AboutTab({ - Key? key, - required this.community, - required this.moderators, - required this.onlineUsers, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return ListView( - padding: const EdgeInsets.only(top: 20), - children: [ - if (community.community.description != null) ...[ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 15), - child: MarkdownText( - community.community.description!, - instanceHost: community.instanceHost, - ), - ), - const _Divider(), - ], - SizedBox( - height: 32, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 15), - children: [ - Chip( - label: Text(L10n.of(context) - .number_of_users_online(onlineUsers ?? 0))), - Chip( - label: - Text('${community.counts.usersActiveDay} users / day')), - Chip( - label: - Text('${community.counts.usersActiveWeek} users / week')), - Chip( - label: Text( - '${community.counts.usersActiveMonth} users / month')), - Chip( - label: Text( - '${community.counts.usersActiveHalfYear} users / 6 months')), - Chip( - label: Text(L10n.of(context) - .number_of_subscribers(community.counts.subscribers))), - Chip( - label: Text( - '${community.counts.posts} post${pluralS(community.counts.posts)}')), - Chip( - label: Text( - '${community.counts.comments} comment${pluralS(community.counts.comments)}')), - ].spaced(8), - ), - ), - const _Divider(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 15), - child: OutlinedButton( - onPressed: () => goTo( - context, - (context) => ModlogPage.forCommunity( - instanceHost: community.instanceHost, - communityId: community.community.id, - communityName: community.community.name, - ), - ), - child: Text(L10n.of(context).modlog), - ), - ), - const _Divider(), - if (moderators != null && moderators!.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 15), - child: Text('Mods:', style: theme.textTheme.subtitle2), - ), - for (final mod in moderators!) - // TODO: add user picture, maybe make it into reusable component - ListTile( - title: Text(mod.moderator.preferredName), - onTap: () => goToUser.fromPersonSafe(context, mod.moderator), - ), - ], - const BottomSafe(), - ], - ); - } -} - -class _Divider extends StatelessWidget { - const _Divider(); - - @override - Widget build(BuildContext context) => const Padding( - padding: EdgeInsets.symmetric(horizontal: 15, vertical: 10), - child: Divider(), - ); -} - -class _FollowButton extends HookWidget { - final CommunityView community; - - const _FollowButton(this.community); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - final isSubbed = useState(community.subscribed); - final delayed = useDelayedLoading(Duration.zero); - final loggedInAction = useLoggedInAction(community.instanceHost); - - subscribe(Jwt token) async { - delayed.start(); - try { - await LemmyApiV3(community.instanceHost).run(FollowCommunity( - communityId: community.community.id, - follow: !isSubbed.value, - auth: token.raw)); - isSubbed.value = !isSubbed.value; - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Row( - children: [ - const Icon(Icons.warning), - const SizedBox(width: 10), - Text("couldn't ${isSubbed.value ? 'un' : ''}sub :<"), - ], - ), - )); - } - - delayed.cancel(); - } - - return ElevatedButtonTheme( - data: ElevatedButtonThemeData( - style: theme.elevatedButtonTheme.style?.copyWith( - shape: MaterialStateProperty.all(const StadiumBorder()), - textStyle: MaterialStateProperty.all(theme.textTheme.subtitle1), - ), - ), - child: Center( - child: SizedBox( - height: 27, - width: 160, - child: delayed.loading - ? const ElevatedButton( - onPressed: null, - child: SizedBox( - height: 15, - width: 15, - child: CircularProgressIndicator.adaptive(), - ), - ) - : ElevatedButton.icon( - onPressed: - loggedInAction(delayed.pending ? (_) {} : subscribe), - icon: isSubbed.value - ? const Icon(Icons.remove, size: 18) - : const Icon(Icons.add, size: 18), - label: Text(isSubbed.value - ? L10n.of(context).unsubscribe - : L10n.of(context).subscribe), - ), - ), - ), - ); - } -} diff --git a/lib/pages/community/community.dart b/lib/pages/community/community.dart new file mode 100644 index 0000000..c198c3c --- /dev/null +++ b/lib/pages/community/community.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:lemmy_api_client/v3.dart'; +import 'package:nested/nested.dart'; + +import '../../hooks/stores.dart'; +import '../../l10n/l10n.dart'; +import '../../stores/accounts_store.dart'; +import '../../util/async_store.dart'; +import '../../util/async_store_listener.dart'; +import '../../util/extensions/api.dart'; +import '../../util/icons.dart'; +import '../../util/observer_consumers.dart'; +import '../../util/share.dart'; +import '../../widgets/failed_to_load.dart'; +import '../../widgets/reveal_after_scroll.dart'; +import '../../widgets/sortable_infinite_list.dart'; +import '../create_post.dart'; +import 'community_about_tab.dart'; +import 'community_more_menu.dart'; +import 'community_overview.dart'; +import 'community_store.dart'; + +/// Displays posts, comments, and general info about the given community +class CommunityPage extends HookWidget { + const CommunityPage(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final accountsStore = useAccountsStore(); + final scrollController = useScrollController(); + + return Nested( + children: [ + AsyncStoreListener( + asyncStore: context.read().communityState), + AsyncStoreListener( + asyncStore: context.read().subscribingState), + AsyncStoreListener( + asyncStore: context.read().blockingState, + successMessageBuilder: (context, BlockedCommunity data) { + final name = data.communityView.community.preferredName; + final blocked = data.blocked ? 'blocked' : 'unblocked'; + return '$name $blocked'; + }, + ), + ], + child: ObserverBuilder(builder: (context, store) { + final communityState = store.communityState; + final communityAsyncState = communityState.asyncState; + + // FALLBACK + if (communityAsyncState is! AsyncStateData) { + return Scaffold( + appBar: AppBar(), + body: Center( + child: (communityState.errorTerm != null) + ? FailedToLoad( + refresh: () => store.refresh(context + .read() + .defaultUserDataFor(store.instanceHost) + ?.jwt), + message: communityState.errorTerm!.tr(context), + ) + : const CircularProgressIndicator.adaptive()), + ); + } + + final fullCommunityView = communityAsyncState.data; + final community = fullCommunityView.communityView; + + void _share() => share(community.community.actorId, context: context); + + return Scaffold( + floatingActionButton: CreatePostFab(community: community), + body: DefaultTabController( + length: 3, + child: NestedScrollView( + controller: scrollController, + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverAppBar( + expandedHeight: community.community.icon == null ? 220 : 300, + pinned: true, + backgroundColor: theme.cardColor, + title: RevealAfterScroll( + scrollController: scrollController, + after: community.community.icon == null ? 110 : 190, + fade: true, + child: Text( + community.community.preferredName, + overflow: TextOverflow.fade, + softWrap: false, + ), + ), + actions: [ + IconButton(icon: Icon(shareIcon), onPressed: _share), + IconButton( + icon: Icon(moreIcon), + onPressed: () => + CommunityMoreMenu.open(context, fullCommunityView)), + ], + flexibleSpace: FlexibleSpaceBar( + background: CommunityOverview(fullCommunityView), + ), + bottom: PreferredSize( + preferredSize: const TabBar(tabs: []).preferredSize, + child: Material( + color: theme.cardColor, + child: TabBar( + tabs: [ + Tab(text: L10n.of(context).posts), + Tab(text: L10n.of(context).comments), + const Tab(text: 'About'), + ], + ), + ), + ), + ), + ], + body: TabBarView( + children: [ + InfinitePostList( + fetcher: (page, batchSize, sort) => + LemmyApiV3(community.instanceHost).run(GetPosts( + type: PostListingType.community, + sort: sort, + communityId: community.community.id, + page: page, + limit: batchSize, + savedOnly: false, + )), + ), + InfiniteCommentList( + fetcher: (page, batchSize, sortType) => + LemmyApiV3(community.instanceHost).run(GetComments( + communityId: community.community.id, + auth: accountsStore + .defaultUserDataFor(community.instanceHost) + ?.jwt + .raw, + type: CommentListingType.community, + sort: sortType, + limit: batchSize, + page: page, + savedOnly: false, + ))), + CommmunityAboutTab(fullCommunityView), + ], + ), + ), + ), + ); + }), + ); + } + + static Route _route(String instanceHost, CommunityStore store) { + return MaterialPageRoute( + builder: (context) { + return Provider.value( + value: store + ..refresh(context + .read() + .defaultUserDataFor(instanceHost) + ?.jwt), + child: const CommunityPage(), + ); + }, + ); + } + + static Route fromNameRoute(String instanceHost, String name) { + return _route( + instanceHost, + CommunityStore.fromName(communityName: name, instanceHost: instanceHost), + ); + } + + static Route fromIdRoute(String instanceHost, int id) { + return _route( + instanceHost, + CommunityStore.fromId(id: id, instanceHost: instanceHost), + ); + } +} diff --git a/lib/pages/community/community_about_tab.dart b/lib/pages/community/community_about_tab.dart new file mode 100644 index 0000000..40a3426 --- /dev/null +++ b/lib/pages/community/community_about_tab.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:lemmy_api_client/v3.dart'; + +import '../../l10n/l10n.dart'; +import '../../stores/accounts_store.dart'; +import '../../util/extensions/spaced.dart'; +import '../../util/goto.dart'; +import '../../util/intl.dart'; +import '../../util/observer_consumers.dart'; +import '../../widgets/bottom_safe.dart'; +import '../../widgets/markdown_text.dart'; +import '../../widgets/pull_to_refresh.dart'; +import '../../widgets/user_tile.dart'; +import '../modlog_page.dart'; +import 'community_store.dart'; + +class CommmunityAboutTab extends StatelessWidget { + final FullCommunityView fullCommunityView; + + const CommmunityAboutTab(this.fullCommunityView, {Key? key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + final community = fullCommunityView.communityView; + final onlineUsers = fullCommunityView.online; + final moderators = fullCommunityView.moderators; + + return PullToRefresh( + onRefresh: () async { + await context.read().refresh(context + .read() + .defaultUserDataFor(fullCommunityView.instanceHost) + ?.jwt); + }, + child: ListView( + padding: const EdgeInsets.only(top: 20), + children: [ + if (community.community.description != null) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: MarkdownText( + community.community.description!, + instanceHost: community.instanceHost, + ), + ), + const _Divider(), + ], + SizedBox( + height: 32, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 15), + children: [ + Chip( + label: Text( + L10n.of(context).number_of_users_online(onlineUsers))), + Chip( + label: + Text('${community.counts.usersActiveDay} users / day')), + Chip( + label: Text( + '${community.counts.usersActiveWeek} users / week')), + Chip( + label: Text( + '${community.counts.usersActiveMonth} users / month')), + Chip( + label: Text( + '${community.counts.usersActiveHalfYear} users / 6 months')), + Chip( + label: Text(L10n.of(context) + .number_of_subscribers(community.counts.subscribers))), + Chip( + label: Text( + '${community.counts.posts} post${pluralS(community.counts.posts)}')), + Chip( + label: Text( + '${community.counts.comments} comment${pluralS(community.counts.comments)}')), + ].spaced(8), + ), + ), + const _Divider(), + if (moderators.isNotEmpty) ...[ + const ListTile( + title: Center( + child: Text('Mods:'), + ), + ), + for (final mod in moderators) + PersonTile( + mod.moderator, + expanded: true, + ), + ], + const _Divider(), + ListTile( + title: Center( + child: Text( + L10n.of(context).modlog, + style: Theme.of(context).textTheme.headline6, + ), + ), + onTap: () => goTo( + context, + (context) => ModlogPage.forCommunity( + instanceHost: community.instanceHost, + communityId: community.community.id, + communityName: community.community.name, + ), + ), + ), + const BottomSafe(), + ], + ), + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + + @override + Widget build(BuildContext context) => const Padding( + padding: EdgeInsets.symmetric(horizontal: 15, vertical: 10), + child: Divider(), + ); +} diff --git a/lib/pages/community/community_follow_button.dart b/lib/pages/community/community_follow_button.dart new file mode 100644 index 0000000..929db54 --- /dev/null +++ b/lib/pages/community/community_follow_button.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:lemmy_api_client/v3.dart'; + +import '../../hooks/logged_in_action.dart'; +import '../../l10n/l10n.dart'; +import '../../util/observer_consumers.dart'; +import 'community_store.dart'; + +class CommunityFollowButton extends HookWidget { + final CommunityView communityView; + + const CommunityFollowButton(this.communityView); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final loggedInAction = + useLoggedInAction(context.read().instanceHost); + + return ObserverBuilder(builder: (context, store) { + return ElevatedButtonTheme( + data: ElevatedButtonThemeData( + style: theme.elevatedButtonTheme.style?.copyWith( + shape: MaterialStateProperty.all(const StadiumBorder()), + textStyle: MaterialStateProperty.all(theme.textTheme.subtitle1), + ), + ), + child: Center( + child: SizedBox( + height: 27, + width: 160, + child: ElevatedButton( + onPressed: store.subscribingState.isLoading + ? () {} + : loggedInAction(store.subscribe), + child: store.subscribingState.isLoading + ? const SizedBox( + width: 15, + height: 15, + child: CircularProgressIndicator.adaptive(), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (communityView.subscribed) + const Icon(Icons.remove, size: 18) + else + const Icon(Icons.add, size: 18), + const SizedBox(width: 5), + Flexible( + child: Text(communityView.subscribed + ? L10n.of(context).unsubscribe + : L10n.of(context).subscribe)) + ], + ), + )), + ), + ); + }); + } +} diff --git a/lib/pages/community/community_more_menu.dart b/lib/pages/community/community_more_menu.dart new file mode 100644 index 0000000..ed951be --- /dev/null +++ b/lib/pages/community/community_more_menu.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:lemmy_api_client/v3.dart'; +import 'package:url_launcher/url_launcher.dart' as ul; + +import '../../hooks/logged_in_action.dart'; +import '../../util/extensions/api.dart'; +import '../../util/observer_consumers.dart'; +import '../../widgets/bottom_modal.dart'; +import '../../widgets/info_table_popup.dart'; +import 'community_store.dart'; + +class CommunityMoreMenu extends HookWidget { + final FullCommunityView fullCommunityView; + + const CommunityMoreMenu({Key? key, required this.fullCommunityView}) + : super(key: key); + + @override + Widget build(BuildContext context) { + final communityView = fullCommunityView.communityView; + + final loggedInAction = useLoggedInAction(communityView.instanceHost); + + return Column( + children: [ + ListTile( + leading: const Icon(Icons.open_in_browser), + title: const Text('Open in browser'), + onTap: () async => await ul.canLaunch(communityView.community.actorId) + ? ul.launch(communityView.community.actorId) + : ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("can't open in browser"))), + ), + ObserverBuilder(builder: (context, store) { + return ListTile( + leading: store.blockingState.isLoading + ? const CircularProgressIndicator.adaptive() + : const Icon(Icons.block), + title: Text( + '${fullCommunityView.communityView.blocked ? 'Unblock' : 'Block'} ${communityView.community.preferredName}'), + onTap: store.blockingState.isLoading + ? null + : loggedInAction((token) { + store.block(token); + Navigator.of(context).pop(); + }), + ); + }), + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('Nerd stuff'), + onTap: () { + showInfoTablePopup(context: context, table: communityView.toJson()); + }, + ), + ], + ); + } + + static void open(BuildContext context, FullCommunityView fullCommunityView) { + final store = context.read(); + showBottomModal( + context: context, + builder: (context) => Provider.value( + value: store, + child: CommunityMoreMenu( + fullCommunityView: fullCommunityView, + ), + ), + ); + } +} diff --git a/lib/pages/community/community_overview.dart b/lib/pages/community/community_overview.dart new file mode 100644 index 0000000..39d1d7c --- /dev/null +++ b/lib/pages/community/community_overview.dart @@ -0,0 +1,144 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:lemmy_api_client/v3.dart'; + +import '../../util/extensions/api.dart'; +import '../../util/goto.dart'; +import '../../util/intl.dart'; +import '../../widgets/avatar.dart'; +import '../../widgets/cached_network_image.dart'; +import '../../widgets/fullscreenable_image.dart'; +import 'community_follow_button.dart'; + +class CommunityOverview extends StatelessWidget { + final FullCommunityView fullCommunityView; + + const CommunityOverview(this.fullCommunityView); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final shadow = BoxShadow(color: theme.canvasColor, blurRadius: 5); + + final community = fullCommunityView.communityView; + + final icon = community.community.icon != null + ? Stack( + alignment: Alignment.center, + children: [ + Container( + width: 90, + height: 90, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.7), + blurRadius: 3, + ), + ], + ), + ), + FullscreenableImage( + url: community.community.icon!, + child: Material( + color: Colors.transparent, + child: Avatar( + url: community.community.icon, + radius: 83 / 2, + alwaysShow: true, + ), + ), + ), + ], + ) + : null; + + return Stack( + children: [ + if (community.community.banner != null) + FullscreenableImage( + url: community.community.banner!, + child: CachedNetworkImage( + imageUrl: community.community.banner!, + errorBuilder: (_, ___) => const SizedBox.shrink(), + ), + ), + SafeArea( + child: Column( + children: [ + const SizedBox(height: 45), + if (icon != null) icon, + const SizedBox(height: 10), + // NAME + RichText( + overflow: TextOverflow.ellipsis, + text: TextSpan( + style: theme.textTheme.subtitle1?.copyWith(shadows: [shadow]), + children: [ + const TextSpan( + text: '!', + style: TextStyle(fontWeight: FontWeight.w200), + ), + TextSpan( + text: community.community.name, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const TextSpan( + text: '@', + style: TextStyle(fontWeight: FontWeight.w200), + ), + TextSpan( + text: community.community.originInstanceHost, + style: const TextStyle(fontWeight: FontWeight.w600), + recognizer: TapGestureRecognizer() + ..onTap = () => goToInstance( + context, + community.community.originInstanceHost, + ), + ), + ], + ), + ), + // TITLE/MOTTO + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + community.community.title, + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.w300, + shadows: [shadow], + ), + ), + ), + const SizedBox(height: 20), + Stack( + alignment: Alignment.center, + children: [ + // INFO ICONS + Row( + children: [ + const Spacer(), + const Icon(Icons.people, size: 20), + const SizedBox(width: 3), + Text(compactNumber(community.counts.subscribers)), + const Spacer(flex: 4), + const Icon(Icons.record_voice_over, size: 20), + const SizedBox(width: 3), + Text(compactNumber(fullCommunityView.online)), + const Spacer(), + ], + ), + CommunityFollowButton(community), + ], + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/pages/community/community_store.dart b/lib/pages/community/community_store.dart new file mode 100644 index 0000000..6418927 --- /dev/null +++ b/lib/pages/community/community_store.dart @@ -0,0 +1,84 @@ +import 'package:lemmy_api_client/v3.dart'; +import 'package:mobx/mobx.dart'; + +import '../../util/async_store.dart'; + +part 'community_store.g.dart'; + +class CommunityStore = _CommunityStore with _$CommunityStore; + +abstract class _CommunityStore with Store { + final String instanceHost; + final String? communityName; + final int? id; + + // ignore: unused_element + _CommunityStore.fromName({ + required String this.communityName, + required this.instanceHost, + }) : id = null; + // ignore: unused_element + _CommunityStore.fromId({required this.id, required this.instanceHost}) + : communityName = null; + + final communityState = AsyncStore(); + final subscribingState = AsyncStore(); + final blockingState = AsyncStore(); + + @action + Future refresh(Jwt? token) async { + await communityState.runLemmy( + instanceHost, + GetCommunity( + auth: token?.raw, + id: id, + name: communityName, + ), + refresh: true, + ); + } + + Future block(Jwt token) async { + final state = communityState.asyncState; + if (state is! AsyncStateData) { + throw StateError('communityState should be ready at this point'); + } + + final res = await blockingState.runLemmy( + instanceHost, + BlockCommunity( + communityId: state.data.communityView.community.id, + block: !state.data.communityView.blocked, + auth: token.raw, + ), + ); + + if (res != null) { + communityState + .setData(state.data.copyWith(communityView: res.communityView)); + } + } + + @action + Future subscribe(Jwt token) async { + final state = communityState.asyncState; + + if (state is! AsyncStateData) { + throw StateError('FullCommunityView should be not null at this point'); + } + final communityView = state.data.communityView; + + final res = await subscribingState.runLemmy( + instanceHost, + FollowCommunity( + communityId: communityView.community.id, + follow: !communityView.subscribed, + auth: token.raw, + ), + ); + + if (res != null) { + communityState.setData(state.data.copyWith(communityView: res)); + } + } +} diff --git a/lib/pages/community/community_store.g.dart b/lib/pages/community/community_store.g.dart new file mode 100644 index 0000000..586bf1d --- /dev/null +++ b/lib/pages/community/community_store.g.dart @@ -0,0 +1,32 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'community_store.dart'; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic + +mixin _$CommunityStore on _CommunityStore, Store { + final _$refreshAsyncAction = AsyncAction('_CommunityStore.refresh'); + + @override + Future refresh(Jwt? token) { + return _$refreshAsyncAction.run(() => super.refresh(token)); + } + + final _$subscribeAsyncAction = AsyncAction('_CommunityStore.subscribe'); + + @override + Future subscribe(Jwt token) { + return _$subscribeAsyncAction.run(() => super.subscribe(token)); + } + + @override + String toString() { + return ''' + + '''; + } +} diff --git a/lib/pages/full_post/comment_section.dart b/lib/pages/full_post/comment_section.dart index 45404d3..f34d909 100644 --- a/lib/pages/full_post/comment_section.dart +++ b/lib/pages/full_post/comment_section.dart @@ -7,7 +7,7 @@ import '../../util/observer_consumers.dart'; import '../../widgets/bottom_modal.dart'; import '../../widgets/bottom_safe.dart'; import '../../widgets/comment/comment.dart'; -import 'full_post.dart'; +import '../../widgets/failed_to_load.dart'; import 'full_post_store.dart'; class _SortSelection { diff --git a/lib/pages/full_post/full_post.dart b/lib/pages/full_post/full_post.dart index f513249..8c53a55 100644 --- a/lib/pages/full_post/full_post.dart +++ b/lib/pages/full_post/full_post.dart @@ -13,6 +13,7 @@ import '../../util/extensions/api.dart'; import '../../util/icons.dart'; import '../../util/observer_consumers.dart'; import '../../util/share.dart'; +import '../../widgets/failed_to_load.dart'; import '../../widgets/post/post.dart'; import '../../widgets/post/post_more_menu.dart'; import '../../widgets/post/post_store.dart'; @@ -165,26 +166,3 @@ class FullPostPage extends HookWidget { ), ); } - -class FailedToLoad extends StatelessWidget { - final String message; - final VoidCallback refresh; - - const FailedToLoad({required this.refresh, required this.message}); - - @override - Widget build(BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(message), - const SizedBox(height: 5), - ElevatedButton.icon( - onPressed: refresh, - icon: const Icon(Icons.refresh), - label: const Text('try again'), - ) - ], - ); - } -} diff --git a/lib/pages/instance.dart b/lib/pages/instance.dart index 650a022..83e9a69 100644 --- a/lib/pages/instance.dart +++ b/lib/pages/instance.dart @@ -5,7 +5,6 @@ import 'package:url_launcher/url_launcher.dart' as ul; import '../hooks/stores.dart'; import '../l10n/l10n.dart'; -import '../util/extensions/api.dart'; import '../util/extensions/spaced.dart'; import '../util/goto.dart'; import '../util/icons.dart'; @@ -19,6 +18,7 @@ import '../widgets/info_table_popup.dart'; import '../widgets/markdown_text.dart'; import '../widgets/reveal_after_scroll.dart'; import '../widgets/sortable_infinite_list.dart'; +import '../widgets/user_tile.dart'; import 'communities_list.dart'; import 'modlog_page.dart'; import 'users_list.dart'; @@ -354,13 +354,9 @@ class _AboutTab extends HookWidget { ), ), for (final u in site.admins) - ListTile( - title: Text(u.person.originPreferredName), - subtitle: u.person.bio != null - ? MarkdownText(u.person.bio!, instanceHost: instanceHost) - : null, - onTap: () => goToUser.fromPersonSafe(context, u.person), - leading: Avatar(url: u.person.avatar), + PersonTile( + u.person, + expanded: true, ), const _Divider(), ListTile( diff --git a/lib/pages/users_list.dart b/lib/pages/users_list.dart index 4ef1d1a..a028460 100644 --- a/lib/pages/users_list.dart +++ b/lib/pages/users_list.dart @@ -42,7 +42,7 @@ class UsersListItem extends StatelessWidget { title: Text(user.person.originPreferredName), subtitle: user.person.bio != null ? Opacity( - opacity: 0.5, + opacity: 0.7, child: MarkdownText( user.person.bio!, instanceHost: user.instanceHost, diff --git a/lib/url_launcher.dart b/lib/url_launcher.dart index 5069af9..67e0042 100644 --- a/lib/url_launcher.dart +++ b/lib/url_launcher.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart' as ul; -import 'pages/community.dart'; +import 'pages/community/community.dart'; import 'pages/instance.dart'; import 'pages/media_view.dart'; import 'pages/user.dart'; @@ -16,7 +16,7 @@ Future linkLauncher({ required String url, required String instanceHost, }) async { - push(Widget Function() builder) { + void push(Widget Function() builder) { goTo(context, (c) => builder()); } @@ -33,8 +33,9 @@ Future linkLauncher({ // CHECK IF LINK TO COMMUNITY if (url.startsWith('/c/')) { - return push(() => CommunityPage.fromName( - communityName: chonks[2], instanceHost: instanceHost)); + await Navigator.of(context) + .push(CommunityPage.fromNameRoute(instanceHost, chonks[2])); + return; } // CHECK IF REDIRECTS TO A PAGE ON ONE OF ADDED INSTANCES diff --git a/lib/util/async_store.dart b/lib/util/async_store.dart index b998ac4..ca49bc9 100644 --- a/lib/util/async_store.dart +++ b/lib/util/async_store.dart @@ -16,20 +16,33 @@ abstract class _AsyncStore with Store { AsyncState asyncState = const AsyncState.initial(); @computed - bool get isLoading => asyncState is AsyncStateLoading; + bool get isLoading => asyncState is AsyncStateLoading; @computed - String? get errorTerm => - asyncState.whenOrNull(error: (errorTerm) => errorTerm); + String? get errorTerm => asyncState.whenOrNull( + error: (errorTerm) => errorTerm, + data: (data, errorTerm) => errorTerm, + ); + + /// sets data in asyncState + @action + void setData(T data) => asyncState = AsyncState.data(data); /// runs some async action and reflects the progress in [asyncState]. /// If successful, the result is returned, otherwise null is returned. /// If this [AsyncStore] is already running some action, it will exit immediately and do nothing + /// + /// When [refresh] is true and [asyncState] is [AsyncStateData], then the data state is persisted and + /// errors are not fatal but stored in [AsyncStateData] @action - Future run(AsyncValueGetter callback) async { + Future run(AsyncValueGetter callback, {bool refresh = false}) async { if (isLoading) return null; - asyncState = const AsyncState.loading(); + final data = refresh ? asyncState.mapOrNull(data: (data) => data) : null; + + if (data == null) { + asyncState = const AsyncState.loading(); + } try { final result = await callback(); @@ -39,9 +52,17 @@ abstract class _AsyncStore with Store { return result; } on SocketException { // TODO: use an existing l10n key - asyncState = const AsyncState.error('network_error'); + if (data != null) { + asyncState = data.copyWith(errorTerm: 'network_error'); + } else { + asyncState = const AsyncState.error('network_error'); + } } catch (err) { - asyncState = AsyncState.error(err.toString()); + if (data != null) { + asyncState = data.copyWith(errorTerm: err.toString()); + } else { + asyncState = AsyncState.error(err.toString()); + } rethrow; } } @@ -49,27 +70,39 @@ abstract class _AsyncStore with Store { /// [run] but specialized for a [LemmyApiQuery]. /// Will catch [LemmyApiException] and map to its error term. @action - Future runLemmy(String instanceHost, LemmyApiQuery query) async { + Future runLemmy( + String instanceHost, + LemmyApiQuery query, { + bool refresh = false, + }) async { try { - return await run(() => LemmyApiV3(instanceHost).run(query)); + return await run(() => LemmyApiV3(instanceHost).run(query), + refresh: refresh); } on LemmyApiException catch (err) { - asyncState = AsyncState.error(err.message); + final data = refresh ? asyncState.mapOrNull(data: (data) => data) : null; + if (data != null) { + asyncState = data.copyWith(errorTerm: err.message); + } else { + asyncState = AsyncState.error(err.message); + } } } } /// State in which an async action can be @freezed -class AsyncState with _$AsyncState { +class AsyncState with _$AsyncState { /// async action has not yet begun - const factory AsyncState.initial() = AsyncStateInitial; + const factory AsyncState.initial() = AsyncStateInitial; /// async action completed successfully with [T] - const factory AsyncState.data(T data) = AsyncStateData; + /// and possibly an error term after a refresh + const factory AsyncState.data(T data, [String? errorTerm]) = + AsyncStateData; /// async action is running at the moment - const factory AsyncState.loading() = AsyncStateLoading; + const factory AsyncState.loading() = AsyncStateLoading; /// async action failed with a translatable error term - const factory AsyncState.error(String errorTerm) = AsyncStateError; + const factory AsyncState.error(String errorTerm) = AsyncStateError; } diff --git a/lib/util/async_store.freezed.dart b/lib/util/async_store.freezed.dart index b1f2d73..dcaeb83 100644 --- a/lib/util/async_store.freezed.dart +++ b/lib/util/async_store.freezed.dart @@ -21,9 +21,10 @@ class _$AsyncStateTearOff { return AsyncStateInitial(); } - AsyncStateData data(T data) { + AsyncStateData data(T data, [String? errorTerm]) { return AsyncStateData( data, + errorTerm, ); } @@ -46,7 +47,7 @@ mixin _$AsyncState { @optionalTypeArgs TResult when({ required TResult Function() initial, - required TResult Function(T data) data, + required TResult Function(T data, String? errorTerm) data, required TResult Function() loading, required TResult Function(String errorTerm) error, }) => @@ -54,7 +55,7 @@ mixin _$AsyncState { @optionalTypeArgs TResult? whenOrNull({ TResult Function()? initial, - TResult Function(T data)? data, + TResult Function(T data, String? errorTerm)? data, TResult Function()? loading, TResult Function(String errorTerm)? error, }) => @@ -62,7 +63,7 @@ mixin _$AsyncState { @optionalTypeArgs TResult maybeWhen({ TResult Function()? initial, - TResult Function(T data)? data, + TResult Function(T data, String? errorTerm)? data, TResult Function()? loading, TResult Function(String errorTerm)? error, required TResult orElse(), @@ -162,7 +163,7 @@ class _$AsyncStateInitial @optionalTypeArgs TResult when({ required TResult Function() initial, - required TResult Function(T data) data, + required TResult Function(T data, String? errorTerm) data, required TResult Function() loading, required TResult Function(String errorTerm) error, }) { @@ -173,7 +174,7 @@ class _$AsyncStateInitial @optionalTypeArgs TResult? whenOrNull({ TResult Function()? initial, - TResult Function(T data)? data, + TResult Function(T data, String? errorTerm)? data, TResult Function()? loading, TResult Function(String errorTerm)? error, }) { @@ -184,7 +185,7 @@ class _$AsyncStateInitial @optionalTypeArgs TResult maybeWhen({ TResult Function()? initial, - TResult Function(T data)? data, + TResult Function(T data, String? errorTerm)? data, TResult Function()? loading, TResult Function(String errorTerm)? error, required TResult orElse(), @@ -242,7 +243,7 @@ abstract class $AsyncStateDataCopyWith { factory $AsyncStateDataCopyWith( AsyncStateData value, $Res Function(AsyncStateData) then) = _$AsyncStateDataCopyWithImpl; - $Res call({T data}); + $Res call({T data, String? errorTerm}); } /// @nodoc @@ -259,12 +260,17 @@ class _$AsyncStateDataCopyWithImpl @override $Res call({ Object? data = freezed, + Object? errorTerm = freezed, }) { return _then(AsyncStateData( data == freezed ? _value.data : data // ignore: cast_nullable_to_non_nullable as T, + errorTerm == freezed + ? _value.errorTerm + : errorTerm // ignore: cast_nullable_to_non_nullable + as String?, )); } } @@ -274,14 +280,16 @@ class _$AsyncStateDataCopyWithImpl class _$AsyncStateData with DiagnosticableTreeMixin implements AsyncStateData { - const _$AsyncStateData(this.data); + const _$AsyncStateData(this.data, [this.errorTerm]); @override final T data; + @override + final String? errorTerm; @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'AsyncState<$T>.data(data: $data)'; + return 'AsyncState<$T>.data(data: $data, errorTerm: $errorTerm)'; } @override @@ -289,7 +297,8 @@ class _$AsyncStateData super.debugFillProperties(properties); properties ..add(DiagnosticsProperty('type', 'AsyncState<$T>.data')) - ..add(DiagnosticsProperty('data', data)); + ..add(DiagnosticsProperty('data', data)) + ..add(DiagnosticsProperty('errorTerm', errorTerm)); } @override @@ -297,12 +306,14 @@ class _$AsyncStateData return identical(this, other) || (other.runtimeType == runtimeType && other is AsyncStateData && - const DeepCollectionEquality().equals(other.data, data)); + const DeepCollectionEquality().equals(other.data, data) && + (identical(other.errorTerm, errorTerm) || + other.errorTerm == errorTerm)); } @override - int get hashCode => - Object.hash(runtimeType, const DeepCollectionEquality().hash(data)); + int get hashCode => Object.hash( + runtimeType, const DeepCollectionEquality().hash(data), errorTerm); @JsonKey(ignore: true) @override @@ -313,35 +324,35 @@ class _$AsyncStateData @optionalTypeArgs TResult when({ required TResult Function() initial, - required TResult Function(T data) data, + required TResult Function(T data, String? errorTerm) data, required TResult Function() loading, required TResult Function(String errorTerm) error, }) { - return data(this.data); + return data(this.data, errorTerm); } @override @optionalTypeArgs TResult? whenOrNull({ TResult Function()? initial, - TResult Function(T data)? data, + TResult Function(T data, String? errorTerm)? data, TResult Function()? loading, TResult Function(String errorTerm)? error, }) { - return data?.call(this.data); + return data?.call(this.data, errorTerm); } @override @optionalTypeArgs TResult maybeWhen({ TResult Function()? initial, - TResult Function(T data)? data, + TResult Function(T data, String? errorTerm)? data, TResult Function()? loading, TResult Function(String errorTerm)? error, required TResult orElse(), }) { if (data != null) { - return data(this.data); + return data(this.data, errorTerm); } return orElse(); } @@ -385,9 +396,11 @@ class _$AsyncStateData } abstract class AsyncStateData implements AsyncState { - const factory AsyncStateData(T data) = _$AsyncStateData; + const factory AsyncStateData(T data, [String? errorTerm]) = + _$AsyncStateData; T get data; + String? get errorTerm; @JsonKey(ignore: true) $AsyncStateDataCopyWith> get copyWith => throw _privateConstructorUsedError; @@ -443,7 +456,7 @@ class _$AsyncStateLoading @optionalTypeArgs TResult when({ required TResult Function() initial, - required TResult Function(T data) data, + required TResult Function(T data, String? errorTerm) data, required TResult Function() loading, required TResult Function(String errorTerm) error, }) { @@ -454,7 +467,7 @@ class _$AsyncStateLoading @optionalTypeArgs TResult? whenOrNull({ TResult Function()? initial, - TResult Function(T data)? data, + TResult Function(T data, String? errorTerm)? data, TResult Function()? loading, TResult Function(String errorTerm)? error, }) { @@ -465,7 +478,7 @@ class _$AsyncStateLoading @optionalTypeArgs TResult maybeWhen({ TResult Function()? initial, - TResult Function(T data)? data, + TResult Function(T data, String? errorTerm)? data, TResult Function()? loading, TResult Function(String errorTerm)? error, required TResult orElse(), @@ -594,7 +607,7 @@ class _$AsyncStateError @optionalTypeArgs TResult when({ required TResult Function() initial, - required TResult Function(T data) data, + required TResult Function(T data, String? errorTerm) data, required TResult Function() loading, required TResult Function(String errorTerm) error, }) { @@ -605,7 +618,7 @@ class _$AsyncStateError @optionalTypeArgs TResult? whenOrNull({ TResult Function()? initial, - TResult Function(T data)? data, + TResult Function(T data, String? errorTerm)? data, TResult Function()? loading, TResult Function(String errorTerm)? error, }) { @@ -616,7 +629,7 @@ class _$AsyncStateError @optionalTypeArgs TResult maybeWhen({ TResult Function()? initial, - TResult Function(T data)? data, + TResult Function(T data, String? errorTerm)? data, TResult Function()? loading, TResult Function(String errorTerm)? error, required TResult orElse(), diff --git a/lib/util/async_store.g.dart b/lib/util/async_store.g.dart index 5c783cb..12e373e 100644 --- a/lib/util/async_store.g.dart +++ b/lib/util/async_store.g.dart @@ -41,15 +41,30 @@ mixin _$AsyncStore on _AsyncStore, Store { final _$runAsyncAction = AsyncAction('_AsyncStore.run'); @override - Future run(AsyncValueGetter callback) { - return _$runAsyncAction.run(() => super.run(callback)); + Future run(AsyncValueGetter callback, {bool refresh = false}) { + return _$runAsyncAction.run(() => super.run(callback, refresh: refresh)); } final _$runLemmyAsyncAction = AsyncAction('_AsyncStore.runLemmy'); @override - Future runLemmy(String instanceHost, LemmyApiQuery query) { - return _$runLemmyAsyncAction.run(() => super.runLemmy(instanceHost, query)); + Future runLemmy(String instanceHost, LemmyApiQuery query, + {bool refresh = false}) { + return _$runLemmyAsyncAction + .run(() => super.runLemmy(instanceHost, query, refresh: refresh)); + } + + final _$_AsyncStoreActionController = ActionController(name: '_AsyncStore'); + + @override + void setData(T data) { + final _$actionInfo = + _$_AsyncStoreActionController.startAction(name: '_AsyncStore.setData'); + try { + return super.setData(data); + } finally { + _$_AsyncStoreActionController.endAction(_$actionInfo); + } } @override diff --git a/lib/util/goto.dart b/lib/util/goto.dart index 72f6338..418a8ef 100644 --- a/lib/util/goto.dart +++ b/lib/util/goto.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:lemmy_api_client/v3.dart'; -import '../pages/community.dart'; +import '../pages/community/community.dart'; import '../pages/full_post/full_post.dart'; import '../pages/instance.dart'; import '../pages/media_view.dart'; @@ -33,19 +33,13 @@ abstract class goToCommunity { /// Navigates to `CommunityPage` static void byId( BuildContext context, String instanceHost, int communityId) => - goTo( - context, - (context) => CommunityPage.fromId( - instanceHost: instanceHost, communityId: communityId), - ); + Navigator.of(context) + .push(CommunityPage.fromIdRoute(instanceHost, communityId)); static void byName( BuildContext context, String instanceHost, String communityName) => - goTo( - context, - (context) => CommunityPage.fromName( - instanceHost: instanceHost, communityName: communityName), - ); + Navigator.of(context) + .push(CommunityPage.fromNameRoute(instanceHost, communityName)); } // ignore: camel_case_types diff --git a/lib/widgets/failed_to_load.dart b/lib/widgets/failed_to_load.dart new file mode 100644 index 0000000..abc66f0 --- /dev/null +++ b/lib/widgets/failed_to_load.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +class FailedToLoad extends StatelessWidget { + final String message; + final VoidCallback refresh; + + const FailedToLoad({required this.refresh, required this.message}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(message), + const SizedBox(height: 5), + ElevatedButton.icon( + onPressed: refresh, + icon: const Icon(Icons.refresh), + label: const Text('try again'), + ) + ], + ); + } +} diff --git a/lib/widgets/post/post_info_section.dart b/lib/widgets/post/post_info_section.dart index a3b9bd3..75e3f3a 100644 --- a/lib/widgets/post/post_info_section.dart +++ b/lib/widgets/post/post_info_section.dart @@ -3,6 +3,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import '../../l10n/l10n.dart'; +import '../../pages/community/community.dart'; import '../../util/extensions/api.dart'; import '../../util/extensions/datetime.dart'; import '../../util/goto.dart'; @@ -30,8 +31,8 @@ class PostInfoSection extends StatelessWidget { Avatar( url: post.community.icon, padding: const EdgeInsets.only(right: 10), - onTap: () => - goToCommunity.byId(context, instanceHost, post.community.id), + onTap: () => Navigator.of(context).push( + CommunityPage.fromIdRoute(instanceHost, post.community.id)), noBlank: true, radius: 20, ), @@ -56,11 +57,9 @@ class PostInfoSection extends StatelessWidget { text: post.community.name, style: const TextStyle(fontWeight: FontWeight.w600), recognizer: TapGestureRecognizer() - ..onTap = () => goToCommunity.byId( - context, - instanceHost, - post.community.id, - ), + ..onTap = () => Navigator.of(context).push( + CommunityPage.fromIdRoute( + instanceHost, post.community.id)), ), const TextSpan( text: '@', diff --git a/lib/widgets/user_tile.dart b/lib/widgets/user_tile.dart new file mode 100644 index 0000000..2b0a444 --- /dev/null +++ b/lib/widgets/user_tile.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:lemmy_api_client/v3.dart'; + +import '../util/extensions/api.dart'; +import '../util/goto.dart'; +import 'avatar.dart'; +import 'markdown_text.dart'; + +class PersonTile extends StatelessWidget { + final PersonSafe person; + final bool expanded; + const PersonTile( + this.person, { + this.expanded = false, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(person.originPreferredName), + subtitle: person.bio != null && expanded + ? Opacity( + opacity: 0.7, + child: + MarkdownText(person.bio!, instanceHost: person.instanceHost), + ) + : null, + onTap: () => goToUser.fromPersonSafe(context, person), + leading: Avatar(url: person.avatar), + ); + } +} diff --git a/test/util/async_store_test.dart b/test/util/async_store_test.dart new file mode 100644 index 0000000..8bccfa0 --- /dev/null +++ b/test/util/async_store_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lemmur/util/async_store.dart'; +import 'package:lemmy_api_client/v3.dart'; + +void main() { + group('AsyncStore', () { + const instanceHost = 'lemmy.ml'; + const badInstanceHost = 'does.not.exist'; + + test('runLemmy works properly all the way through', () async { + final store = AsyncStore(); + + expect(store.asyncState, isA()); + expect(store.isLoading, false); + expect(store.errorTerm, null); + + final fut = store.runLemmy(instanceHost, const GetPost(id: 91588)); + + expect(store.asyncState, isA()); + expect(store.isLoading, true); + expect(store.errorTerm, null); + + final res = await fut; + + expect(store.asyncState, isA()); + expect(store.isLoading, false); + expect(store.errorTerm, null); + expect(store.asyncState, AsyncState.data(res!)); + }); + + test('fails properly 1', () async { + final store = AsyncStore(); + + expect(store.asyncState, isA()); + expect(store.isLoading, false); + expect(store.errorTerm, null); + + final fut = store.runLemmy(instanceHost, const GetPost(id: 0)); + + expect(store.asyncState, isA()); + expect(store.isLoading, true); + expect(store.errorTerm, null); + + await fut; + + expect(store.asyncState, isA()); + expect(store.isLoading, false); + expect(store.errorTerm, 'couldnt_find_post'); + }); + + test('fails properly 2', () async { + final store = AsyncStore(); + + expect(store.asyncState, isA()); + expect(store.isLoading, false); + expect(store.errorTerm, null); + + final fut = store.runLemmy(badInstanceHost, const GetPost(id: 0)); + + expect(store.asyncState, isA()); + expect(store.isLoading, true); + expect(store.errorTerm, null); + + await fut; + + expect(store.asyncState, isA()); + expect(store.isLoading, false); + expect(store.errorTerm, 'network_error'); + }); + + test('succeeds then fails on refresh, and then succeeds', () async { + final store = AsyncStore(); + + final res = await store.runLemmy(instanceHost, const GetPost(id: 91588)); + + expect(store.asyncState, isA()); + expect(store.errorTerm, null); + + expect(store.asyncState, AsyncState.data(res!)); + + await store.runLemmy( + badInstanceHost, + const GetPost(id: 91588), + refresh: true, + ); + + expect(store.asyncState, isA()); + expect(store.errorTerm, 'network_error'); + expect(store.asyncState, AsyncState.data(res, 'network_error')); + + final res2 = await store.runLemmy( + instanceHost, + const GetPost(id: 91588), + refresh: true, + ); + + expect(store.asyncState, isA()); + expect(store.errorTerm, null); + expect(store.asyncState, AsyncState.data(res2!)); + }); + }); +}