Merge pull request #70 from krawieck/home_page

Home page
This commit is contained in:
Marcin Wojnarowski 2020-10-06 17:42:56 +02:00 committed by GitHub
commit a320dc5ca3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 371 additions and 28 deletions

View File

@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
import 'hooks/stores.dart';
import 'pages/communities_tab.dart';
import 'pages/create_post.dart';
import 'pages/home_tab.dart';
import 'pages/profile_tab.dart';
import 'stores/accounts_store.dart';
import 'stores/config_store.dart';
@ -71,7 +72,7 @@ class MyApp extends HookWidget {
class MyHomePage extends HookWidget {
final List<Widget> pages = [
Center(child: Text('home')), // TODO: home tab
HomeTab(),
CommunitiesTab(),
Center(child: Text('search')), // TODO: search tab
UserProfileTab(),

View File

@ -5,6 +5,7 @@ import 'package:lemmy_api_client/lemmy_api_client.dart';
import '../hooks/debounce.dart';
import '../hooks/stores.dart';
import '../util/cleanup_url.dart';
import '../widgets/fullscreenable_image.dart';
/// A page that let's user add a new instance. Pops a url of the added instance
@ -26,7 +27,7 @@ class AddInstancePage extends HookWidget {
final debounce = useDebounce(() async {
if (prevInput == instanceController.text) return;
final inst = _fixInstanceUrl(instanceController.text);
final inst = cleanUpUrl(instanceController.text);
if (inst.isEmpty) {
isSite.value = null;
return;
@ -47,7 +48,7 @@ class AddInstancePage extends HookWidget {
instanceController.removeListener(debounce);
};
}, []);
final inst = _fixInstanceUrl(instanceController.text);
final inst = cleanUpUrl(instanceController.text);
handleOnAdd() async {
try {
await accountsStore.addInstance(inst, assumeValid: true);
@ -145,20 +146,3 @@ class AddInstancePage extends HookWidget {
);
}
}
/// removes protocol and trailing slash
String _fixInstanceUrl(String inst) {
if (inst.startsWith('https://')) {
inst = inst.substring(8);
}
if (inst.startsWith('http://')) {
inst = inst.substring(7);
}
if (inst.endsWith('/')) {
inst = inst.substring(0, inst.length - 1);
}
return inst;
}

317
lib/pages/home_tab.dart Normal file
View File

@ -0,0 +1,317 @@
import 'dart:math' show max;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/lemmy_api_client.dart';
import '../hooks/infinite_scroll.dart';
import '../hooks/memo_future.dart';
import '../hooks/stores.dart';
import '../util/goto.dart';
import '../widgets/bottom_modal.dart';
import '../widgets/infinite_scroll.dart';
import '../widgets/post.dart';
import '../widgets/post_list_options.dart';
import 'add_account.dart';
import 'inbox.dart';
/// First thing users sees when opening the app
/// Shows list of posts from all or just specific instances
class HomeTab extends HookWidget {
@override
Widget build(BuildContext context) {
final selectedList =
useState(_SelectedList(listingType: PostListingType.subscribed));
// TODO: needs to be an observer? for accounts changes
final accStore = useAccountsStore();
final isc = useInfiniteScrollController();
final theme = Theme.of(context);
final instancesIcons = useMemoFuture(() async {
final map = <String, String>{};
final instances = accStore.instances.toList(growable: false);
final sites = await Future.wait(instances
.map((e) => LemmyApi(e).v1.getSite().catchError((e) => null)));
for (var i in Iterable.generate(sites.length)) {
map[instances[i]] = sites[i].site.icon;
}
return map;
});
handleListChange() async {
final val = await showModalBottomSheet<_SelectedList>(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) {
pop(_SelectedList thing) => Navigator.of(context).pop(thing);
return BottomModal(
child: Column(
children: [
SizedBox(height: 5),
ListTile(
title: Text('EVERYTHING'),
dense: true,
contentPadding: EdgeInsets.zero,
visualDensity:
VisualDensity(vertical: VisualDensity.minimumDensity),
leading: SizedBox.shrink(),
),
ListTile(
title: Text('Subscribed'),
leading: SizedBox(width: 20, height: 20),
onTap: () => pop(
_SelectedList(listingType: PostListingType.subscribed)),
),
ListTile(
title: Text('All'),
leading: SizedBox(width: 20, height: 20),
onTap: () =>
pop(_SelectedList(listingType: PostListingType.all)),
),
for (final instance in accStore.instances) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Divider(),
),
ListTile(
title: Text(
instance.toUpperCase(),
style: TextStyle(
color:
theme.textTheme.bodyText1.color.withOpacity(0.7)),
),
onTap: () => goToInstance(context, instance),
dense: true,
contentPadding: EdgeInsets.zero,
visualDensity:
VisualDensity(vertical: VisualDensity.minimumDensity),
leading: (instancesIcons.hasData &&
instancesIcons.data[instance] != null)
? Padding(
padding: const EdgeInsets.only(left: 20),
child: SizedBox(
width: 25,
height: 25,
child: CachedNetworkImage(
imageUrl: instancesIcons.data[instance],
height: 25,
width: 25,
),
),
)
: SizedBox(width: 30),
),
ListTile(
title: Text(
'Subscribed',
style: TextStyle(
color: accStore.isAnonymousFor(instance)
? theme.textTheme.bodyText1.color.withOpacity(0.4)
: null),
),
onTap: accStore.isAnonymousFor(instance)
? () => showCupertinoModalPopup(
context: context,
builder: (_) =>
AddAccountPage(instanceUrl: instance))
: () => pop(_SelectedList(
listingType: PostListingType.subscribed,
instanceUrl: instance,
)),
leading: SizedBox(width: 20),
),
ListTile(
title: Text('All'),
onTap: () => pop(_SelectedList(
listingType: PostListingType.all,
instanceUrl: instance,
)),
leading: SizedBox(width: 20),
),
]
],
),
);
},
);
if (val != null) {
selectedList.value = val;
isc.clear();
}
}
final title = () {
final first = selectedList.value.listingType == PostListingType.subscribed
? 'Subscribed'
: 'All';
final last = selectedList.value.instanceUrl == null
? ''
: '@${selectedList.value.instanceUrl}';
return '$first$last';
}();
return Scaffold(
// TODO: make appbar autohide when scrolling down
appBar: AppBar(
actions: [
IconButton(
icon: Icon(Icons.notifications),
onPressed: () => goTo(context, (_) => InboxPage()),
)
],
centerTitle: true,
title: TextButton(
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10))),
padding: EdgeInsets.symmetric(horizontal: 15),
primary: theme.buttonColor,
textStyle: theme.primaryTextTheme.headline6,
),
onPressed: handleListChange,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
title,
style: theme.primaryTextTheme.headline6,
overflow: TextOverflow.ellipsis,
),
),
Icon(
Icons.arrow_drop_down,
color: theme.primaryTextTheme.headline6.color,
),
],
),
),
),
body: InfiniteHomeList(
controller: isc,
selectedList: selectedList.value,
),
);
}
}
/// Infinite list of posts
class InfiniteHomeList extends HookWidget {
final Function onStyleChange;
final InfiniteScrollController controller;
final _SelectedList selectedList;
InfiniteHomeList({
@required this.selectedList,
this.onStyleChange,
this.controller,
}) : assert(selectedList != null);
@override
Widget build(BuildContext context) {
final accStore = useAccountsStore();
final sort = useState(SortType.active);
void changeSorting(SortType newSort) {
sort.value = newSort;
controller.clear();
}
/// fetches post from many instances at once and combines them into a single
/// list
///
/// Process of combining them works sort of like zip function in python
Future<List<PostView>> generalFetcher(
int page,
int limit,
SortType sort,
PostListingType listingType,
) async {
assert(
listingType != PostListingType.community, 'only subscribed or all');
final instances = () {
if (listingType == PostListingType.all) {
return accStore.instances;
} else {
return accStore.loggedInInstances;
}
}();
final futures =
instances.map((instanceUrl) => LemmyApi(instanceUrl).v1.getPosts(
type: listingType,
sort: sort,
page: page,
limit: limit,
auth: accStore.defaultTokenFor(instanceUrl)?.raw,
));
final posts = await Future.wait(futures);
final newPosts = <PostView>[];
for (final i
in Iterable.generate(posts.map((e) => e.length).reduce(max))) {
for (final el in posts) {
if (el.elementAt(i) != null) {
newPosts.add(el[i]);
}
}
}
return newPosts;
}
Future<List<PostView>> Function(int, int) fetcherFromInstance(
String instanceUrl, PostListingType listingType, SortType sort) =>
(page, batchSize) => LemmyApi(instanceUrl).v1.getPosts(
type: listingType,
sort: sort,
page: page,
limit: batchSize,
auth: accStore.defaultTokenFor(instanceUrl)?.raw,
);
return InfiniteScroll<PostView>(
prepend: Column(
children: [
PostListOptions(
onChange: changeSorting,
defaultSort: SortType.active,
styleButton: onStyleChange != null,
),
],
),
builder: (post) => Column(
children: [
Post(post),
SizedBox(height: 20),
],
),
padding: EdgeInsets.zero,
fetchMore: selectedList.instanceUrl == null
? (page, limit) =>
generalFetcher(page, limit, sort.value, selectedList.listingType)
: fetcherFromInstance(
selectedList.instanceUrl,
selectedList.listingType,
sort.value,
),
controller: controller,
batchSize: 20,
);
}
}
class _SelectedList {
final String instanceUrl;
final PostListingType listingType;
_SelectedList({
@required this.listingType,
this.instanceUrl,
});
String toString() =>
'SelectedList({instanceUrl: $instanceUrl, listingType: $listingType})';
}

20
lib/pages/inbox.dart Normal file
View File

@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class InboxPage extends HookWidget {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'🚧 WORK IN PROGRESS 🚧',
style: Theme.of(context).textTheme.headline5,
)
],
),
),
);
}

View File

@ -353,7 +353,7 @@ class _AboutTab extends HookWidget {
),
),
if (commSnap.hasData)
...commSnap.data.getRange(0, 6).map((e) => ListTile(
...commSnap.data.take(6).map((e) => ListTile(
onTap: () =>
goToCommunity.byId(context, e.instanceUrl, e.id),
title: Text(e.name),

14
lib/util/cleanup_url.dart Normal file
View File

@ -0,0 +1,14 @@
/// Strips protocol, 'www.', and trailing '/' from [url] aka. cleans it up
String cleanUpUrl(String url) {
if (url.startsWith('https://')) {
url = url.substring(8);
}
if (url.startsWith('www.')) {
url = url.substring(4);
}
if (url.endsWith('/')) {
url = url.substring(0, url.length - 1);
}
return url;
}

View File

@ -1,5 +1,7 @@
import 'package:lemmy_api_client/lemmy_api_client.dart';
import '../cleanup_url.dart';
// Extensions to lemmy api objects which give a [.instanceUrl] getter
// allowing for a convenient way of knowing from which instance did this
// object come from
@ -7,25 +9,27 @@ import 'package:lemmy_api_client/lemmy_api_client.dart';
// TODO: change it to something more robust? regex?
extension GetInstanceCommunityView on CommunityView {
String get instanceUrl => actorId.split('/')[2];
String get instanceUrl => _extract(actorId);
}
extension GetInstanceUserView on UserView {
String get instanceUrl => actorId.split('/')[2];
String get instanceUrl => _extract(actorId);
}
extension GetInstanceCommunityModeratorView on CommunityModeratorView {
String get instanceUrl => userActorId.split('/')[2];
String get instanceUrl => _extract(userActorId);
}
extension GetInstancePostView on PostView {
String get instanceUrl => apId.split('/')[2];
String get instanceUrl => _extract(apId);
}
extension GetInstanceUser on User {
String get instanceUrl => actorId.split('/')[2];
String get instanceUrl => _extract(actorId);
}
extension GetInstanceCommentView on CommentView {
String get instanceUrl => apId.split('/')[2];
String get instanceUrl => _extract(apId);
}
String _extract(String s) => cleanUpUrl(s.split('/')[2]);

View File

@ -49,7 +49,10 @@ class InfiniteScroll<T> extends HookWidget {
useEffect(() {
if (controller != null) {
controller.clear = () => data.value = [];
controller.clear = () {
data.value = [];
hasMore.current = true;
};
return controller.dispose;
}