Migrate modlog to mobx (#303)

* Migrate modlog to mobx

* Remove column

* Add MobxProvider and DisposableStore

* Add modlog store tests
This commit is contained in:
Marcin Wojnarowski 2021-12-04 18:03:54 +01:00 committed by GitHub
parent 85f1ab8f99
commit d4d4a5b999
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 910 additions and 610 deletions

View File

@ -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

View File

@ -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<void> mainCommon(AppConfig appConfig) async {
WidgetsFlutterBinding.ensureInitialized();
@ -22,15 +23,14 @@ Future<void> 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(),
),

View File

@ -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<AccountsStore>()

View File

@ -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,

View File

@ -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<CommunityStore>();
showBottomModal(
context: context,
builder: (context) => Provider.value(
builder: (context) => MobxProvider.value(
value: store,
child: CommunityMoreMenu(
fullCommunityView: fullCommunityView,

View File

@ -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._(),

View File

@ -342,6 +342,7 @@ class _SelectedList {
this.instanceHost,
});
@override
String toString() =>
'SelectedList(instanceHost: $instanceHost, listingType: $listingType)';
}

View File

@ -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),
),
),
],

View File

@ -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<ModlogPageStore>(
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<ModlogPageStore>(
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'),
),
);
}
}

View File

@ -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(),
);
}
}

View File

@ -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<Modlog>();
@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<void> fetchPage() async {
await modlogState.runLemmy(
instanceHost,
GetModlog(page: page, communityId: communityId),
);
}
@action
void previousPage() {
page--;
}
@action
void nextPage() {
page++;
}
}

View File

@ -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<bool>? _$hasPreviousPageComputed;
@override
bool get hasPreviousPage =>
(_$hasPreviousPageComputed ??= Computed<bool>(() => super.hasPreviousPage,
name: '_ModlogPageStore.hasPreviousPage'))
.value;
Computed<bool>? _$hasNextPageComputed;
@override
bool get hasNextPage =>
(_$hasNextPageComputed ??= Computed<bool>(() => 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<void> 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}
''';
}
}

View File

@ -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)
],
),
),
);
}
}

View File

@ -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<Modlog>(
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(),
);
}

View File

@ -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<BlocksStore>(
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(),
),

View File

@ -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<String, dynamic>,
).._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 {

View File

@ -13,7 +13,7 @@ class AsyncStore<T> = _AsyncStore<T> with _$AsyncStore<T>;
abstract class _AsyncStore<T> with Store {
@observable
AsyncState<T> asyncState = const AsyncState.initial();
AsyncState<T> asyncState = AsyncState<T>.initial();
@computed
bool get isLoading => asyncState is AsyncStateLoading<T>;
@ -41,7 +41,7 @@ abstract class _AsyncStore<T> with Store {
final data = refresh ? asyncState.mapOrNull(data: (data) => data) : null;
if (data == null) {
asyncState = const AsyncState.loading();
asyncState = AsyncState<T>.loading();
}
try {
@ -61,7 +61,7 @@ abstract class _AsyncStore<T> with Store {
if (data != null) {
asyncState = data.copyWith(errorTerm: err.toString());
} else {
asyncState = AsyncState.error(err.toString());
asyncState = AsyncState<T>.error(err.toString());
}
rethrow;
}
@ -83,10 +83,24 @@ abstract class _AsyncStore<T> with Store {
if (data != null) {
asyncState = data.copyWith(errorTerm: err.message);
} else {
asyncState = AsyncState.error(err.message);
asyncState = AsyncState<T>.error(err.message);
}
}
}
/// helper function for mapping [asyncState] into 3 variants
U map<U>({
required U Function() loading,
required U Function(String errorTerm) error,
required U Function(T data) data,
}) {
return asyncState.when<U>(
initial: loading,
data: (value, errorTerm) => data(value),
loading: loading,
error: error,
);
}
}
/// State in which an async action can be

View File

@ -20,6 +20,7 @@ class AsyncStoreListener<T> extends SingleChildStatelessWidget {
Widget? child,
}) : super(key: key, child: child);
@override
Widget buildWithChild(BuildContext context, Widget? child) {
return ObserverListener<AsyncStore<T>>(
store: asyncStore,

View File

@ -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<T extends Store> extends Provider<T> {
MobxProvider({
Key? key,
required Create<T> 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<ReactionDisposer> _disposers = [];
@protected
void addReaction(ReactionDisposer reaction) => _disposers.add(reaction);
void dispose() {
for (final disposer in _disposers) {
disposer();
}
}
}

View File

@ -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,

View File

@ -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),

View File

@ -35,6 +35,7 @@ Future<void> 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';
}

View File

@ -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<Modlog>.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<Modlog>.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<Modlog>.initial());
});
});
}

View File

@ -98,5 +98,27 @@ void main() {
expect(store.errorTerm, null);
expect(store.asyncState, AsyncState.data(res2!));
});
test('maps states correctly', () {
final store = AsyncStore<int>();
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');
});
});
}