Merge pull request #101 from krawieck/refresh

This commit is contained in:
Marcin Wojnarowski 2021-01-09 17:42:19 +01:00 committed by GitHub
commit 68b740e529
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 258 additions and 162 deletions

View File

@ -0,0 +1,44 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'memo_future.dart';
class Refreshable<T> {
const Refreshable({@required this.snapshot, @required this.refresh})
: assert(snapshot != null),
assert(refresh != null);
final AsyncSnapshot<T> snapshot;
final AsyncCallback refresh;
}
/// Similar to [useMemoFuture] but adds a `.refresh` method which
/// allows to re-run the fetcher. Calling `.refresh` will not
/// turn AsyncSnapshot into a loading state. Instead it will
/// replace the ready state with the new data when available
///
/// `keys` will re-run the initial fetching thus yielding a
/// loading state in the AsyncSnapshot
Refreshable<T> useRefreshable<T>(AsyncValueGetter<T> fetcher,
[List<Object> keys = const <dynamic>[]]) {
final newData = useState<T>(null);
final snapshot = useMemoFuture(() async {
newData.value = null;
return fetcher();
}, keys);
final outSnapshot = () {
if (newData.value != null) {
return AsyncSnapshot.withData(ConnectionState.done, newData.value);
}
return snapshot;
}();
return Refreshable(
snapshot: outSnapshot,
refresh: () async {
newData.value = await fetcher();
},
);
}

View File

@ -2,12 +2,13 @@ import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzy/fuzzy.dart';
import 'package:lemmy_api_client/lemmy_api_client.dart';
import '../hooks/delayed_loading.dart';
import '../hooks/memo_future.dart';
import '../hooks/refreshable.dart';
import '../hooks/stores.dart';
import '../util/extensions/api.dart';
import '../util/extensions/iterators.dart';
@ -29,8 +30,7 @@ class CommunitiesTab extends HookWidget {
useMemoized(() => accountsStore.loggedInInstances.length);
final isCollapsed = useState(List.filled(amountOfDisplayInstances, false));
// TODO: rebuild when instances/accounts change
final instancesSnap = useMemoFuture(() {
getInstances() {
final futures = accountsStore.loggedInInstances
.map(
(instanceHost) =>
@ -39,8 +39,9 @@ class CommunitiesTab extends HookWidget {
.toList();
return Future.wait(futures);
});
final communitiesSnap = useMemoFuture(() {
}
getCommunities() {
final futures = accountsStore.loggedInInstances
.map(
(instanceHost) => LemmyApi(instanceHost)
@ -56,9 +57,14 @@ class CommunitiesTab extends HookWidget {
.toList();
return Future.wait(futures);
});
}
if (communitiesSnap.hasError || instancesSnap.hasError) {
// TODO: rebuild when instances/accounts change
final instancesRefreshable = useRefreshable(getInstances);
final communitiesRefreshable = useRefreshable(getCommunities);
if (communitiesRefreshable.snapshot.hasError ||
instancesRefreshable.snapshot.hasError) {
return Scaffold(
appBar: AppBar(),
body: Center(
@ -68,15 +74,16 @@ class CommunitiesTab extends HookWidget {
Padding(
padding: const EdgeInsets.all(8),
child: Text(
communitiesSnap.error?.toString() ??
instancesSnap.error?.toString(),
communitiesRefreshable.snapshot.error?.toString() ??
instancesRefreshable.snapshot.error?.toString(),
),
)
],
),
),
);
} else if (!communitiesSnap.hasData || !instancesSnap.hasData) {
} else if (!communitiesRefreshable.snapshot.hasData ||
!instancesRefreshable.snapshot.hasData) {
return Scaffold(
appBar: AppBar(),
body: const Center(
@ -85,8 +92,22 @@ class CommunitiesTab extends HookWidget {
);
}
final instances = instancesSnap.data;
final communities = communitiesSnap.data
refresh() async {
await HapticFeedback.mediumImpact();
try {
await Future.wait([
instancesRefreshable.refresh(),
communitiesRefreshable.refresh(),
]);
// ignore: avoid_catches_without_on_clauses
} catch (e) {
Scaffold.of(context)
.showSnackBar(SnackBar(content: Text(e.toString())));
}
}
final instances = instancesRefreshable.snapshot.data;
final communities = communitiesRefreshable.snapshot.data
..forEach(
(e) => e.sort((a, b) => a.communityName.compareTo(b.communityName)));
@ -136,91 +157,95 @@ class CommunitiesTab extends HookWidget {
),
),
),
body: ListView(
children: [
for (var i = 0; i < amountOfDisplayInstances; i++)
Column(
children: [
ListTile(
onTap: () => goToInstance(
context, accountsStore.loggedInInstances.elementAt(i)),
onLongPress: () => toggleCollapse(i),
leading: instances[i].icon != null
? CachedNetworkImage(
height: 50,
width: 50,
imageUrl: instances[i].icon,
imageBuilder: (context, imageProvider) => Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
image: DecorationImage(
fit: BoxFit.cover, image: imageProvider),
body: RefreshIndicator(
onRefresh: refresh,
child: ListView(
children: [
for (var i = 0; i < amountOfDisplayInstances; i++)
Column(
children: [
ListTile(
onTap: () => goToInstance(
context, accountsStore.loggedInInstances.elementAt(i)),
onLongPress: () => toggleCollapse(i),
leading: instances[i].icon != null
? CachedNetworkImage(
height: 50,
width: 50,
imageUrl: instances[i].icon,
imageBuilder: (context, imageProvider) => Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
image: DecorationImage(
fit: BoxFit.cover, image: imageProvider),
),
),
errorWidget: (_, __, ___) =>
const SizedBox(width: 50),
)
: const SizedBox(width: 50),
title: Text(
instances[i].name,
style: theme.textTheme.headline6,
),
trailing: IconButton(
icon: Icon(isCollapsed.value[i]
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down),
onPressed: () => toggleCollapse(i),
),
),
if (!isCollapsed.value[i])
for (final comm in filterCommunities(communities[i]))
Padding(
padding: const EdgeInsets.only(left: 17),
child: ListTile(
onTap: () => goToCommunity.byId(
context,
accountsStore.loggedInInstances.elementAt(i),
comm.communityId),
dense: true,
leading: VerticalDivider(
color: theme.hintColor,
),
errorWidget: (_, __, ___) =>
const SizedBox(width: 50),
)
: const SizedBox(width: 50),
title: Text(
instances[i].name,
style: theme.textTheme.headline6,
),
trailing: IconButton(
icon: Icon(isCollapsed.value[i]
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down),
onPressed: () => toggleCollapse(i),
),
),
if (!isCollapsed.value[i])
for (final comm in filterCommunities(communities[i]))
Padding(
padding: const EdgeInsets.only(left: 17),
child: ListTile(
onTap: () => goToCommunity.byId(
context,
accountsStore.loggedInInstances.elementAt(i),
comm.communityId),
dense: true,
leading: VerticalDivider(
color: theme.hintColor,
),
title: Row(
children: [
if (comm.communityIcon != null)
CachedNetworkImage(
height: 30,
width: 30,
imageUrl: comm.communityIcon,
imageBuilder: (context, imageProvider) =>
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
image: DecorationImage(
fit: BoxFit.cover,
image: imageProvider),
title: Row(
children: [
if (comm.communityIcon != null)
CachedNetworkImage(
height: 30,
width: 30,
imageUrl: comm.communityIcon,
imageBuilder: (context, imageProvider) =>
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
image: DecorationImage(
fit: BoxFit.cover,
image: imageProvider),
),
),
),
errorWidget: (_, __, ___) =>
const SizedBox(width: 30),
)
else
const SizedBox(width: 30),
const SizedBox(width: 10),
Text(
'''!${comm.communityName}${comm.isLocal ? '' : '@${comm.originInstanceHost}'}''',
),
],
errorWidget: (_, __, ___) =>
const SizedBox(width: 30),
)
else
const SizedBox(width: 30),
const SizedBox(width: 10),
Text(
'''!${comm.communityName}${comm.isLocal ? '' : '@${comm.originInstanceHost}'}''',
),
],
),
trailing: _CommunitySubscribeToggle(
key: ValueKey(comm.communityId),
instanceHost: comm.instanceHost,
communityId: comm.communityId,
),
),
trailing: _CommunitySubscribeToggle(
instanceHost: comm.instanceHost,
communityId: comm.communityId,
),
),
)
],
),
],
)
],
),
],
),
),
);
}
@ -231,9 +256,10 @@ class _CommunitySubscribeToggle extends HookWidget {
final String instanceHost;
const _CommunitySubscribeToggle(
{@required this.instanceHost, @required this.communityId})
{@required this.instanceHost, @required this.communityId, Key key})
: assert(instanceHost != null),
assert(communityId != null);
assert(communityId != null),
super(key: key);
@override
Widget build(BuildContext context) {

View File

@ -1,11 +1,12 @@
import 'package:esys_flutter_share/esys_flutter_share.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/lemmy_api_client.dart';
import '../hooks/logged_in_action.dart';
import '../hooks/memo_future.dart';
import '../hooks/refreshable.dart';
import '../hooks/stores.dart';
import '../util/more_icon.dart';
import '../widgets/comment_section.dart';
@ -30,23 +31,22 @@ class FullPostPage extends HookWidget {
@override
Widget build(BuildContext context) {
final accStore = useAccountsStore();
final fullPostSnap = useMemoFuture(() => LemmyApi(instanceHost)
final fullPostRefreshable = useRefreshable(() => LemmyApi(instanceHost)
.v1
.getPost(id: id, auth: accStore.defaultTokenFor(instanceHost)?.raw));
final loggedInAction = useLoggedInAction(instanceHost);
final newComments = useState(const <CommentView>[]);
// FALLBACK VIEW
if (!fullPostSnap.hasData && this.post == null) {
if (!fullPostRefreshable.snapshot.hasData && this.post == null) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (fullPostSnap.hasError)
Text(fullPostSnap.error.toString())
if (fullPostRefreshable.snapshot.hasError)
Text(fullPostRefreshable.snapshot.error.toString())
else
const CircularProgressIndicator(),
],
@ -57,12 +57,27 @@ class FullPostPage extends HookWidget {
// VARIABLES
final post = fullPostSnap.hasData ? fullPostSnap.data.post : this.post;
final post = fullPostRefreshable.snapshot.hasData
? fullPostRefreshable.snapshot.data.post
: this.post;
final fullPost = fullPostSnap.data;
final fullPost = fullPostRefreshable.snapshot.data;
// FUNCTIONS
refresh() async {
await HapticFeedback.mediumImpact();
try {
await fullPostRefreshable.refresh();
// ignore: avoid_catches_without_on_clauses
} catch (e) {
Scaffold.of(context).showSnackBar(SnackBar(
content: Text(e.toString()),
));
}
}
sharePost() => Share.text('Share post', post.apId, 'text/plain');
comment() async {
@ -89,31 +104,34 @@ class FullPostPage extends HookWidget {
floatingActionButton: FloatingActionButton(
onPressed: loggedInAction((_) => comment()),
child: const Icon(Icons.comment)),
body: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
Post(post, fullPost: true),
if (fullPostSnap.hasData)
CommentSection(
newComments.value.followedBy(fullPost.comments).toList(),
postCreatorId: fullPost.post.creatorId)
else if (fullPostSnap.hasError)
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 30),
child: Column(
children: [
const Icon(Icons.error),
Text('Error: ${fullPostSnap.error}')
],
body: RefreshIndicator(
onRefresh: refresh,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
Post(post, fullPost: true),
if (fullPostRefreshable.snapshot.hasData)
CommentSection(
newComments.value.followedBy(fullPost.comments).toList(),
postCreatorId: fullPost.post.creatorId)
else if (fullPostRefreshable.snapshot.hasError)
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 30),
child: Column(
children: [
const Icon(Icons.error),
Text('Error: ${fullPostRefreshable.snapshot.error}')
],
),
)
else
const Padding(
padding: EdgeInsets.only(top: 40),
child: Center(child: CircularProgressIndicator()),
),
)
else
const Padding(
padding: EdgeInsets.only(top: 40),
child: Center(child: CircularProgressIndicator()),
),
],
],
),
));
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../hooks/ref.dart';
@ -61,42 +62,49 @@ class InfiniteScroll<T> extends HookWidget {
final page = data.value.length ~/ batchSize + 1;
return ListView.builder(
padding: padding,
// +2 for the loading widget and prepend widget
itemCount: data.value.length + 2,
itemBuilder: (_, i) {
if (i == 0) {
return prepend;
}
i -= 1;
// reached the bottom, fetch more
if (i == data.value.length) {
// if there are no more, skip
if (!hasMore.current) {
return const SizedBox.shrink();
}
// if it's already fetching more, skip
if (!isFetching.current) {
isFetching.current = true;
fetchMore(page, batchSize).then((newData) {
// if got less than the batchSize, mark the list as done
if (newData.length < batchSize) {
hasMore.current = false;
}
// append new data
data.value = [...data.value, ...newData];
}).whenComplete(() => isFetching.current = false);
}
return loadingWidget;
}
// not last element, render list item
return builder(data.value[i]);
return RefreshIndicator(
onRefresh: () async {
controller.clear();
await HapticFeedback.mediumImpact();
await Future.delayed(const Duration(seconds: 1));
},
child: ListView.builder(
padding: padding,
// +2 for the loading widget and prepend widget
itemCount: data.value.length + 2,
itemBuilder: (_, i) {
if (i == 0) {
return prepend;
}
i -= 1;
// reached the bottom, fetch more
if (i == data.value.length) {
// if there are no more, skip
if (!hasMore.current) {
return const SizedBox.shrink();
}
// if it's already fetching more, skip
if (!isFetching.current) {
isFetching.current = true;
fetchMore(page, batchSize).then((newData) {
// if got less than the batchSize, mark the list as done
if (newData.length < batchSize) {
hasMore.current = false;
}
// append new data
data.value = [...data.value, ...newData];
}).whenComplete(() => isFetching.current = false);
}
return loadingWidget;
}
// not last element, render list item
return builder(data.value[i]);
},
),
);
}
}