Merge pull request #60 from krawieck/infinite-list-in-action

This commit is contained in:
Marcin Wojnarowski 2020-09-29 21:55:04 +02:00 committed by GitHub
commit f37f01951f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 252 additions and 111 deletions

View File

@ -122,8 +122,8 @@ class MyHomePage extends HookWidget {
children: [
tabButton(Icons.home),
tabButton(Icons.list),
Container(),
Container(),
SizedBox.shrink(),
SizedBox.shrink(),
tabButton(Icons.search),
tabButton(Icons.person),
],

View File

@ -120,7 +120,7 @@ class AddAccountPage extends HookWidget {
url: icon.value,
child: CachedNetworkImage(
imageUrl: icon.value,
errorWidget: (_, __, ___) => Container(),
errorWidget: (_, __, ___) => SizedBox.shrink(),
),
),
),

View File

@ -80,7 +80,7 @@ class AddInstancePage extends HookWidget {
child: FullscreenableImage(
child: CachedNetworkImage(
imageUrl: icon.value,
errorWidget: (_, __, ___) => Container(),
errorWidget: (_, __, ___) => SizedBox.shrink(),
),
url: icon.value,
))

View File

@ -5,58 +5,69 @@ import 'package:lemmy_api_client/lemmy_api_client.dart';
import '../util/extensions/api.dart';
import '../util/goto.dart';
import '../widgets/markdown_text.dart';
import '../widgets/sortable_infinite_list.dart';
class CommunitiesListPage extends StatelessWidget {
final String title;
final List<CommunityView> communities;
final Future<List<CommunityView>> Function(
int page,
int batchSize,
SortType sortType,
) fetcher;
const CommunitiesListPage({Key key, @required this.communities, this.title})
: assert(communities != null),
const CommunitiesListPage({Key key, @required this.fetcher, this.title = ''})
: assert(fetcher != null),
assert(title != null),
super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// TODO: abillity to load more, right now its 10 - default page size
return Scaffold(
appBar: AppBar(
title: Text(title ?? '', style: theme.textTheme.headline6),
centerTitle: true,
backgroundColor: theme.cardColor,
iconTheme: theme.iconTheme,
),
body: ListView.builder(
itemBuilder: (context, i) => ListTile(
title: Text(communities[i].name),
subtitle: communities[i].description != null
? Opacity(
opacity: 0.5,
child: MarkdownText(
communities[i].description,
instanceUrl: communities[i].instanceUrl,
),
)
: null,
onTap: () => goToCommunity.byId(
context, communities[i].instanceUrl, communities[i].id),
leading: communities[i].icon != null
? CachedNetworkImage(
height: 50,
width: 50,
imageUrl: communities[i].icon,
imageBuilder: (context, imageProvider) => Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
image: DecorationImage(
fit: BoxFit.cover, image: imageProvider),
appBar: AppBar(
brightness: theme.brightness,
title: Text(title, style: theme.textTheme.headline6),
centerTitle: true,
backgroundColor: theme.cardColor,
iconTheme: theme.iconTheme,
),
body: SortableInfiniteList<CommunityView>(
fetcher: fetcher,
builder: (community) => Column(
children: [
Divider(),
ListTile(
title: Text(community.name),
subtitle: community.description != null
? Opacity(
opacity: 0.5,
child: MarkdownText(
community.description,
instanceUrl: community.instanceUrl,
),
),
errorWidget: (_, __, ___) => SizedBox(width: 50),
)
: SizedBox(width: 50),
// TODO: add trailing button for un/subscribing to communities
),
itemCount: communities.length,
));
)
: null,
onTap: () => goToCommunity.byId(
context, community.instanceUrl, community.id),
leading: community.icon != null
? CachedNetworkImage(
height: 50,
width: 50,
imageUrl: community.icon,
imageBuilder: (context, imageProvider) => Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
image: DecorationImage(
fit: BoxFit.cover, image: imageProvider),
),
),
errorWidget: (_, __, ___) => SizedBox(width: 50),
)
: SizedBox(width: 50),
),
],
),
),
);
}
}

View File

@ -115,6 +115,7 @@ class CommunitiesTab extends HookWidget {
toggleCollapse(int i) => isCollapsed.value =
isCollapsed.value.mapWithIndex((e, j) => j == i ? !e : e).toList();
// TODO: add observer
return Scaffold(
appBar: AppBar(
actions: [
@ -140,6 +141,7 @@ class CommunitiesTab extends HookWidget {
Column(
children: [
ListTile(
onTap: () {}, // TODO: open instance
onLongPress: () => toggleCollapse(i),
leading: instances[i].icon != null
? CachedNetworkImage(
@ -172,6 +174,7 @@ class CommunitiesTab extends HookWidget {
Padding(
padding: const EdgeInsets.only(left: 17),
child: ListTile(
onTap: () {}, // TODO: open community
dense: true,
leading: VerticalDivider(
color: theme.hintColor,

View File

@ -19,6 +19,7 @@ import '../widgets/badge.dart';
import '../widgets/bottom_modal.dart';
import '../widgets/fullscreenable_image.dart';
import '../widgets/markdown_text.dart';
import '../widgets/sortable_infinite_list.dart';
class CommunityPage extends HookWidget {
final CommunityView _community;
@ -207,16 +208,28 @@ class CommunityPage extends HookWidget {
],
body: TabBarView(
children: [
ListView(
children: [
Center(child: Text('posts go here')),
],
),
ListView(
children: [
Center(child: Text('comments go here')),
],
InfinitePostList(
fetcher: (page, batchSize, sort) =>
LemmyApi(community.instanceUrl).v1.getPosts(
type: PostListingType.community,
sort: sort,
communityId: community.id,
page: page,
limit: batchSize,
),
),
InfiniteCommentList(
fetcher: (page, batchSize, sortType) =>
LemmyApi(community.instanceUrl).v1.getComments(
communityId: community.id,
auth: accountsStore
.defaultTokenFor(community.instanceUrl)
?.raw,
type: CommentListingType.community,
sort: sortType,
limit: batchSize,
page: page,
)),
_AboutTab(
community: community,
moderators: fullCommunitySnap.data?.moderators,
@ -289,7 +302,7 @@ class _CommunityOverview extends StatelessWidget {
url: community.banner,
child: CachedNetworkImage(
imageUrl: community.banner,
errorWidget: (_, __, ___) => Container(),
errorWidget: (_, __, ___) => SizedBox.shrink(),
),
),
SafeArea(
@ -486,7 +499,7 @@ class _AboutTab extends StatelessWidget {
for (final mod in moderators)
ListTile(
title: Text(mod.userPreferredUsername ?? '@${mod.userName}'),
onTap: () => goToUser.byId(context, mod.instanceUrl, mod.id),
onTap: () => goToUser.byId(context, mod.instanceUrl, mod.userId),
),
]
],
@ -512,7 +525,6 @@ class _Badge extends StatelessWidget {
style:
TextStyle(color: textColorBasedOnBackground(theme.accentColor)),
),
borderRadius: const BorderRadius.all(Radius.circular(10)),
),
);
}

View File

@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
import 'package:lemmy_api_client/lemmy_api_client.dart';
import 'package:url_launcher/url_launcher.dart' as ul;
import '../hooks/stores.dart';
import '../util/extensions/api.dart';
import '../util/goto.dart';
import '../util/text_color.dart';
@ -14,6 +15,7 @@ import '../widgets/badge.dart';
import '../widgets/bottom_modal.dart';
import '../widgets/fullscreenable_image.dart';
import '../widgets/markdown_text.dart';
import '../widgets/sortable_infinite_list.dart';
import 'communities_list.dart';
import 'users_list.dart';
@ -36,6 +38,7 @@ class InstancePage extends HookWidget {
final theme = Theme.of(context);
final siteSnap = useFuture(siteFuture);
final colorOnCard = textColorBasedOnBackground(theme.cardColor);
final accStore = useAccountsStore();
if (!siteSnap.hasData) {
return Scaffold(
@ -165,7 +168,7 @@ class InstancePage extends HookWidget {
url: site.site.banner,
child: CachedNetworkImage(
imageUrl: site.site.banner,
errorWidget: (_, __, ___) => Container(),
errorWidget: (_, __, ___) => SizedBox.shrink(),
),
),
SafeArea(
@ -212,16 +215,25 @@ class InstancePage extends HookWidget {
],
body: TabBarView(
children: [
ListView(
children: [
Center(child: Text('posts go here')),
],
),
ListView(
children: [
Center(child: Text('comments go here')),
],
),
InfinitePostList(
fetcher: (page, batchSize, sort) =>
LemmyApi(instanceUrl).v1.getPosts(
// TODO: switch between all and subscribed
type: PostListingType.all,
sort: sort,
limit: batchSize,
page: page,
auth: accStore.defaultTokenFor(instanceUrl)?.raw,
)),
InfiniteCommentList(
fetcher: (page, batchSize, sort) =>
LemmyApi(instanceUrl).v1.getComments(
type: CommentListingType.all,
sort: sort,
limit: batchSize,
page: page,
auth: accStore.defaultTokenFor(instanceUrl)?.raw,
)),
_AboutTab(site,
communitiesFuture: communitiesFuture,
instanceUrl: instanceUrl),
@ -284,13 +296,21 @@ class _AboutTab extends HookWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final commSnap = useFuture(communitiesFuture);
final accStore = useAccountsStore();
void goToCommunities() {
goTo(
context,
(_) => CommunitiesListPage(
communities: commSnap.data,
title: 'Communities of ${site.site.name}'),
fetcher: (page, batchSize, sortType) =>
LemmyApi(instanceUrl).v1.listCommunities(
sort: sortType,
limit: batchSize,
page: page,
auth: accStore.defaultTokenFor(instanceUrl)?.raw,
),
title: 'Communities of ${site.site.name}',
),
);
}
@ -436,7 +456,6 @@ class _Badge extends StatelessWidget {
style:
TextStyle(color: textColorBasedOnBackground(theme.accentColor)),
),
// TODO: change border radius
),
);
}

View File

@ -4,8 +4,8 @@ import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart' as ul;
import 'pages/community.dart';
import 'pages/full_post.dart';
import 'pages/instance.dart';
import 'pages/media_view.dart';
import 'pages/user.dart';
import 'stores/accounts_store.dart';
import 'util/goto.dart';
@ -22,15 +22,16 @@ Future<void> linkLauncher({
final instances = context.read<AccountsStore>().users.keys.toList();
final chonks = url.split('/');
if (chonks.length == 1) return openInBrowser(url);
// CHECK IF LINK TO USER
if (chonks[1] == 'u') {
if (url.startsWith('/u/')) {
return push(
() => UserPage.fromName(instanceUrl: instanceUrl, username: chonks[2]));
}
// CHECK IF LINK TO COMMUNITY
if (chonks[1] == 'c') {
if (url.startsWith('/c/')) {
return push(() => CommunityPage.fromName(
communityName: chonks[2], instanceUrl: instanceUrl));
}
@ -50,28 +51,23 @@ Future<void> linkLauncher({
final split = rest.split('/');
switch (split[1]) {
case 'c':
return push(() => CommunityPage.fromName(
communityName: split[2], instanceUrl: matchedInstance));
return goToCommunity.byName(context, matchedInstance, split[2]);
case 'u':
return push(() => UserPage.fromName(
instanceUrl: matchedInstance, username: split[2]));
return goToUser.byName(context, matchedInstance, split[2]);
case 'post':
if (split.length == 3) {
return push(() => FullPostPage(
id: int.parse(split[2]), instanceUrl: matchedInstance));
return goToPost(context, matchedInstance, int.parse(split[2]));
} else if (split.length == 5) {
// TODO: post with focus on comment thread
print('comment in post');
return;
return goToPost(context, matchedInstance, int.parse(split[2]));
}
break;
case 'pictrs':
// TODO: put here push to media view
print('pictrs');
return;
return push(() => MediaViewPage(url));
case 'communities':
// TODO: put here push to communities page

View File

@ -6,7 +6,7 @@ class Badge extends StatelessWidget {
Badge({
@required this.child,
this.borderRadius = const BorderRadius.all(Radius.circular(5)),
this.borderRadius = const BorderRadius.all(Radius.circular(10)),
});
@override

View File

@ -251,7 +251,7 @@ class Comment extends HookWidget {
}();
final actions = collapsed.value
? Container()
? SizedBox.shrink()
: Row(children: [
if (selectable.value && !comment.deleted && !comment.removed)
_CommentAction(
@ -324,7 +324,7 @@ class Comment extends HookWidget {
),
),
),
errorWidget: (_, __, ___) => Container(),
errorWidget: (_, __, ___) => SizedBox.shrink(),
),
),
),

View File

@ -24,9 +24,13 @@ class InfiniteScroll<T> extends HookWidget {
final Widget Function(T data) builder;
final Future<List<T>> Function(int page, int batchSize) fetchMore;
final InfiniteScrollController controller;
final Widget prepend;
final EdgeInsetsGeometry padding;
InfiniteScroll({
this.batchSize = 10,
this.prepend = const SizedBox.shrink(),
this.padding,
this.loadingWidget =
const ListTile(title: Center(child: CircularProgressIndicator())),
@required this.builder,
@ -54,14 +58,20 @@ class InfiniteScroll<T> extends HookWidget {
final page = data.value.length ~/ batchSize + 1;
return ListView.builder(
// +1 for the loading widget
itemCount: data.value.length + 1,
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 Container();
return SizedBox.shrink();
}
// if it's already fetching more, skip

View File

@ -25,10 +25,11 @@ enum MediaType {
gallery,
video,
other,
none,
}
MediaType whatType(String url) {
if (url == null) return MediaType.other;
if (url == null || url.isEmpty) return MediaType.none;
// TODO: make detection more nuanced
if (url.endsWith('.jpg') ||
@ -141,7 +142,7 @@ class Post extends HookWidget {
linkLauncher(context: context, url: post.url, instanceUrl: instanceUrl);
final urlDomain = () {
if (post.url == null) return null;
if (whatType(post.url) == MediaType.none) return null;
final url = post.url.split('/')[2];
if (url.startsWith('www.')) return url.substring(4);
@ -262,6 +263,9 @@ class Post extends HookWidget {
IconButton(
onPressed: () => showMoreMenu(context, post),
icon: Icon(Icons.more_vert),
iconSize: 24,
padding: EdgeInsets.all(0),
visualDensity: VisualDensity.compact,
)
],
)
@ -283,8 +287,7 @@ class Post extends HookWidget {
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
),
if (post.url != null &&
whatType(post.url) == MediaType.other &&
if (whatType(post.url) == MediaType.other &&
post.thumbnailUrl != null) ...[
Spacer(),
InkWell(
@ -342,11 +345,12 @@ class Post extends HookWidget {
]),
Row(children: [
Flexible(
child: Text('${post.embedTitle}',
child: Text('${post.embedTitle ?? ''}',
style: theme.textTheme.subtitle1
.apply(fontWeightDelta: 2)))
]),
if (post.embedDescription != null)
if (post.embedDescription != null &&
post.embedDescription.isNotEmpty)
Row(children: [
Flexible(child: Text(post.embedDescription))
]),
@ -416,9 +420,10 @@ class Post extends HookWidget {
children: [
info(),
title(),
if (whatType(post.url) != MediaType.other)
if (whatType(post.url) != MediaType.other &&
whatType(post.url) != MediaType.none)
postImage()
else if (post.url != null)
else if (post.url != null && post.url.isNotEmpty)
linkPreview(),
if (post.body != null)
Padding(

View File

@ -8,9 +8,11 @@ import 'bottom_modal.dart';
class PostListOptions extends HookWidget {
final void Function(SortType sort) onChange;
final SortType defaultSort;
final bool styleButton;
PostListOptions({
@required this.onChange,
this.styleButton = true,
this.defaultSort = SortType.active,
});
@ -31,9 +33,7 @@ class PostListOptions extends HookWidget {
RadioListTile<SortType>(
value: x,
groupValue: sort.value,
// TODO: use something more robust and user-friendly
// than describeEnum
title: Text(describeEnum(x)),
title: Text(x.value),
onChanged: (val) {
sort.value = val;
onChange(val);
@ -57,17 +57,19 @@ class PostListOptions extends HookWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(describeEnum(sort.value)),
Text(sort.value.value),
const SizedBox(width: 8),
Icon(Icons.arrow_drop_down),
],
),
),
Spacer(),
IconButton(
icon: Icon(Icons.view_stream),
onPressed: () => print('TBD'),
)
if (styleButton)
IconButton(
icon: Icon(Icons.view_stream),
// TODO: create compact post and dropdown for selecting
onPressed: () => print('TBD'),
),
],
),
);

View File

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/lemmy_api_client.dart';
import '../comment_tree.dart';
import '../hooks/infinite_scroll.dart';
import 'comment.dart';
import 'infinite_scroll.dart';
import 'post.dart';
import 'post_list_options.dart';
/// Infinite list of posts
class SortableInfiniteList<T> extends HookWidget {
final Future<List<T>> Function(int page, int batchSize, SortType sortType)
fetcher;
final Widget Function(T) builder;
final Function onStyleChange;
SortableInfiniteList({
@required this.fetcher,
@required this.builder,
this.onStyleChange,
}) : assert(fetcher != null),
assert(builder != null);
@override
Widget build(BuildContext context) {
final isc = useInfiniteScrollController();
final sort = useState(SortType.active);
void changeSorting(SortType newSort) {
sort.value = newSort;
isc.clear();
}
return InfiniteScroll<T>(
prepend: PostListOptions(
onChange: changeSorting,
defaultSort: SortType.active,
styleButton: onStyleChange != null,
),
builder: builder,
padding: EdgeInsets.zero,
fetchMore: (page, batchSize) => fetcher(page, batchSize, sort.value),
controller: isc,
batchSize: 20,
);
}
}
class InfinitePostList extends StatelessWidget {
final Future<List<PostView>> Function(
int page, int batchSize, SortType sortType) fetcher;
InfinitePostList({@required this.fetcher}) : assert(fetcher != null);
Widget build(BuildContext context) => SortableInfiniteList<PostView>(
onStyleChange: () {},
builder: (post) => Column(
children: [
Post(post),
SizedBox(height: 20),
],
),
fetcher: fetcher,
);
}
class InfiniteCommentList extends StatelessWidget {
final Future<List<CommentView>> Function(
int page, int batchSize, SortType sortType) fetcher;
InfiniteCommentList({@required this.fetcher}) : assert(fetcher != null);
Widget build(BuildContext context) => SortableInfiniteList<CommentView>(
builder: (comment) => Comment(
CommentTree(comment),
postCreatorId: null,
),
fetcher: fetcher,
);
}

View File

@ -96,7 +96,7 @@ class UserProfile extends HookWidget {
if (userViewSnap.data?.banner != null)
CachedNetworkImage(
imageUrl: userViewSnap.data.banner,
errorWidget: (_, __, ___) => Container(),
errorWidget: (_, __, ___) => SizedBox.shrink(),
)
else
Container(
@ -155,7 +155,7 @@ class UserProfile extends HookWidget {
borderRadius: BorderRadius.all(Radius.circular(12)),
child: CachedNetworkImage(
imageUrl: userViewSnap.data.avatar,
errorWidget: (_, __, ___) => Container(),
errorWidget: (_, __, ___) => SizedBox.shrink(),
),
),
),

View File

@ -379,7 +379,7 @@ packages:
name: lemmy_api_client
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.0"
version: "0.6.0"
logging:
dependency: transitive
description:

View File

@ -31,9 +31,10 @@ dependencies:
flutter_hooks: ^0.13.2
cached_network_image: ^2.2.0+1
timeago: ^2.0.27
lemmy_api_client: ^0.5.0
fuzzy: <1.0.0
lemmy_api_client: ^0.6.0
mobx: ^1.2.1
flutter_mobx: ^1.1.0
provider: ^4.3.1