refactor community page to use mobx (#299)
This commit is contained in:
parent
f0bfa9e5cf
commit
95d8ee7fa7
|
@ -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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(),
|
||||||
|
);
|
||||||
|
}
|
|
@ -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))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 '''
|
||||||
|
|
||||||
|
''';
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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'),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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'),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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: '@',
|
||||||
|
|
|
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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!));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in New Issue