diff --git a/analysis_options.yaml b/analysis_options.yaml index 119cad3..a11c286 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,5 +1,6 @@ linter: rules: + - annotate_overrides - avoid_bool_literals_in_conditional_expressions - avoid_catching_errors - avoid_equals_and_hash_code_on_mutable_classes diff --git a/lib/main_common.dart b/lib/main_common.dart index b5921ef..0dec2f1 100644 --- a/lib/main_common.dart +++ b/lib/main_common.dart @@ -12,6 +12,7 @@ import 'l10n/timeago/pl.dart'; import 'pages/log_console/log_console_page_store.dart'; import 'stores/accounts_store.dart'; import 'stores/config_store.dart'; +import 'util/mobx_provider.dart'; Future mainCommon(AppConfig appConfig) async { WidgetsFlutterBinding.ensureInitialized(); @@ -22,15 +23,14 @@ Future mainCommon(AppConfig appConfig) async { _setupLogger(appConfig, logConsoleStore); _setupTimeago(); - final configStore = ConfigStore.load(sharedPrefs); final accountsStore = await AccountsStore.load(); runApp( MultiProvider( providers: [ - Provider.value(value: configStore), + MobxProvider(create: (context) => ConfigStore.load(sharedPrefs)), ChangeNotifierProvider.value(value: accountsStore), - Provider.value(value: logConsoleStore), + MobxProvider.value(value: logConsoleStore), ], child: const MyApp(), ), diff --git a/lib/pages/community/community.dart b/lib/pages/community/community.dart index c198c3c..a648763 100644 --- a/lib/pages/community/community.dart +++ b/lib/pages/community/community.dart @@ -10,6 +10,7 @@ import '../../util/async_store.dart'; import '../../util/async_store_listener.dart'; import '../../util/extensions/api.dart'; import '../../util/icons.dart'; +import '../../util/mobx_provider.dart'; import '../../util/observer_consumers.dart'; import '../../util/share.dart'; import '../../widgets/failed_to_load.dart'; @@ -158,7 +159,7 @@ class CommunityPage extends HookWidget { static Route _route(String instanceHost, CommunityStore store) { return MaterialPageRoute( builder: (context) { - return Provider.value( + return MobxProvider.value( value: store ..refresh(context .read() diff --git a/lib/pages/community/community_about_tab.dart b/lib/pages/community/community_about_tab.dart index 1e3e897..a9462cc 100644 --- a/lib/pages/community/community_about_tab.dart +++ b/lib/pages/community/community_about_tab.dart @@ -4,13 +4,12 @@ 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/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 '../modlog/modlog.dart'; import 'community_store.dart'; class CommmunityAboutTab extends StatelessWidget { @@ -99,9 +98,8 @@ class CommmunityAboutTab extends StatelessWidget { style: Theme.of(context).textTheme.headline6, ), ), - onTap: () => goTo( - context, - (context) => ModlogPage.forCommunity( + onTap: () => Navigator.of(context).push( + ModlogPage.forCommunityRoute( instanceHost: community.instanceHost, communityId: community.community.id, communityName: community.community.name, diff --git a/lib/pages/community/community_more_menu.dart b/lib/pages/community/community_more_menu.dart index ed951be..a9b464e 100644 --- a/lib/pages/community/community_more_menu.dart +++ b/lib/pages/community/community_more_menu.dart @@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher.dart' as ul; import '../../hooks/logged_in_action.dart'; import '../../util/extensions/api.dart'; +import '../../util/mobx_provider.dart'; import '../../util/observer_consumers.dart'; import '../../widgets/bottom_modal.dart'; import '../../widgets/info_table_popup.dart'; @@ -60,9 +61,10 @@ class CommunityMoreMenu extends HookWidget { static void open(BuildContext context, FullCommunityView fullCommunityView) { final store = context.read(); + showBottomModal( context: context, - builder: (context) => Provider.value( + builder: (context) => MobxProvider.value( value: store, child: CommunityMoreMenu( fullCommunityView: fullCommunityView, diff --git a/lib/pages/full_post/full_post.dart b/lib/pages/full_post/full_post.dart index 8c53a55..59de191 100644 --- a/lib/pages/full_post/full_post.dart +++ b/lib/pages/full_post/full_post.dart @@ -11,6 +11,7 @@ import '../../stores/accounts_store.dart'; import '../../util/async_store_listener.dart'; import '../../util/extensions/api.dart'; import '../../util/icons.dart'; +import '../../util/mobx_provider.dart'; import '../../util/observer_consumers.dart'; import '../../util/share.dart'; import '../../widgets/failed_to_load.dart'; @@ -100,7 +101,7 @@ class FullPostPage extends HookWidget { ), actions: [ IconButton(icon: Icon(shareIcon), onPressed: sharePost), - Provider.value( + MobxProvider.value( value: postStore, child: const SavePostButton(), ), @@ -143,7 +144,7 @@ class FullPostPage extends HookWidget { } static Route route(int id, String instanceHost) => MaterialPageRoute( - builder: (context) => Provider( + builder: (context) => MobxProvider( create: (context) => FullPostStore(instanceHost: instanceHost, postId: id) ..refresh(_tryGetJwt(context, instanceHost)), @@ -152,14 +153,14 @@ class FullPostPage extends HookWidget { ); static Route fromPostViewRoute(PostView postView) => MaterialPageRoute( - builder: (context) => Provider( + builder: (context) => MobxProvider( create: (context) => FullPostStore.fromPostView(postView) ..refresh(_tryGetJwt(context, postView.instanceHost)), child: const FullPostPage._(), ), ); static Route fromPostStoreRoute(PostStore postStore) => MaterialPageRoute( - builder: (context) => Provider( + builder: (context) => MobxProvider( create: (context) => FullPostStore.fromPostStore(postStore) ..refresh(_tryGetJwt(context, postStore.postView.instanceHost)), child: const FullPostPage._(), diff --git a/lib/pages/home_tab.dart b/lib/pages/home_tab.dart index 67ec4c2..f73d07a 100644 --- a/lib/pages/home_tab.dart +++ b/lib/pages/home_tab.dart @@ -342,6 +342,7 @@ class _SelectedList { this.instanceHost, }); + @override String toString() => 'SelectedList(instanceHost: $instanceHost, listingType: $listingType)'; } diff --git a/lib/pages/instance.dart b/lib/pages/instance.dart index 83e9a69..33e5ba3 100644 --- a/lib/pages/instance.dart +++ b/lib/pages/instance.dart @@ -20,7 +20,7 @@ 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 'modlog/modlog.dart'; import 'users_list.dart'; /// Displays posts, comments, and general info about the given instance @@ -365,9 +365,8 @@ class _AboutTab extends HookWidget { ), ListTile( title: Center(child: Text(L10n.of(context).modlog)), - onTap: () => goTo( - context, - (context) => ModlogPage.forInstance(instanceHost: instanceHost), + onTap: () => Navigator.of(context).push( + ModlogPage.forInstanceRoute(instanceHost), ), ), ], diff --git a/lib/pages/modlog/modlog.dart b/lib/pages/modlog/modlog.dart new file mode 100644 index 0000000..b82a984 --- /dev/null +++ b/lib/pages/modlog/modlog.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; + +import '../../l10n/l10n.dart'; +import '../../util/mobx_provider.dart'; +import '../../util/observer_consumers.dart'; +import '../../widgets/bottom_safe.dart'; +import '../../widgets/failed_to_load.dart'; +import 'modlog_page_store.dart'; +import 'modlog_table.dart'; + +class ModlogPage extends StatelessWidget { + final String name; + + const ModlogPage._({required this.name}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("$name's modlog")), + body: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox(), + ObserverBuilder( + builder: (context, store) { + if (!store.hasNextPage) { + return const Center(child: Text('no more logs to show')); + } + + return store.modlogState.map( + loading: () => const Center( + child: CircularProgressIndicator.adaptive(), + ), + error: (errorTerm) => Center( + child: FailedToLoad( + message: errorTerm.tr(context), + refresh: store.fetchPage, + ), + ), + data: (modlog) => ModlogTable(modlog: modlog), + ); + }, + ), + Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: ObserverBuilder( + builder: (context, store) => Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: store.hasPreviousPage && + !store.modlogState.isLoading + ? store.previousPage + : null, + child: const Icon(Icons.skip_previous), + ), + TextButton( + onPressed: store.hasNextPage && + !store.modlogState.isLoading + ? store.nextPage + : null, + child: const Icon(Icons.skip_next), + ), + ], + ), + ), + ), + const BottomSafe(), + ], + ), + ], + ), + ), + ), + ), + ); + } + + static Route forInstanceRoute(String instanceHost) { + return MaterialPageRoute( + builder: (context) => MobxProvider( + create: (context) => ModlogPageStore(instanceHost)..fetchPage(), + child: ModlogPage._(name: instanceHost), + ), + ); + } + + static Route forCommunityRoute({ + required String instanceHost, + required int communityId, + required String communityName, + }) { + return MaterialPageRoute( + builder: (context) => MobxProvider( + create: (context) => + ModlogPageStore(instanceHost, communityId)..fetchPage(), + child: ModlogPage._(name: '!$communityName'), + ), + ); + } +} diff --git a/lib/pages/modlog/modlog_entry.dart b/lib/pages/modlog/modlog_entry.dart new file mode 100644 index 0000000..43f5f9b --- /dev/null +++ b/lib/pages/modlog/modlog_entry.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:lemmy_api_client/v3.dart'; + +import '../../l10n/l10n.dart'; +import '../../util/extensions/api.dart'; +import '../../util/goto.dart'; +import '../../widgets/avatar.dart'; + +class ModlogEntry { + final DateTime when; + final PersonSafe mod; + final Widget action; + final String? reason; + + const ModlogEntry._({ + required this.when, + required this.mod, + required this.action, + this.reason, + }); + + ModlogEntry.fromModRemovePostView( + ModRemovePostView removedPost, + Widget action, + ) : this._( + when: removedPost.modRemovePost.when, + mod: removedPost.moderator, + action: action, + reason: removedPost.modRemovePost.reason, + ); + + ModlogEntry.fromModLockPostView( + ModLockPostView lockedPost, + Widget action, + ) : this._( + when: lockedPost.modLockPost.when, + mod: lockedPost.moderator, + action: action, + ); + + ModlogEntry.fromModStickyPostView( + ModStickyPostView stickiedPost, + Widget action, + ) : this._( + when: stickiedPost.modStickyPost.when, + mod: stickiedPost.moderator, + action: action, + ); + + ModlogEntry.fromModRemoveCommentView( + ModRemoveCommentView removedComment, + Widget action, + ) : this._( + when: removedComment.modRemoveComment.when, + mod: removedComment.moderator, + action: action, + reason: removedComment.modRemoveComment.reason, + ); + + ModlogEntry.fromModRemoveCommunityView( + ModRemoveCommunityView removedCommunity, + Widget action, + ) : this._( + when: removedCommunity.modRemoveCommunity.when, + mod: removedCommunity.moderator, + action: action, + reason: removedCommunity.modRemoveCommunity.reason, + ); + + ModlogEntry.fromModBanFromCommunityView( + ModBanFromCommunityView bannedFromCommunity, + Widget action, + ) : this._( + when: bannedFromCommunity.modBanFromCommunity.when, + mod: bannedFromCommunity.moderator, + action: action, + reason: bannedFromCommunity.modBanFromCommunity.reason, + ); + + ModlogEntry.fromModBanView( + ModBanView banned, + Widget action, + ) : this._( + when: banned.modBan.when, + mod: banned.moderator, + action: action, + reason: banned.modBan.reason, + ); + + ModlogEntry.fromModAddCommunityView( + ModAddCommunityView addedToCommunity, + Widget action, + ) : this._( + when: addedToCommunity.modAddCommunity.when, + mod: addedToCommunity.moderator, + action: action, + ); + + ModlogEntry.fromModTransferCommunityView( + ModTransferCommunityView transferCommunity, + Widget action, + ) : this._( + when: transferCommunity.modTransferCommunity.when, + mod: transferCommunity.moderator, + action: action, + ); + + ModlogEntry.fromModAddView( + ModAddView added, + Widget action, + ) : this._( + when: added.modAdd.when, + mod: added.moderator, + action: action, + ); + + TableRow build(BuildContext context) { + return TableRow( + children: [ + GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (context) => Dialog( + child: Padding( + padding: const EdgeInsets.all(8), + child: Center( + heightFactor: 1, + child: Text(when.toString()), + ), + ), + ), + ); + }, + child: Center(child: Text(when.timeagoShort(context))), + ), + GestureDetector( + onTap: () => goToUser.byId( + context, + mod.instanceHost, + mod.id, + ), + child: Row( + children: [ + Avatar( + url: mod.avatar, + noBlank: true, + radius: 10, + ), + Text( + ' ${mod.preferredName}', + style: + TextStyle(color: Theme.of(context).colorScheme.secondary), + ), + ], + ), + ), + action, + if (reason == null) const Center(child: Text('-')) else Text(reason!), + ] + .map( + (widget) => Padding( + padding: const EdgeInsets.all(8), + child: widget, + ), + ) + .toList(), + ); + } +} diff --git a/lib/pages/modlog/modlog_page_store.dart b/lib/pages/modlog/modlog_page_store.dart new file mode 100644 index 0000000..840c58e --- /dev/null +++ b/lib/pages/modlog/modlog_page_store.dart @@ -0,0 +1,62 @@ +import 'package:lemmy_api_client/v3.dart'; +import 'package:mobx/mobx.dart'; + +import '../../util/async_store.dart'; +import '../../util/mobx_provider.dart'; + +part 'modlog_page_store.g.dart'; + +class ModlogPageStore = _ModlogPageStore with _$ModlogPageStore; + +abstract class _ModlogPageStore with Store, DisposableStore { + final String instanceHost; + final int? communityId; + + _ModlogPageStore(this.instanceHost, [this.communityId]) { + addReaction(reaction((_) => page, (_) => fetchPage())); + } + + @observable + int page = 1; + + final modlogState = AsyncStore(); + + @computed + bool get hasPreviousPage => page != 1; + + @computed + bool get hasNextPage => + modlogState.asyncState.whenOrNull( + data: (data, error) => + data.removedPosts.length + + data.lockedPosts.length + + data.stickiedPosts.length + + data.removedComments.length + + data.removedCommunities.length + + data.bannedFromCommunity.length + + data.banned.length + + data.addedToCommunity.length + + data.transferredToCommunity.length + + data.added.length != + 0, + ) ?? + true; + + @action + Future fetchPage() async { + await modlogState.runLemmy( + instanceHost, + GetModlog(page: page, communityId: communityId), + ); + } + + @action + void previousPage() { + page--; + } + + @action + void nextPage() { + page++; + } +} diff --git a/lib/pages/modlog/modlog_page_store.g.dart b/lib/pages/modlog/modlog_page_store.g.dart new file mode 100644 index 0000000..bbd6216 --- /dev/null +++ b/lib/pages/modlog/modlog_page_store.g.dart @@ -0,0 +1,82 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'modlog_page_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 _$ModlogPageStore on _ModlogPageStore, Store { + Computed? _$hasPreviousPageComputed; + + @override + bool get hasPreviousPage => + (_$hasPreviousPageComputed ??= Computed(() => super.hasPreviousPage, + name: '_ModlogPageStore.hasPreviousPage')) + .value; + Computed? _$hasNextPageComputed; + + @override + bool get hasNextPage => + (_$hasNextPageComputed ??= Computed(() => super.hasNextPage, + name: '_ModlogPageStore.hasNextPage')) + .value; + + final _$pageAtom = Atom(name: '_ModlogPageStore.page'); + + @override + int get page { + _$pageAtom.reportRead(); + return super.page; + } + + @override + set page(int value) { + _$pageAtom.reportWrite(value, super.page, () { + super.page = value; + }); + } + + final _$fetchPageAsyncAction = AsyncAction('_ModlogPageStore.fetchPage'); + + @override + Future fetchPage() { + return _$fetchPageAsyncAction.run(() => super.fetchPage()); + } + + final _$_ModlogPageStoreActionController = + ActionController(name: '_ModlogPageStore'); + + @override + void previousPage() { + final _$actionInfo = _$_ModlogPageStoreActionController.startAction( + name: '_ModlogPageStore.previousPage'); + try { + return super.previousPage(); + } finally { + _$_ModlogPageStoreActionController.endAction(_$actionInfo); + } + } + + @override + void nextPage() { + final _$actionInfo = _$_ModlogPageStoreActionController.startAction( + name: '_ModlogPageStore.nextPage'); + try { + return super.nextPage(); + } finally { + _$_ModlogPageStoreActionController.endAction(_$actionInfo); + } + } + + @override + String toString() { + return ''' +page: ${page}, +hasPreviousPage: ${hasPreviousPage}, +hasNextPage: ${hasNextPage} + '''; + } +} diff --git a/lib/pages/modlog/modlog_table.dart b/lib/pages/modlog/modlog_table.dart new file mode 100644 index 0000000..fb24363 --- /dev/null +++ b/lib/pages/modlog/modlog_table.dart @@ -0,0 +1,310 @@ +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 '../../widgets/avatar.dart'; +import 'modlog_entry.dart'; + +class ModlogTable extends StatelessWidget { + const ModlogTable({Key? key, required this.modlog}) : super(key: key); + + final Modlog modlog; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + InlineSpan user(PersonSafe user) { + return TextSpan( + children: [ + WidgetSpan( + child: Avatar( + url: user.avatar, + noBlank: true, + radius: 10, + ), + ), + TextSpan( + text: ' ${user.preferredName}', + style: TextStyle(color: theme.colorScheme.secondary), + recognizer: TapGestureRecognizer() + ..onTap = () => goToUser.byId( + context, + user.instanceHost, + user.id, + ), + ), + ], + ); + } + + InlineSpan community(CommunitySafe community) { + return TextSpan( + children: [ + WidgetSpan( + child: Avatar( + url: community.icon, + noBlank: true, + radius: 10, + ), + ), + TextSpan( + text: ' !${community.name}', + style: TextStyle(color: theme.colorScheme.secondary), + recognizer: TapGestureRecognizer() + ..onTap = () => goToCommunity.byId( + context, + community.instanceHost, + community.id, + ), + ), + ], + ); + } + + final modlogEntries = [ + for (final removedPost in modlog.removedPosts) + ModlogEntry.fromModRemovePostView( + removedPost, + RichText( + text: TextSpan( + children: [ + if (removedPost.modRemovePost.removed ?? false) + const TextSpan(text: 'removed') + else + const TextSpan(text: 'restored'), + const TextSpan(text: ' post '), + TextSpan( + text: '"${removedPost.post.name}"', + style: TextStyle(color: theme.colorScheme.secondary), + recognizer: TapGestureRecognizer() + ..onTap = () => goToPost( + context, + removedPost.instanceHost, + removedPost.post.id, + ), + ), + ], + style: TextStyle(color: theme.colorScheme.onSurface), + ), + ), + ), + for (final lockedPost in modlog.lockedPosts) + ModlogEntry.fromModLockPostView( + lockedPost, + RichText( + text: TextSpan( + children: [ + if (lockedPost.modLockPost.locked ?? false) + const TextSpan(text: 'locked') + else + const TextSpan(text: 'unlocked'), + const TextSpan(text: ' post '), + TextSpan( + text: '"${lockedPost.post.name}"', + style: TextStyle(color: theme.colorScheme.secondary), + recognizer: TapGestureRecognizer() + ..onTap = () => goToPost( + context, + lockedPost.instanceHost, + lockedPost.post.id, + ), + ), + ], + style: TextStyle(color: theme.colorScheme.onSurface), + ), + ), + ), + for (final stickiedPost in modlog.stickiedPosts) + ModlogEntry.fromModStickyPostView( + stickiedPost, + RichText( + text: TextSpan( + children: [ + if (stickiedPost.modStickyPost.stickied ?? false) + const TextSpan(text: 'stickied') + else + const TextSpan(text: 'unstickied'), + const TextSpan(text: ' post '), + TextSpan( + text: '"${stickiedPost.post.name}"', + style: TextStyle(color: theme.colorScheme.secondary), + recognizer: TapGestureRecognizer() + ..onTap = () => goToPost( + context, + stickiedPost.instanceHost, + stickiedPost.post.id, + ), + ), + ], + style: TextStyle(color: theme.colorScheme.onSurface), + ), + ), + ), + for (final removedComment in modlog.removedComments) + ModlogEntry.fromModRemoveCommentView( + removedComment, + RichText( + text: TextSpan( + children: [ + if (removedComment.modRemoveComment.removed ?? false) + const TextSpan(text: 'removed') + else + const TextSpan(text: 'restored'), + const TextSpan(text: ' comment '), + TextSpan( + text: + '"${removedComment.comment.content.replaceAll('\n', ' ')}"', + style: TextStyle(color: theme.colorScheme.secondary), + recognizer: TapGestureRecognizer() + ..onTap = () => goToPost( + context, + removedComment.instanceHost, + removedComment.post.id, + ), + ), + const TextSpan(text: ' by '), + user(removedComment.commenter), + ], + style: TextStyle(color: theme.colorScheme.onSurface), + ), + ), + ), + for (final removedCommunity in modlog.removedCommunities) + ModlogEntry.fromModRemoveCommunityView( + removedCommunity, + RichText( + text: TextSpan( + children: [ + if (removedCommunity.modRemoveCommunity.removed ?? false) + const TextSpan(text: 'removed') + else + const TextSpan(text: 'restored'), + const TextSpan(text: ' community '), + community(removedCommunity.community), + ], + style: TextStyle(color: theme.colorScheme.onSurface), + ), + ), + ), + for (final bannedFromCommunity in modlog.bannedFromCommunity) + ModlogEntry.fromModBanFromCommunityView( + bannedFromCommunity, + RichText( + text: TextSpan( + children: [ + if (bannedFromCommunity.modBanFromCommunity.banned ?? false) + const TextSpan(text: 'banned ') + else + const TextSpan(text: 'unbanned '), + user(bannedFromCommunity.bannedPerson), + const TextSpan(text: ' from community '), + community(bannedFromCommunity.community), + ], + style: TextStyle(color: theme.colorScheme.onSurface), + ), + ), + ), + for (final banned in modlog.banned) + ModlogEntry.fromModBanView( + banned, + RichText( + text: TextSpan( + children: [ + if (banned.modBan.banned ?? false) + const TextSpan(text: 'banned ') + else + const TextSpan(text: 'unbanned '), + user(banned.bannedPerson), + ], + style: TextStyle(color: theme.colorScheme.onSurface), + ), + ), + ), + for (final addedToCommunity in modlog.addedToCommunity) + ModlogEntry.fromModAddCommunityView( + addedToCommunity, + RichText( + text: TextSpan( + children: [ + if (addedToCommunity.modAddCommunity.removed ?? false) + const TextSpan(text: 'removed ') + else + const TextSpan(text: 'appointed '), + user(addedToCommunity.moddedPerson), + const TextSpan(text: ' as mod of '), + community(addedToCommunity.community), + ], + style: TextStyle(color: theme.colorScheme.onSurface), + ), + ), + ), + for (final transferredToCommunity in modlog.transferredToCommunity) + ModlogEntry.fromModTransferCommunityView( + transferredToCommunity, + RichText( + text: TextSpan( + children: [ + if (transferredToCommunity.modTransferCommunity.removed ?? + false) + const TextSpan(text: 'removed ') + else + const TextSpan(text: 'transferred '), + community(transferredToCommunity.community), + const TextSpan(text: ' to '), + user(transferredToCommunity.moddedPerson), + ], + style: TextStyle(color: theme.colorScheme.onSurface), + ), + ), + ), + for (final added in modlog.added) + ModlogEntry.fromModAddView( + added, + RichText( + text: TextSpan( + children: [ + if (added.modAdd.removed ?? false) + const TextSpan(text: 'removed ') + else + const TextSpan(text: 'apointed '), + user(added.moddedPerson), + const TextSpan(text: ' as admin'), + ], + style: TextStyle(color: theme.colorScheme.onSurface), + ), + ), + ), + ]..sort((a, b) => b.when.compareTo(a.when)); + + return SingleChildScrollView( + padding: const EdgeInsets.all(8), + scrollDirection: Axis.horizontal, + child: SizedBox( + width: 1000, + child: Table( + border: TableBorder.all(color: theme.colorScheme.onSurface), + columnWidths: const { + 0: FixedColumnWidth(80), + 1: FixedColumnWidth(200), + 2: FlexColumnWidth(), + 3: FixedColumnWidth(200), + }, + children: [ + const TableRow( + children: [ + Center(child: Text('when')), + Center(child: Text('mod')), + Center(child: Text('action')), + Center(child: Text('reason')), + ], + ), + for (final modlogEntry in modlogEntries) modlogEntry.build(context) + ], + ), + ), + ); + } +} diff --git a/lib/pages/modlog_page.dart b/lib/pages/modlog_page.dart deleted file mode 100644 index 34b0af9..0000000 --- a/lib/pages/modlog_page.dart +++ /dev/null @@ -1,574 +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 '../l10n/l10n.dart'; -import '../util/extensions/api.dart'; -import '../util/goto.dart'; -import '../widgets/avatar.dart'; -import '../widgets/bottom_safe.dart'; - -class ModlogPage extends HookWidget { - final String instanceHost; - final String name; - final int? communityId; - - const ModlogPage.forInstance({ - required this.instanceHost, - }) : communityId = null, - name = instanceHost; - - const ModlogPage.forCommunity({ - required this.instanceHost, - required int this.communityId, - required String communityName, - }) : name = '!$communityName'; - - @override - Widget build(BuildContext context) { - final page = useState(1); - // will be set true when a fetch returns 0 results - final isDone = useState(false); - - final modlogFut = useMemoized( - () => LemmyApiV3(instanceHost).run( - GetModlog( - communityId: communityId, - page: page.value, - ), - ), - [page.value], - ); - - return Scaffold( - appBar: AppBar(title: Text("$name's modlog")), - body: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const SizedBox.shrink(), - FutureBuilder( - key: ValueKey(modlogFut), - future: modlogFut, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator.adaptive()); - } else if (snapshot.hasError) { - return Center( - child: Text('Error: ${snapshot.error?.toString()}')); - } - final modlog = snapshot.requireData; - - if (modlog.added.length + - modlog.addedToCommunity.length + - modlog.banned.length + - modlog.bannedFromCommunity.length + - modlog.lockedPosts.length + - modlog.removedComments.length + - modlog.removedCommunities.length + - modlog.removedPosts.length + - modlog.stickiedPosts.length == - 0) { - WidgetsBinding.instance - ?.addPostFrameCallback((_) => isDone.value = true); - - return const Center(child: Text('no more logs to show')); - } - - return _ModlogTable(modlog: modlog); - }, - ), - Column( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton( - onPressed: - page.value != 1 ? () => page.value-- : null, - child: const Icon(Icons.skip_previous), - ), - TextButton( - onPressed: isDone.value ? null : () => page.value++, - child: const Icon(Icons.skip_next), - ), - ], - ), - ), - const BottomSafe(), - ], - ), - ], - ), - ), - ), - ), - ); - } -} - -class _ModlogTable extends StatelessWidget { - const _ModlogTable({Key? key, required this.modlog}) : super(key: key); - - final Modlog modlog; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - InlineSpan user(PersonSafe user) => TextSpan( - children: [ - WidgetSpan( - child: Avatar( - url: user.avatar, - noBlank: true, - radius: 10, - ), - ), - TextSpan( - text: ' ${user.preferredName}', - style: TextStyle(color: theme.colorScheme.secondary), - recognizer: TapGestureRecognizer() - ..onTap = () => goToUser.byId( - context, - user.instanceHost, - user.id, - ), - ), - ], - ); - - InlineSpan community(CommunitySafe community) => TextSpan( - children: [ - WidgetSpan( - child: Avatar( - url: community.icon, - noBlank: true, - radius: 10, - ), - ), - TextSpan( - text: ' !${community.name}', - style: TextStyle(color: theme.colorScheme.secondary), - recognizer: TapGestureRecognizer() - ..onTap = () => goToCommunity.byId( - context, - community.instanceHost, - community.id, - ), - ), - ], - ); - - final modlogEntries = [ - for (final removedPost in modlog.removedPosts) - _ModlogEntry.fromModRemovePostView( - removedPost, - RichText( - text: TextSpan( - children: [ - if (removedPost.modRemovePost.removed ?? false) - const TextSpan(text: 'removed') - else - const TextSpan(text: 'restored'), - const TextSpan(text: ' post '), - TextSpan( - text: '"${removedPost.post.name}"', - style: TextStyle(color: theme.colorScheme.secondary), - recognizer: TapGestureRecognizer() - ..onTap = () => goToPost( - context, - removedPost.instanceHost, - removedPost.post.id, - ), - ), - ], - style: TextStyle(color: theme.colorScheme.onSurface), - ), - ), - ), - for (final lockedPost in modlog.lockedPosts) - _ModlogEntry.fromModLockPostView( - lockedPost, - RichText( - text: TextSpan( - children: [ - if (lockedPost.modLockPost.locked ?? false) - const TextSpan(text: 'locked') - else - const TextSpan(text: 'unlocked'), - const TextSpan(text: ' post '), - TextSpan( - text: '"${lockedPost.post.name}"', - style: TextStyle(color: theme.colorScheme.secondary), - recognizer: TapGestureRecognizer() - ..onTap = () => goToPost( - context, - lockedPost.instanceHost, - lockedPost.post.id, - ), - ), - ], - style: TextStyle(color: theme.colorScheme.onSurface), - ), - ), - ), - for (final stickiedPost in modlog.stickiedPosts) - _ModlogEntry.fromModStickyPostView( - stickiedPost, - RichText( - text: TextSpan( - children: [ - if (stickiedPost.modStickyPost.stickied ?? false) - const TextSpan(text: 'stickied') - else - const TextSpan(text: 'unstickied'), - const TextSpan(text: ' post '), - TextSpan( - text: '"${stickiedPost.post.name}"', - style: TextStyle(color: theme.colorScheme.secondary), - recognizer: TapGestureRecognizer() - ..onTap = () => goToPost( - context, - stickiedPost.instanceHost, - stickiedPost.post.id, - ), - ), - ], - style: TextStyle(color: theme.colorScheme.onSurface), - ), - ), - ), - for (final removedComment in modlog.removedComments) - _ModlogEntry.fromModRemoveCommentView( - removedComment, - RichText( - text: TextSpan( - children: [ - if (removedComment.modRemoveComment.removed ?? false) - const TextSpan(text: 'removed') - else - const TextSpan(text: 'restored'), - const TextSpan(text: ' comment '), - TextSpan( - text: - '"${removedComment.comment.content.replaceAll('\n', ' ')}"', - style: TextStyle(color: theme.colorScheme.secondary), - recognizer: TapGestureRecognizer() - ..onTap = () => goToPost( - context, - removedComment.instanceHost, - removedComment.post.id, - ), - ), - const TextSpan(text: ' by '), - user(removedComment.commenter), - ], - style: TextStyle(color: theme.colorScheme.onSurface), - ), - ), - ), - for (final removedCommunity in modlog.removedCommunities) - _ModlogEntry.fromModRemoveCommunityView( - removedCommunity, - RichText( - text: TextSpan( - children: [ - if (removedCommunity.modRemoveCommunity.removed ?? false) - const TextSpan(text: 'removed') - else - const TextSpan(text: 'restored'), - const TextSpan(text: ' community '), - community(removedCommunity.community), - ], - style: TextStyle(color: theme.colorScheme.onSurface), - ), - ), - ), - for (final bannedFromCommunity in modlog.bannedFromCommunity) - _ModlogEntry.fromModBanFromCommunityView( - bannedFromCommunity, - RichText( - text: TextSpan( - children: [ - if (bannedFromCommunity.modBanFromCommunity.banned ?? false) - const TextSpan(text: 'banned ') - else - const TextSpan(text: 'unbanned '), - user(bannedFromCommunity.bannedPerson), - const TextSpan(text: ' from community '), - community(bannedFromCommunity.community), - ], - style: TextStyle(color: theme.colorScheme.onSurface), - ), - ), - ), - for (final banned in modlog.banned) - _ModlogEntry.fromModBanView( - banned, - RichText( - text: TextSpan( - children: [ - if (banned.modBan.banned ?? false) - const TextSpan(text: 'banned ') - else - const TextSpan(text: 'unbanned '), - user(banned.bannedPerson), - ], - style: TextStyle(color: theme.colorScheme.onSurface), - ), - ), - ), - for (final addedToCommunity in modlog.addedToCommunity) - _ModlogEntry.fromModAddCommunityView( - addedToCommunity, - RichText( - text: TextSpan( - children: [ - if (addedToCommunity.modAddCommunity.removed ?? false) - const TextSpan(text: 'removed ') - else - const TextSpan(text: 'appointed '), - user(addedToCommunity.moddedPerson), - const TextSpan(text: ' as mod of '), - community(addedToCommunity.community), - ], - style: TextStyle(color: theme.colorScheme.onSurface), - ), - ), - ), - for (final transferredToCommunity in modlog.transferredToCommunity) - _ModlogEntry.fromModTransferCommunityView( - transferredToCommunity, - RichText( - text: TextSpan( - children: [ - if (transferredToCommunity.modTransferCommunity.removed ?? - false) - const TextSpan(text: 'removed ') - else - const TextSpan(text: 'transferred '), - community(transferredToCommunity.community), - const TextSpan(text: ' to '), - user(transferredToCommunity.moddedPerson), - ], - style: TextStyle(color: theme.colorScheme.onSurface), - ), - ), - ), - for (final added in modlog.added) - _ModlogEntry.fromModAddView( - added, - RichText( - text: TextSpan( - children: [ - if (added.modAdd.removed ?? false) - const TextSpan(text: 'removed ') - else - const TextSpan(text: 'apointed '), - user(added.moddedPerson), - const TextSpan(text: ' as admin'), - ], - style: TextStyle(color: theme.colorScheme.onSurface), - ), - ), - ), - ]..sort((a, b) => b.when.compareTo(a.when)); - - return SingleChildScrollView( - padding: const EdgeInsets.all(8), - scrollDirection: Axis.horizontal, - child: SizedBox( - width: 1000, - child: Table( - border: TableBorder.all(color: theme.colorScheme.onSurface), - columnWidths: const { - 0: FixedColumnWidth(80), - 1: FixedColumnWidth(200), - 2: FlexColumnWidth(), - 3: FixedColumnWidth(200), - }, - children: [ - const TableRow( - children: [ - Center(child: Text('when')), - Center(child: Text('mod')), - Center(child: Text('action')), - Center(child: Text('reason')), - ], - ), - for (final modlogEntry in modlogEntries) modlogEntry.build(context) - ], - ), - ), - ); - } -} - -class _ModlogEntry { - final DateTime when; - final PersonSafe mod; - final Widget action; - final String? reason; - - const _ModlogEntry({ - required this.when, - required this.mod, - required this.action, - this.reason, - }); - - _ModlogEntry.fromModRemovePostView( - ModRemovePostView removedPost, - Widget action, - ) : this( - when: removedPost.modRemovePost.when, - mod: removedPost.moderator, - action: action, - reason: removedPost.modRemovePost.reason, - ); - - _ModlogEntry.fromModLockPostView( - ModLockPostView lockedPost, - Widget action, - ) : this( - when: lockedPost.modLockPost.when, - mod: lockedPost.moderator, - action: action, - ); - - _ModlogEntry.fromModStickyPostView( - ModStickyPostView stickiedPost, - Widget action, - ) : this( - when: stickiedPost.modStickyPost.when, - mod: stickiedPost.moderator, - action: action, - ); - - _ModlogEntry.fromModRemoveCommentView( - ModRemoveCommentView removedComment, - Widget action, - ) : this( - when: removedComment.modRemoveComment.when, - mod: removedComment.moderator, - action: action, - reason: removedComment.modRemoveComment.reason, - ); - - _ModlogEntry.fromModRemoveCommunityView( - ModRemoveCommunityView removedCommunity, - Widget action, - ) : this( - when: removedCommunity.modRemoveCommunity.when, - mod: removedCommunity.moderator, - action: action, - reason: removedCommunity.modRemoveCommunity.reason, - ); - - _ModlogEntry.fromModBanFromCommunityView( - ModBanFromCommunityView bannedFromCommunity, - Widget action, - ) : this( - when: bannedFromCommunity.modBanFromCommunity.when, - mod: bannedFromCommunity.moderator, - action: action, - reason: bannedFromCommunity.modBanFromCommunity.reason, - ); - - _ModlogEntry.fromModBanView( - ModBanView banned, - Widget action, - ) : this( - when: banned.modBan.when, - mod: banned.moderator, - action: action, - reason: banned.modBan.reason, - ); - - _ModlogEntry.fromModAddCommunityView( - ModAddCommunityView addedToCommunity, - Widget action, - ) : this( - when: addedToCommunity.modAddCommunity.when, - mod: addedToCommunity.moderator, - action: action, - ); - - _ModlogEntry.fromModTransferCommunityView( - ModTransferCommunityView transferCommunity, - Widget action, - ) : this( - when: transferCommunity.modTransferCommunity.when, - mod: transferCommunity.moderator, - action: action, - ); - - _ModlogEntry.fromModAddView( - ModAddView added, - Widget action, - ) : this( - when: added.modAdd.when, - mod: added.moderator, - action: action, - ); - - TableRow build(BuildContext context) => TableRow( - children: [ - GestureDetector( - onTap: () { - showDialog( - context: context, - builder: (context) => Dialog( - child: Padding( - padding: const EdgeInsets.all(8), - child: Center( - heightFactor: 1, - child: Text(when.toString()), - ), - ), - ), - ); - }, - child: Center(child: Text(when.timeagoShort(context))), - ), - GestureDetector( - onTap: () => goToUser.byId( - context, - mod.instanceHost, - mod.id, - ), - child: Row( - children: [ - Avatar( - url: mod.avatar, - noBlank: true, - radius: 10, - ), - Text( - ' ${mod.preferredName}', - style: - TextStyle(color: Theme.of(context).colorScheme.secondary), - ), - ], - ), - ), - action, - if (reason == null) const Center(child: Text('-')) else Text(reason!), - ] - .map( - (widget) => Padding( - padding: const EdgeInsets.all(8), - child: widget, - ), - ) - .toList(), - ); -} diff --git a/lib/pages/settings/blocks/blocks.dart b/lib/pages/settings/blocks/blocks.dart index 86ba9ae..9ebd3ba 100644 --- a/lib/pages/settings/blocks/blocks.dart +++ b/lib/pages/settings/blocks/blocks.dart @@ -7,6 +7,7 @@ import '../../../hooks/stores.dart'; import '../../../l10n/l10n_from_string.dart'; import '../../../stores/accounts_store.dart'; import '../../../util/async_store_listener.dart'; +import '../../../util/mobx_provider.dart'; import '../../../util/observer_consumers.dart'; import '../../../widgets/pull_to_refresh.dart'; import 'block_dialog.dart'; @@ -61,7 +62,7 @@ class _UserBlocksWrapper extends StatelessWidget { @override Widget build(BuildContext context) { - return Provider( + return MobxProvider( create: (context) => BlocksStore( instanceHost: instanceHost, token: context @@ -119,8 +120,8 @@ class _UserBlocks extends HookWidget { ) ] else ...[ for (final user in store.blockedUsers!) - Provider( - create: (context) => user, + MobxProvider.value( + value: user, key: ValueKey(user), child: const BlockPersonTile(), ), @@ -153,8 +154,8 @@ class _UserBlocks extends HookWidget { ), const Divider(), for (final community in store.blockedCommunities!) - Provider( - create: (context) => community, + MobxProvider.value( + value: community, key: ValueKey(community), child: const BlockCommunityTile(), ), diff --git a/lib/stores/config_store.dart b/lib/stores/config_store.dart index a684aea..03548e9 100644 --- a/lib/stores/config_store.dart +++ b/lib/stores/config_store.dart @@ -8,16 +8,16 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../l10n/l10n.dart'; import '../util/async_store.dart'; +import '../util/mobx_provider.dart'; part 'config_store.g.dart'; /// Store managing user-level configuration such as theme or language @JsonSerializable() @LocaleConverter() -class ConfigStore extends _ConfigStore with _$ConfigStore { +class ConfigStore extends _ConfigStore with _$ConfigStore, DisposableStore { static const _prefsKey = 'v1:ConfigStore'; late final SharedPreferences _sharedPrefs; - late final ReactionDisposer _saveDisposer; @visibleForTesting ConfigStore(); @@ -28,7 +28,7 @@ class ConfigStore extends _ConfigStore with _$ConfigStore { as Map, ).._sharedPrefs = sharedPrefs; - store._saveDisposer = autorun((_) => store.save()); + store.addReaction(autorun((_) => store.save())); return store; } @@ -38,10 +38,6 @@ class ConfigStore extends _ConfigStore with _$ConfigStore { await _sharedPrefs.setString(_prefsKey, serialized); } - - void dispose() { - _saveDisposer(); - } } abstract class _ConfigStore with Store { diff --git a/lib/util/async_store.dart b/lib/util/async_store.dart index ca49bc9..10c0be2 100644 --- a/lib/util/async_store.dart +++ b/lib/util/async_store.dart @@ -13,7 +13,7 @@ class AsyncStore = _AsyncStore with _$AsyncStore; abstract class _AsyncStore with Store { @observable - AsyncState asyncState = const AsyncState.initial(); + AsyncState asyncState = AsyncState.initial(); @computed bool get isLoading => asyncState is AsyncStateLoading; @@ -41,7 +41,7 @@ abstract class _AsyncStore with Store { final data = refresh ? asyncState.mapOrNull(data: (data) => data) : null; if (data == null) { - asyncState = const AsyncState.loading(); + asyncState = AsyncState.loading(); } try { @@ -61,7 +61,7 @@ abstract class _AsyncStore with Store { if (data != null) { asyncState = data.copyWith(errorTerm: err.toString()); } else { - asyncState = AsyncState.error(err.toString()); + asyncState = AsyncState.error(err.toString()); } rethrow; } @@ -83,10 +83,24 @@ abstract class _AsyncStore with Store { if (data != null) { asyncState = data.copyWith(errorTerm: err.message); } else { - asyncState = AsyncState.error(err.message); + asyncState = AsyncState.error(err.message); } } } + + /// helper function for mapping [asyncState] into 3 variants + U map({ + required U Function() loading, + required U Function(String errorTerm) error, + required U Function(T data) data, + }) { + return asyncState.when( + initial: loading, + data: (value, errorTerm) => data(value), + loading: loading, + error: error, + ); + } } /// State in which an async action can be diff --git a/lib/util/async_store_listener.dart b/lib/util/async_store_listener.dart index 6e77878..e4b77bc 100644 --- a/lib/util/async_store_listener.dart +++ b/lib/util/async_store_listener.dart @@ -20,6 +20,7 @@ class AsyncStoreListener extends SingleChildStatelessWidget { Widget? child, }) : super(key: key, child: child); + @override Widget buildWithChild(BuildContext context, Widget? child) { return ObserverListener>( store: asyncStore, diff --git a/lib/util/mobx_provider.dart b/lib/util/mobx_provider.dart new file mode 100644 index 0000000..7afca1d --- /dev/null +++ b/lib/util/mobx_provider.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:mobx/mobx.dart'; + +import 'observer_consumers.dart'; + +/// Provides a mobx store and disposes it if it implements [DisposableStore] +/// +/// Important: this will not make [context.watch] react to changes +class MobxProvider extends Provider { + MobxProvider({ + Key? key, + required Create create, + bool? lazy, + TransitionBuilder? builder, + Widget? child, + }) : super( + key: key, + create: create, + dispose: (context, store) { + if (store is DisposableStore) store.dispose(); + }, + lazy: lazy, + builder: builder, + child: child, + ); + + /// will not dispose the store + MobxProvider.value({ + Key? key, + required T value, + TransitionBuilder? builder, + Widget? child, + }) : super.value( + key: key, + builder: builder, + value: value, + child: child, + ); +} + +/// tracks reactions and disposes them in [DisposableStore.dispose] +mixin DisposableStore on Store { + final List _disposers = []; + + @protected + void addReaction(ReactionDisposer reaction) => _disposers.add(reaction); + + void dispose() { + for (final disposer in _disposers) { + disposer(); + } + } +} diff --git a/lib/widgets/comment/comment.dart b/lib/widgets/comment/comment.dart index 4adb3a6..22501b5 100644 --- a/lib/widgets/comment/comment.dart +++ b/lib/widgets/comment/comment.dart @@ -11,6 +11,7 @@ import '../../util/async_store_listener.dart'; import '../../util/extensions/api.dart'; import '../../util/extensions/cake_day.dart'; import '../../util/goto.dart'; +import '../../util/mobx_provider.dart'; import '../../util/observer_consumers.dart'; import '../../util/text_color.dart'; import '../avatar.dart'; @@ -78,7 +79,7 @@ class CommentWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return Provider( + return MobxProvider( create: (context) => CommentStore( context.read(), commentTree: commentTree, diff --git a/lib/widgets/post/post.dart b/lib/widgets/post/post.dart index 54b2a28..3fc21af 100644 --- a/lib/widgets/post/post.dart +++ b/lib/widgets/post/post.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import '../../pages/full_post/full_post.dart'; import '../../util/async_store_listener.dart'; import '../../util/extensions/api.dart'; +import '../../util/mobx_provider.dart'; import 'post_actions.dart'; import 'post_body.dart'; import 'post_info_section.dart'; @@ -28,7 +29,7 @@ class PostTile extends StatelessWidget { Widget build(BuildContext context) { return Nested( children: [ - Provider.value(value: postStore), + MobxProvider.value(value: postStore), Provider.value(value: fullPost), AsyncStoreListener(asyncStore: postStore.savingState), AsyncStoreListener(asyncStore: postStore.votingState), diff --git a/scripts/release.dart b/scripts/release.dart index 5771c9e..c6a689a 100755 --- a/scripts/release.dart +++ b/scripts/release.dart @@ -35,6 +35,7 @@ Future assertNoStagedGit() async { class Version { final int major, minor, patch, code; Version(this.major, this.minor, this.patch, this.code); + @override String toString() => '$major.$minor.$patch+$code'; String toStringNoCode() => '$major.$minor.$patch'; } diff --git a/test/pages/modlog/modlog_page_test.dart b/test/pages/modlog/modlog_page_test.dart new file mode 100644 index 0000000..62face3 --- /dev/null +++ b/test/pages/modlog/modlog_page_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lemmur/pages/modlog/modlog_page_store.dart'; +import 'package:lemmur/util/async_store.dart'; +import 'package:lemmy_api_client/v3.dart'; + +void main() { + group('ModlogPageStore', () { + late ModlogPageStore store; + const instanceHost = 'lemmy.ml'; + + setUp(() { + store = ModlogPageStore(instanceHost); + }); + tearDown(() { + store.dispose(); + }); + + test('Initial states are correct', () { + expect(store.communityId, null); + expect(store.instanceHost, instanceHost); + expect(store.hasNextPage, true); + expect(store.hasPreviousPage, false); + expect(store.modlogState.asyncState, const AsyncState.initial()); + expect(store.page, 1); + }); + + test('Fetches a page when changed', () { + store.nextPage(); + + expect(store.page, 2); + expect(store.hasPreviousPage, true); + expect(store.modlogState.asyncState, const AsyncState.loading()); + + store.previousPage(); + + expect(store.page, 1); + expect(store.hasPreviousPage, false); + }); + + test('Stops listening after disposal', () { + store + ..dispose() + ..nextPage(); + + expect(store.page, 2); + expect(store.hasPreviousPage, true); + expect(store.modlogState.asyncState, const AsyncState.initial()); + }); + }); +} diff --git a/test/util/async_store_test.dart b/test/util/async_store_test.dart index 8bccfa0..66bcac1 100644 --- a/test/util/async_store_test.dart +++ b/test/util/async_store_test.dart @@ -98,5 +98,27 @@ void main() { expect(store.errorTerm, null); expect(store.asyncState, AsyncState.data(res2!)); }); + + test('maps states correctly', () { + final store = AsyncStore(); + + loading() => 'loading'; + data(data) => 'data'; + error(error) => 'error'; + + expect(store.map(loading: loading, error: error, data: data), 'loading'); + + store.asyncState = const AsyncState.loading(); + expect(store.map(loading: loading, error: error, data: data), 'loading'); + + store.asyncState = const AsyncState.data(123); + expect(store.map(loading: loading, error: error, data: data), 'data'); + + store.asyncState = const AsyncState.data(123, 'error'); + expect(store.map(loading: loading, error: error, data: data), 'data'); + + store.asyncState = const AsyncState.error('error'); + expect(store.map(loading: loading, error: error, data: data), 'error'); + }); }); }