refactor community page to use mobx (#299)

This commit is contained in:
Filip Krawczyk 2021-11-25 18:12:36 +01:00 committed by GitHub
parent f0bfa9e5cf
commit 95d8ee7fa7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 998 additions and 636 deletions

View File

@ -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) => <Widget>[
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<CommunityModeratorView>? 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),
),
),
),
);
}
}

View File

@ -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<CommunityStore>().communityState),
AsyncStoreListener(
asyncStore: context.read<CommunityStore>().subscribingState),
AsyncStoreListener(
asyncStore: context.read<CommunityStore>().blockingState,
successMessageBuilder: (context, BlockedCommunity data) {
final name = data.communityView.community.preferredName;
final blocked = data.blocked ? 'blocked' : 'unblocked';
return '$name $blocked';
},
),
],
child: ObserverBuilder<CommunityStore>(builder: (context, store) {
final communityState = store.communityState;
final communityAsyncState = communityState.asyncState;
// FALLBACK
if (communityAsyncState is! AsyncStateData<FullCommunityView>) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: (communityState.errorTerm != null)
? FailedToLoad(
refresh: () => store.refresh(context
.read<AccountsStore>()
.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<AccountsStore>()
.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),
);
}
}

View File

@ -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<CommunityStore>().refresh(context
.read<AccountsStore>()
.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(),
);
}

View File

@ -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<CommunityStore>().instanceHost);
return ObserverBuilder<CommunityStore>(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: <Widget>[
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))
],
),
)),
),
);
});
}
}

View File

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

View File

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

View File

@ -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<FullCommunityView>();
final subscribingState = AsyncStore<CommunityView>();
final blockingState = AsyncStore<BlockedCommunity>();
@action
Future<void> refresh(Jwt? token) async {
await communityState.runLemmy(
instanceHost,
GetCommunity(
auth: token?.raw,
id: id,
name: communityName,
),
refresh: true,
);
}
Future<void> block(Jwt token) async {
final state = communityState.asyncState;
if (state is! AsyncStateData<FullCommunityView>) {
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<void> subscribe(Jwt token) async {
final state = communityState.asyncState;
if (state is! AsyncStateData<FullCommunityView>) {
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));
}
}
}

View File

@ -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<void> refresh(Jwt? token) {
return _$refreshAsyncAction.run(() => super.refresh(token));
}
final _$subscribeAsyncAction = AsyncAction('_CommunityStore.subscribe');
@override
Future<void> subscribe(Jwt token) {
return _$subscribeAsyncAction.run(() => super.subscribe(token));
}
@override
String toString() {
return '''
''';
}
}

View File

@ -7,7 +7,7 @@ import '../../util/observer_consumers.dart';
import '../../widgets/bottom_modal.dart'; import '../../widgets/bottom_modal.dart';
import '../../widgets/bottom_safe.dart'; import '../../widgets/bottom_safe.dart';
import '../../widgets/comment/comment.dart'; import '../../widgets/comment/comment.dart';
import 'full_post.dart'; import '../../widgets/failed_to_load.dart';
import 'full_post_store.dart'; import 'full_post_store.dart';
class _SortSelection { class _SortSelection {

View File

@ -13,6 +13,7 @@ import '../../util/extensions/api.dart';
import '../../util/icons.dart'; import '../../util/icons.dart';
import '../../util/observer_consumers.dart'; import '../../util/observer_consumers.dart';
import '../../util/share.dart'; import '../../util/share.dart';
import '../../widgets/failed_to_load.dart';
import '../../widgets/post/post.dart'; import '../../widgets/post/post.dart';
import '../../widgets/post/post_more_menu.dart'; import '../../widgets/post/post_more_menu.dart';
import '../../widgets/post/post_store.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'),
)
],
);
}
}

View File

@ -5,7 +5,6 @@ import 'package:url_launcher/url_launcher.dart' as ul;
import '../hooks/stores.dart'; import '../hooks/stores.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../util/extensions/api.dart';
import '../util/extensions/spaced.dart'; import '../util/extensions/spaced.dart';
import '../util/goto.dart'; import '../util/goto.dart';
import '../util/icons.dart'; import '../util/icons.dart';
@ -19,6 +18,7 @@ import '../widgets/info_table_popup.dart';
import '../widgets/markdown_text.dart'; import '../widgets/markdown_text.dart';
import '../widgets/reveal_after_scroll.dart'; import '../widgets/reveal_after_scroll.dart';
import '../widgets/sortable_infinite_list.dart'; import '../widgets/sortable_infinite_list.dart';
import '../widgets/user_tile.dart';
import 'communities_list.dart'; import 'communities_list.dart';
import 'modlog_page.dart'; import 'modlog_page.dart';
import 'users_list.dart'; import 'users_list.dart';
@ -354,13 +354,9 @@ class _AboutTab extends HookWidget {
), ),
), ),
for (final u in site.admins) for (final u in site.admins)
ListTile( PersonTile(
title: Text(u.person.originPreferredName), u.person,
subtitle: u.person.bio != null expanded: true,
? MarkdownText(u.person.bio!, instanceHost: instanceHost)
: null,
onTap: () => goToUser.fromPersonSafe(context, u.person),
leading: Avatar(url: u.person.avatar),
), ),
const _Divider(), const _Divider(),
ListTile( ListTile(

View File

@ -42,7 +42,7 @@ class UsersListItem extends StatelessWidget {
title: Text(user.person.originPreferredName), title: Text(user.person.originPreferredName),
subtitle: user.person.bio != null subtitle: user.person.bio != null
? Opacity( ? Opacity(
opacity: 0.5, opacity: 0.7,
child: MarkdownText( child: MarkdownText(
user.person.bio!, user.person.bio!,
instanceHost: user.instanceHost, instanceHost: user.instanceHost,

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart' as ul; import 'package:url_launcher/url_launcher.dart' as ul;
import 'pages/community.dart'; import 'pages/community/community.dart';
import 'pages/instance.dart'; import 'pages/instance.dart';
import 'pages/media_view.dart'; import 'pages/media_view.dart';
import 'pages/user.dart'; import 'pages/user.dart';
@ -16,7 +16,7 @@ Future<void> linkLauncher({
required String url, required String url,
required String instanceHost, required String instanceHost,
}) async { }) async {
push(Widget Function() builder) { void push(Widget Function() builder) {
goTo(context, (c) => builder()); goTo(context, (c) => builder());
} }
@ -33,8 +33,9 @@ Future<void> linkLauncher({
// CHECK IF LINK TO COMMUNITY // CHECK IF LINK TO COMMUNITY
if (url.startsWith('/c/')) { if (url.startsWith('/c/')) {
return push(() => CommunityPage.fromName( await Navigator.of(context)
communityName: chonks[2], instanceHost: instanceHost)); .push(CommunityPage.fromNameRoute(instanceHost, chonks[2]));
return;
} }
// CHECK IF REDIRECTS TO A PAGE ON ONE OF ADDED INSTANCES // CHECK IF REDIRECTS TO A PAGE ON ONE OF ADDED INSTANCES

View File

@ -16,20 +16,33 @@ abstract class _AsyncStore<T> with Store {
AsyncState<T> asyncState = const AsyncState.initial(); AsyncState<T> asyncState = const AsyncState.initial();
@computed @computed
bool get isLoading => asyncState is AsyncStateLoading; bool get isLoading => asyncState is AsyncStateLoading<T>;
@computed @computed
String? get errorTerm => String? get errorTerm => asyncState.whenOrNull<String?>(
asyncState.whenOrNull(error: (errorTerm) => errorTerm); 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]. /// runs some async action and reflects the progress in [asyncState].
/// If successful, the result is returned, otherwise null is returned. /// 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 /// 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 @action
Future<T?> run(AsyncValueGetter<T> callback) async { Future<T?> run(AsyncValueGetter<T> callback, {bool refresh = false}) async {
if (isLoading) return null; 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 { try {
final result = await callback(); final result = await callback();
@ -39,9 +52,17 @@ abstract class _AsyncStore<T> with Store {
return result; return result;
} on SocketException { } on SocketException {
// TODO: use an existing l10n key // 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) { } catch (err) {
asyncState = AsyncState.error(err.toString()); if (data != null) {
asyncState = data.copyWith(errorTerm: err.toString());
} else {
asyncState = AsyncState.error(err.toString());
}
rethrow; rethrow;
} }
} }
@ -49,27 +70,39 @@ abstract class _AsyncStore<T> with Store {
/// [run] but specialized for a [LemmyApiQuery]. /// [run] but specialized for a [LemmyApiQuery].
/// Will catch [LemmyApiException] and map to its error term. /// Will catch [LemmyApiException] and map to its error term.
@action @action
Future<T?> runLemmy(String instanceHost, LemmyApiQuery<T> query) async { Future<T?> runLemmy(
String instanceHost,
LemmyApiQuery<T> query, {
bool refresh = false,
}) async {
try { try {
return await run(() => LemmyApiV3(instanceHost).run(query)); return await run(() => LemmyApiV3(instanceHost).run(query),
refresh: refresh);
} on LemmyApiException catch (err) { } 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 /// State in which an async action can be
@freezed @freezed
class AsyncState<T> with _$AsyncState { class AsyncState<T> with _$AsyncState<T> {
/// async action has not yet begun /// async action has not yet begun
const factory AsyncState.initial() = AsyncStateInitial; const factory AsyncState.initial() = AsyncStateInitial<T>;
/// async action completed successfully with [T] /// 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<T>;
/// async action is running at the moment /// async action is running at the moment
const factory AsyncState.loading() = AsyncStateLoading; const factory AsyncState.loading() = AsyncStateLoading<T>;
/// async action failed with a translatable error term /// async action failed with a translatable error term
const factory AsyncState.error(String errorTerm) = AsyncStateError; const factory AsyncState.error(String errorTerm) = AsyncStateError<T>;
} }

View File

@ -21,9 +21,10 @@ class _$AsyncStateTearOff {
return AsyncStateInitial<T>(); return AsyncStateInitial<T>();
} }
AsyncStateData<T> data<T>(T data) { AsyncStateData<T> data<T>(T data, [String? errorTerm]) {
return AsyncStateData<T>( return AsyncStateData<T>(
data, data,
errorTerm,
); );
} }
@ -46,7 +47,7 @@ mixin _$AsyncState<T> {
@optionalTypeArgs @optionalTypeArgs
TResult when<TResult extends Object?>({ TResult when<TResult extends Object?>({
required TResult Function() initial, 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() loading,
required TResult Function(String errorTerm) error, required TResult Function(String errorTerm) error,
}) => }) =>
@ -54,7 +55,7 @@ mixin _$AsyncState<T> {
@optionalTypeArgs @optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({ TResult? whenOrNull<TResult extends Object?>({
TResult Function()? initial, TResult Function()? initial,
TResult Function(T data)? data, TResult Function(T data, String? errorTerm)? data,
TResult Function()? loading, TResult Function()? loading,
TResult Function(String errorTerm)? error, TResult Function(String errorTerm)? error,
}) => }) =>
@ -62,7 +63,7 @@ mixin _$AsyncState<T> {
@optionalTypeArgs @optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({ TResult maybeWhen<TResult extends Object?>({
TResult Function()? initial, TResult Function()? initial,
TResult Function(T data)? data, TResult Function(T data, String? errorTerm)? data,
TResult Function()? loading, TResult Function()? loading,
TResult Function(String errorTerm)? error, TResult Function(String errorTerm)? error,
required TResult orElse(), required TResult orElse(),
@ -162,7 +163,7 @@ class _$AsyncStateInitial<T>
@optionalTypeArgs @optionalTypeArgs
TResult when<TResult extends Object?>({ TResult when<TResult extends Object?>({
required TResult Function() initial, 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() loading,
required TResult Function(String errorTerm) error, required TResult Function(String errorTerm) error,
}) { }) {
@ -173,7 +174,7 @@ class _$AsyncStateInitial<T>
@optionalTypeArgs @optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({ TResult? whenOrNull<TResult extends Object?>({
TResult Function()? initial, TResult Function()? initial,
TResult Function(T data)? data, TResult Function(T data, String? errorTerm)? data,
TResult Function()? loading, TResult Function()? loading,
TResult Function(String errorTerm)? error, TResult Function(String errorTerm)? error,
}) { }) {
@ -184,7 +185,7 @@ class _$AsyncStateInitial<T>
@optionalTypeArgs @optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({ TResult maybeWhen<TResult extends Object?>({
TResult Function()? initial, TResult Function()? initial,
TResult Function(T data)? data, TResult Function(T data, String? errorTerm)? data,
TResult Function()? loading, TResult Function()? loading,
TResult Function(String errorTerm)? error, TResult Function(String errorTerm)? error,
required TResult orElse(), required TResult orElse(),
@ -242,7 +243,7 @@ abstract class $AsyncStateDataCopyWith<T, $Res> {
factory $AsyncStateDataCopyWith( factory $AsyncStateDataCopyWith(
AsyncStateData<T> value, $Res Function(AsyncStateData<T>) then) = AsyncStateData<T> value, $Res Function(AsyncStateData<T>) then) =
_$AsyncStateDataCopyWithImpl<T, $Res>; _$AsyncStateDataCopyWithImpl<T, $Res>;
$Res call({T data}); $Res call({T data, String? errorTerm});
} }
/// @nodoc /// @nodoc
@ -259,12 +260,17 @@ class _$AsyncStateDataCopyWithImpl<T, $Res>
@override @override
$Res call({ $Res call({
Object? data = freezed, Object? data = freezed,
Object? errorTerm = freezed,
}) { }) {
return _then(AsyncStateData<T>( return _then(AsyncStateData<T>(
data == freezed data == freezed
? _value.data ? _value.data
: data // ignore: cast_nullable_to_non_nullable : data // ignore: cast_nullable_to_non_nullable
as T, as T,
errorTerm == freezed
? _value.errorTerm
: errorTerm // ignore: cast_nullable_to_non_nullable
as String?,
)); ));
} }
} }
@ -274,14 +280,16 @@ class _$AsyncStateDataCopyWithImpl<T, $Res>
class _$AsyncStateData<T> class _$AsyncStateData<T>
with DiagnosticableTreeMixin with DiagnosticableTreeMixin
implements AsyncStateData<T> { implements AsyncStateData<T> {
const _$AsyncStateData(this.data); const _$AsyncStateData(this.data, [this.errorTerm]);
@override @override
final T data; final T data;
@override
final String? errorTerm;
@override @override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
return 'AsyncState<$T>.data(data: $data)'; return 'AsyncState<$T>.data(data: $data, errorTerm: $errorTerm)';
} }
@override @override
@ -289,7 +297,8 @@ class _$AsyncStateData<T>
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties properties
..add(DiagnosticsProperty('type', 'AsyncState<$T>.data')) ..add(DiagnosticsProperty('type', 'AsyncState<$T>.data'))
..add(DiagnosticsProperty('data', data)); ..add(DiagnosticsProperty('data', data))
..add(DiagnosticsProperty('errorTerm', errorTerm));
} }
@override @override
@ -297,12 +306,14 @@ class _$AsyncStateData<T>
return identical(this, other) || return identical(this, other) ||
(other.runtimeType == runtimeType && (other.runtimeType == runtimeType &&
other is AsyncStateData<T> && other is AsyncStateData<T> &&
const DeepCollectionEquality().equals(other.data, data)); const DeepCollectionEquality().equals(other.data, data) &&
(identical(other.errorTerm, errorTerm) ||
other.errorTerm == errorTerm));
} }
@override @override
int get hashCode => int get hashCode => Object.hash(
Object.hash(runtimeType, const DeepCollectionEquality().hash(data)); runtimeType, const DeepCollectionEquality().hash(data), errorTerm);
@JsonKey(ignore: true) @JsonKey(ignore: true)
@override @override
@ -313,35 +324,35 @@ class _$AsyncStateData<T>
@optionalTypeArgs @optionalTypeArgs
TResult when<TResult extends Object?>({ TResult when<TResult extends Object?>({
required TResult Function() initial, 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() loading,
required TResult Function(String errorTerm) error, required TResult Function(String errorTerm) error,
}) { }) {
return data(this.data); return data(this.data, errorTerm);
} }
@override @override
@optionalTypeArgs @optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({ TResult? whenOrNull<TResult extends Object?>({
TResult Function()? initial, TResult Function()? initial,
TResult Function(T data)? data, TResult Function(T data, String? errorTerm)? data,
TResult Function()? loading, TResult Function()? loading,
TResult Function(String errorTerm)? error, TResult Function(String errorTerm)? error,
}) { }) {
return data?.call(this.data); return data?.call(this.data, errorTerm);
} }
@override @override
@optionalTypeArgs @optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({ TResult maybeWhen<TResult extends Object?>({
TResult Function()? initial, TResult Function()? initial,
TResult Function(T data)? data, TResult Function(T data, String? errorTerm)? data,
TResult Function()? loading, TResult Function()? loading,
TResult Function(String errorTerm)? error, TResult Function(String errorTerm)? error,
required TResult orElse(), required TResult orElse(),
}) { }) {
if (data != null) { if (data != null) {
return data(this.data); return data(this.data, errorTerm);
} }
return orElse(); return orElse();
} }
@ -385,9 +396,11 @@ class _$AsyncStateData<T>
} }
abstract class AsyncStateData<T> implements AsyncState<T> { abstract class AsyncStateData<T> implements AsyncState<T> {
const factory AsyncStateData(T data) = _$AsyncStateData<T>; const factory AsyncStateData(T data, [String? errorTerm]) =
_$AsyncStateData<T>;
T get data; T get data;
String? get errorTerm;
@JsonKey(ignore: true) @JsonKey(ignore: true)
$AsyncStateDataCopyWith<T, AsyncStateData<T>> get copyWith => $AsyncStateDataCopyWith<T, AsyncStateData<T>> get copyWith =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
@ -443,7 +456,7 @@ class _$AsyncStateLoading<T>
@optionalTypeArgs @optionalTypeArgs
TResult when<TResult extends Object?>({ TResult when<TResult extends Object?>({
required TResult Function() initial, 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() loading,
required TResult Function(String errorTerm) error, required TResult Function(String errorTerm) error,
}) { }) {
@ -454,7 +467,7 @@ class _$AsyncStateLoading<T>
@optionalTypeArgs @optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({ TResult? whenOrNull<TResult extends Object?>({
TResult Function()? initial, TResult Function()? initial,
TResult Function(T data)? data, TResult Function(T data, String? errorTerm)? data,
TResult Function()? loading, TResult Function()? loading,
TResult Function(String errorTerm)? error, TResult Function(String errorTerm)? error,
}) { }) {
@ -465,7 +478,7 @@ class _$AsyncStateLoading<T>
@optionalTypeArgs @optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({ TResult maybeWhen<TResult extends Object?>({
TResult Function()? initial, TResult Function()? initial,
TResult Function(T data)? data, TResult Function(T data, String? errorTerm)? data,
TResult Function()? loading, TResult Function()? loading,
TResult Function(String errorTerm)? error, TResult Function(String errorTerm)? error,
required TResult orElse(), required TResult orElse(),
@ -594,7 +607,7 @@ class _$AsyncStateError<T>
@optionalTypeArgs @optionalTypeArgs
TResult when<TResult extends Object?>({ TResult when<TResult extends Object?>({
required TResult Function() initial, 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() loading,
required TResult Function(String errorTerm) error, required TResult Function(String errorTerm) error,
}) { }) {
@ -605,7 +618,7 @@ class _$AsyncStateError<T>
@optionalTypeArgs @optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({ TResult? whenOrNull<TResult extends Object?>({
TResult Function()? initial, TResult Function()? initial,
TResult Function(T data)? data, TResult Function(T data, String? errorTerm)? data,
TResult Function()? loading, TResult Function()? loading,
TResult Function(String errorTerm)? error, TResult Function(String errorTerm)? error,
}) { }) {
@ -616,7 +629,7 @@ class _$AsyncStateError<T>
@optionalTypeArgs @optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({ TResult maybeWhen<TResult extends Object?>({
TResult Function()? initial, TResult Function()? initial,
TResult Function(T data)? data, TResult Function(T data, String? errorTerm)? data,
TResult Function()? loading, TResult Function()? loading,
TResult Function(String errorTerm)? error, TResult Function(String errorTerm)? error,
required TResult orElse(), required TResult orElse(),

View File

@ -41,15 +41,30 @@ mixin _$AsyncStore<T> on _AsyncStore<T>, Store {
final _$runAsyncAction = AsyncAction('_AsyncStore.run'); final _$runAsyncAction = AsyncAction('_AsyncStore.run');
@override @override
Future<T?> run(AsyncValueGetter<T> callback) { Future<T?> run(AsyncValueGetter<T> callback, {bool refresh = false}) {
return _$runAsyncAction.run(() => super.run(callback)); return _$runAsyncAction.run(() => super.run(callback, refresh: refresh));
} }
final _$runLemmyAsyncAction = AsyncAction('_AsyncStore.runLemmy'); final _$runLemmyAsyncAction = AsyncAction('_AsyncStore.runLemmy');
@override @override
Future<T?> runLemmy(String instanceHost, LemmyApiQuery<T> query) { Future<T?> runLemmy(String instanceHost, LemmyApiQuery<T> query,
return _$runLemmyAsyncAction.run(() => super.runLemmy(instanceHost, 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 @override

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lemmy_api_client/v3.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/full_post/full_post.dart';
import '../pages/instance.dart'; import '../pages/instance.dart';
import '../pages/media_view.dart'; import '../pages/media_view.dart';
@ -33,19 +33,13 @@ abstract class goToCommunity {
/// Navigates to `CommunityPage` /// Navigates to `CommunityPage`
static void byId( static void byId(
BuildContext context, String instanceHost, int communityId) => BuildContext context, String instanceHost, int communityId) =>
goTo( Navigator.of(context)
context, .push(CommunityPage.fromIdRoute(instanceHost, communityId));
(context) => CommunityPage.fromId(
instanceHost: instanceHost, communityId: communityId),
);
static void byName( static void byName(
BuildContext context, String instanceHost, String communityName) => BuildContext context, String instanceHost, String communityName) =>
goTo( Navigator.of(context)
context, .push(CommunityPage.fromNameRoute(instanceHost, communityName));
(context) => CommunityPage.fromName(
instanceHost: instanceHost, communityName: communityName),
);
} }
// ignore: camel_case_types // ignore: camel_case_types

View File

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

View File

@ -3,6 +3,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../l10n/l10n.dart'; import '../../l10n/l10n.dart';
import '../../pages/community/community.dart';
import '../../util/extensions/api.dart'; import '../../util/extensions/api.dart';
import '../../util/extensions/datetime.dart'; import '../../util/extensions/datetime.dart';
import '../../util/goto.dart'; import '../../util/goto.dart';
@ -30,8 +31,8 @@ class PostInfoSection extends StatelessWidget {
Avatar( Avatar(
url: post.community.icon, url: post.community.icon,
padding: const EdgeInsets.only(right: 10), padding: const EdgeInsets.only(right: 10),
onTap: () => onTap: () => Navigator.of(context).push(
goToCommunity.byId(context, instanceHost, post.community.id), CommunityPage.fromIdRoute(instanceHost, post.community.id)),
noBlank: true, noBlank: true,
radius: 20, radius: 20,
), ),
@ -56,11 +57,9 @@ class PostInfoSection extends StatelessWidget {
text: post.community.name, text: post.community.name,
style: const TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w600),
recognizer: TapGestureRecognizer() recognizer: TapGestureRecognizer()
..onTap = () => goToCommunity.byId( ..onTap = () => Navigator.of(context).push(
context, CommunityPage.fromIdRoute(
instanceHost, instanceHost, post.community.id)),
post.community.id,
),
), ),
const TextSpan( const TextSpan(
text: '@', text: '@',

View File

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

View File

@ -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<FullPostView>();
expect(store.asyncState, isA<AsyncStateInitial>());
expect(store.isLoading, false);
expect(store.errorTerm, null);
final fut = store.runLemmy(instanceHost, const GetPost(id: 91588));
expect(store.asyncState, isA<AsyncStateLoading>());
expect(store.isLoading, true);
expect(store.errorTerm, null);
final res = await fut;
expect(store.asyncState, isA<AsyncStateData>());
expect(store.isLoading, false);
expect(store.errorTerm, null);
expect(store.asyncState, AsyncState.data(res!));
});
test('fails properly 1', () async {
final store = AsyncStore<FullPostView>();
expect(store.asyncState, isA<AsyncStateInitial>());
expect(store.isLoading, false);
expect(store.errorTerm, null);
final fut = store.runLemmy(instanceHost, const GetPost(id: 0));
expect(store.asyncState, isA<AsyncStateLoading>());
expect(store.isLoading, true);
expect(store.errorTerm, null);
await fut;
expect(store.asyncState, isA<AsyncStateError>());
expect(store.isLoading, false);
expect(store.errorTerm, 'couldnt_find_post');
});
test('fails properly 2', () async {
final store = AsyncStore<FullPostView>();
expect(store.asyncState, isA<AsyncStateInitial>());
expect(store.isLoading, false);
expect(store.errorTerm, null);
final fut = store.runLemmy(badInstanceHost, const GetPost(id: 0));
expect(store.asyncState, isA<AsyncStateLoading>());
expect(store.isLoading, true);
expect(store.errorTerm, null);
await fut;
expect(store.asyncState, isA<AsyncStateError>());
expect(store.isLoading, false);
expect(store.errorTerm, 'network_error');
});
test('succeeds then fails on refresh, and then succeeds', () async {
final store = AsyncStore<FullPostView>();
final res = await store.runLemmy(instanceHost, const GetPost(id: 91588));
expect(store.asyncState, isA<AsyncStateData>());
expect(store.errorTerm, null);
expect(store.asyncState, AsyncState.data(res!));
await store.runLemmy(
badInstanceHost,
const GetPost(id: 91588),
refresh: true,
);
expect(store.asyncState, isA<AsyncStateData>());
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<AsyncStateData>());
expect(store.errorTerm, null);
expect(store.asyncState, AsyncState.data(res2!));
});
});
}