From 19b26883160a66cde4d6655d1a8bbaa42c7c3360 Mon Sep 17 00:00:00 2001 From: Filip Krawczyk Date: Wed, 24 Feb 2021 20:52:18 +0100 Subject: [PATCH] add inbox page + other stuff (#164) --- CHANGELOG.md | 8 + lib/hooks/infinite_scroll.dart | 9 +- lib/pages/add_account.dart | 2 +- lib/pages/add_instance.dart | 3 +- lib/pages/communities_tab.dart | 6 +- lib/pages/community.dart | 24 +- lib/pages/create_post.dart | 3 +- lib/pages/full_post.dart | 17 +- lib/pages/home_tab.dart | 7 +- lib/pages/inbox.dart | 390 +++++++++++++++++++++++- lib/pages/instance.dart | 30 +- lib/pages/media_view.dart | 2 + lib/pages/search_results.dart | 6 +- lib/pages/user.dart | 31 +- lib/pages/users_list.dart | 2 +- lib/pages/write_message.dart | 143 +++++++++ lib/stores/accounts_store.dart | 17 +- lib/util/delayed_action.dart | 33 ++ lib/util/extensions/api.dart | 8 +- lib/util/goto.dart | 4 + lib/widgets/bottom_modal.dart | 3 +- lib/widgets/comment.dart | 294 +++++++++--------- lib/widgets/comment_section.dart | 9 +- lib/widgets/infinite_scroll.dart | 1 - lib/widgets/info_table_popup.dart | 50 ++- lib/widgets/markdown_mode_icon.dart | 12 + lib/widgets/post.dart | 174 ++++++----- lib/widgets/reveal_after_scroll.dart | 41 +++ lib/widgets/sortable_infinite_list.dart | 8 +- lib/widgets/tile_action.dart | 45 +++ lib/widgets/write_comment.dart | 35 +-- pubspec.lock | 23 +- pubspec.yaml | 3 +- 33 files changed, 1099 insertions(+), 344 deletions(-) create mode 100644 lib/pages/write_message.dart create mode 100644 lib/util/delayed_action.dart create mode 100644 lib/widgets/markdown_mode_icon.dart create mode 100644 lib/widgets/reveal_after_scroll.dart create mode 100644 lib/widgets/tile_action.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index af3f7fb..47513f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ - Added page with saved posts/comments. It can be accessed from the profile tab under the bookmark icon - Added modlog page. Can be visited in the context of an instance or community from the about tab +- Added inbox page, that can be accessed by tapping bell in the home tab +- Added abillity to send private messages + +### Changed + +- Titles on some pages, have an appear affect when scrolling down +- Long pressing comments now have a ripple effect + ### Fixed diff --git a/lib/hooks/infinite_scroll.dart b/lib/hooks/infinite_scroll.dart index 4e03cb8..0537f4c 100644 --- a/lib/hooks/infinite_scroll.dart +++ b/lib/hooks/infinite_scroll.dart @@ -2,5 +2,10 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import '../widgets/infinite_scroll.dart'; -InfiniteScrollController useInfiniteScrollController() => - useMemoized(() => InfiniteScrollController()); +InfiniteScrollController useInfiniteScrollController() { + final controller = useMemoized(() => InfiniteScrollController()); + + useEffect(() => controller.dispose, []); + + return controller; +} diff --git a/lib/pages/add_account.dart b/lib/pages/add_account.dart index 807994f..508829b 100644 --- a/lib/pages/add_account.dart +++ b/lib/pages/add_account.dart @@ -32,7 +32,7 @@ class AddAccountPage extends HookWidget { useEffect(() { LemmyApiV2(selectedInstance.value) - .run(GetSite()) + .run(const GetSite()) .then((site) => icon.value = site.siteView.site.icon); return null; }, [selectedInstance.value]); diff --git a/lib/pages/add_instance.dart b/lib/pages/add_instance.dart index 03c7dd5..0c3b67e 100644 --- a/lib/pages/add_instance.dart +++ b/lib/pages/add_instance.dart @@ -33,7 +33,8 @@ class AddInstancePage extends HookWidget { return; } try { - icon.value = (await LemmyApiV2(inst).run(GetSite())).siteView.site.icon; + icon.value = + (await LemmyApiV2(inst).run(const GetSite())).siteView.site.icon; isSite.value = true; // ignore: avoid_catches_without_on_clauses } catch (e) { diff --git a/lib/pages/communities_tab.dart b/lib/pages/communities_tab.dart index 1ff95ed..d263374 100644 --- a/lib/pages/communities_tab.dart +++ b/lib/pages/communities_tab.dart @@ -36,7 +36,7 @@ class CommunitiesTab extends HookWidget { final futures = accountsStore.loggedInInstances .map( (instanceHost) => LemmyApiV2(instanceHost) - .run(GetSite()) + .run(const GetSite()) .then((e) => e.siteView.site), ) .toList(); @@ -211,9 +211,7 @@ class CommunitiesTab extends HookWidget { url: comm.community.icon, ), const SizedBox(width: 10), - Text( - '!${comm.community.name}${comm.community.local ? '' : '@${comm.community.originInstanceHost}'}', - ), + Text(comm.community.originDisplayName), ], ), trailing: _CommunitySubscribeToggle( diff --git a/lib/pages/community.dart b/lib/pages/community.dart index a4922ee..197d714 100644 --- a/lib/pages/community.dart +++ b/lib/pages/community.dart @@ -20,6 +20,7 @@ import '../widgets/bottom_modal.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'; @@ -54,6 +55,7 @@ class CommunityPage extends HookWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final accountsStore = useAccountsStore(); + final scrollController = useScrollController(); final fullCommunitySnap = useMemoFuture(() { final token = accountsStore.defaultTokenFor(instanceHost); @@ -125,12 +127,7 @@ class CommunityPage extends HookWidget { leading: const Icon(Icons.info_outline), title: const Text('Nerd stuff'), onTap: () { - showInfoTablePopup(context, { - 'id': community.community.id, - 'actorId': community.community.actorId, - 'created by': '@${community.creator.name}', - 'published': community.community.published, - }); + showInfoTablePopup(context: context, table: community.toJson()); }, ), ], @@ -143,12 +140,22 @@ class CommunityPage extends HookWidget { 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: Text('!${community.community.name}'), + title: RevealAfterScroll( + scrollController: scrollController, + after: community.community.icon == null ? 110 : 190, + fade: true, + child: Text( + community.community.displayName, + overflow: TextOverflow.fade, + softWrap: false, + ), + ), actions: [ IconButton(icon: const Icon(Icons.share), onPressed: _share), IconButton(icon: Icon(moreIcon), onPressed: _openMoreMenu), @@ -437,8 +444,7 @@ class _AboutTab extends StatelessWidget { ListTile( title: Text( mod.moderator.preferredUsername ?? '@${mod.moderator.name}'), - onTap: () => - goToUser.byId(context, mod.instanceHost, mod.moderator.id), + onTap: () => goToUser.fromUserSafe(context, mod.moderator), ), ] ], diff --git a/lib/pages/create_post.dart b/lib/pages/create_post.dart index 09f6eab..edb937e 100644 --- a/lib/pages/create_post.dart +++ b/lib/pages/create_post.dart @@ -15,6 +15,7 @@ import '../util/extensions/spaced.dart'; import '../util/goto.dart'; import '../util/pictrs.dart'; import '../util/unawaited.dart'; +import '../widgets/markdown_mode_icon.dart'; import '../widgets/markdown_text.dart'; import '../widgets/radio_picker.dart'; import 'full_post.dart'; @@ -263,7 +264,7 @@ class CreatePostPage extends HookWidget { leading: const CloseButton(), actions: [ IconButton( - icon: Icon(showFancy.value ? Icons.build : Icons.brush), + icon: markdownModeIcon(fancy: showFancy.value), onPressed: () => showFancy.value = !showFancy.value, ), ], diff --git a/lib/pages/full_post.dart b/lib/pages/full_post.dart index c2da876..a66c9f8 100644 --- a/lib/pages/full_post.dart +++ b/lib/pages/full_post.dart @@ -8,9 +8,11 @@ import 'package:lemmy_api_client/v2.dart'; import '../hooks/logged_in_action.dart'; import '../hooks/refreshable.dart'; import '../hooks/stores.dart'; +import '../util/extensions/api.dart'; import '../util/more_icon.dart'; import '../widgets/comment_section.dart'; import '../widgets/post.dart'; +import '../widgets/reveal_after_scroll.dart'; import '../widgets/save_post_button.dart'; import '../widgets/write_comment.dart'; @@ -31,6 +33,8 @@ class FullPostPage extends HookWidget { @override Widget build(BuildContext context) { final accStore = useAccountsStore(); + final scrollController = useScrollController(); + final fullPostRefreshable = useRefreshable(() => LemmyApiV2(instanceHost).run(GetPost( id: id, @@ -85,7 +89,7 @@ class FullPostPage extends HookWidget { comment() async { final newComment = await showCupertinoModalPopup( context: context, - builder: (_) => WriteComment.toPost(post), + builder: (_) => WriteComment.toPost(post.post), ); if (newComment != null) { newComments.value = [...newComments.value, newComment]; @@ -94,6 +98,15 @@ class FullPostPage extends HookWidget { return Scaffold( appBar: AppBar( + centerTitle: false, + title: RevealAfterScroll( + scrollController: scrollController, + after: 65, + child: Text( + post.community.originDisplayName, + overflow: TextOverflow.fade, + ), + ), actions: [ IconButton(icon: const Icon(Icons.share), onPressed: sharePost), SavePostButton(post), @@ -108,8 +121,10 @@ class FullPostPage extends HookWidget { body: RefreshIndicator( onRefresh: refresh, child: ListView( + controller: scrollController, physics: const AlwaysScrollableScrollPhysics(), children: [ + const SizedBox(height: 15), PostWidget(post, fullPost: true), if (fullPostRefreshable.snapshot.hasData) CommentSection( diff --git a/lib/pages/home_tab.dart b/lib/pages/home_tab.dart index bf227d0..969171b 100644 --- a/lib/pages/home_tab.dart +++ b/lib/pages/home_tab.dart @@ -32,8 +32,8 @@ class HomeTab extends HookWidget { final theme = Theme.of(context); final instancesIcons = useMemoFuture(() async { final instances = accStore.instances.toList(growable: false); - final sites = await Future.wait(instances - .map((e) => LemmyApiV2(e).run(GetSite()).catchError((e) => null))); + final sites = await Future.wait(instances.map( + (e) => LemmyApiV2(e).run(const GetSite()).catchError((e) => null))); return { for (var i = 0; i < sites.length; i++) @@ -229,7 +229,8 @@ class HomeTab extends HookWidget { child: Text( title, style: theme.appBarTheme.textTheme.headline6, - overflow: TextOverflow.ellipsis, + overflow: TextOverflow.fade, + softWrap: false, ), ), const Icon(Icons.arrow_drop_down), diff --git a/lib/pages/inbox.dart b/lib/pages/inbox.dart index f9a1c36..a259088 100644 --- a/lib/pages/inbox.dart +++ b/lib/pages/inbox.dart @@ -1,22 +1,392 @@ +import 'dart:math' show pi; + +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/v2.dart'; +import 'package:matrix4_transform/matrix4_transform.dart'; + +import '../hooks/delayed_loading.dart'; +import '../hooks/infinite_scroll.dart'; +import '../hooks/stores.dart'; +import '../util/delayed_action.dart'; +import '../util/extensions/api.dart'; +import '../util/extensions/datetime.dart'; +import '../util/goto.dart'; +import '../util/more_icon.dart'; +import '../widgets/bottom_modal.dart'; +import '../widgets/comment.dart'; +import '../widgets/infinite_scroll.dart'; +import '../widgets/info_table_popup.dart'; +import '../widgets/markdown_mode_icon.dart'; +import '../widgets/markdown_text.dart'; +import '../widgets/radio_picker.dart'; +import '../widgets/sortable_infinite_list.dart'; +import '../widgets/tile_action.dart'; +import 'write_message.dart'; class InboxPage extends HookWidget { const InboxPage(); @override - Widget build(BuildContext context) => Scaffold( + Widget build(BuildContext context) { + final accStore = useAccountsStore(); + final selected = useState(accStore.defaultInstanceHost); + final theme = Theme.of(context); + final isc = useInfiniteScrollController(); + final unreadOnly = useState(true); + + if (accStore.hasNoAccount) { + return Scaffold( appBar: AppBar(), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '🚧 WORK IN PROGRESS 🚧', - style: Theme.of(context).textTheme.headline5, - ) + body: const Center(child: Text('no accounts added')), + ); + } + + toggleUnreadOnly() { + unreadOnly.value = !unreadOnly.value; + isc.clear(); + } + + return DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + title: RadioPicker( + onChanged: (val) { + selected.value = val; + isc.clear(); + }, + title: 'select instance', + groupValue: selected.value, + buttonBuilder: (context, displayString, onPressed) => TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 15), + ), + onPressed: onPressed, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + displayString, + style: theme.appBarTheme.textTheme.headline6, + overflow: TextOverflow.fade, + softWrap: false, + ), + ), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + values: accStore.loggedInInstances.toList(), + ), + actions: [ + IconButton( + icon: Icon(unreadOnly.value ? Icons.mail : Icons.mail_outline), + onPressed: toggleUnreadOnly, + tooltip: unreadOnly.value ? 'show all' : 'show only unread', + ) + ], + bottom: const TabBar( + tabs: [ + Tab(text: 'Replies'), + Tab(text: 'Mentions'), + Tab(text: 'Messages'), ], ), ), - ); + body: TabBarView( + children: [ + SortableInfiniteList( + noItems: const Text('no replies'), + controller: isc, + defaultSort: SortType.new_, + fetcher: (page, batchSize, sortType) => + LemmyApiV2(selected.value).run(GetReplies( + auth: accStore.defaultTokenFor(selected.value).raw, + sort: sortType, + limit: batchSize, + page: page, + unreadOnly: unreadOnly.value, + )), + itemBuilder: (cv) => CommentWidget.fromCommentView( + cv, + canBeMarkedAsRead: true, + hideOnRead: unreadOnly.value, + ), + ), + SortableInfiniteList( + noItems: const Text('no mentions'), + controller: isc, + defaultSort: SortType.new_, + fetcher: (page, batchSize, sortType) => + LemmyApiV2(selected.value).run(GetUserMentions( + auth: accStore.defaultTokenFor(selected.value).raw, + sort: sortType, + limit: batchSize, + page: page, + unreadOnly: unreadOnly.value, + )), + itemBuilder: (umv) => CommentWidget.fromUserMentionView( + umv, + hideOnRead: unreadOnly.value, + ), + ), + InfiniteScroll( + noItems: const Padding( + padding: EdgeInsets.only(top: 60), + child: Text('no messages'), + ), + controller: isc, + fetcher: (page, batchSize) => LemmyApiV2(selected.value).run( + GetPrivateMessages( + auth: accStore.defaultTokenFor(selected.value).raw, + limit: batchSize, + page: page, + unreadOnly: unreadOnly.value, + ), + ), + itemBuilder: (mv) => PrivateMessageTile( + privateMessageView: mv, + hideOnRead: unreadOnly.value, + ), + ), + ], + ), + ), + ); + } +} + +class PrivateMessageTile extends HookWidget { + final PrivateMessageView privateMessageView; + final bool hideOnRead; + + const PrivateMessageTile({ + @required this.privateMessageView, + this.hideOnRead = false, + }) : assert(privateMessageView != null), + assert(hideOnRead != null); + static const double _iconSize = 16; + + @override + Widget build(BuildContext context) { + final accStore = useAccountsStore(); + final theme = Theme.of(context); + + final pmv = useState(privateMessageView); + final raw = useState(false); + final selectable = useState(false); + final deleted = useState(pmv.value.privateMessage.deleted); + final deleteDelayed = useDelayedLoading(const Duration(milliseconds: 250)); + final read = useState(pmv.value.privateMessage.read); + final readDelayed = useDelayedLoading(const Duration(milliseconds: 200)); + + final toMe = useMemoized(() => + pmv.value.recipient.originInstanceHost == pmv.value.instanceHost && + pmv.value.recipient.id == + accStore.defaultTokenFor(pmv.value.instanceHost)?.payload?.id); + + final otherSide = + useMemoized(() => toMe ? pmv.value.creator : pmv.value.recipient); + + void showMoreMenu() { + showBottomModal( + context: context, + builder: (context) { + pop() => Navigator.of(context).pop(); + return Column( + children: [ + ListTile( + title: Text(raw.value ? 'Show fancy' : 'Show raw'), + leading: markdownModeIcon(fancy: !raw.value), + onTap: () { + raw.value = !raw.value; + pop(); + }, + ), + ListTile( + title: Text('Make ${selectable.value ? 'un' : ''}selectable'), + leading: Icon( + selectable.value ? Icons.assignment : Icons.content_cut), + onTap: () { + selectable.value = !selectable.value; + pop(); + }, + ), + ListTile( + title: const Text('Nerd stuff'), + leading: const Icon(Icons.info_outline), + onTap: () { + pop(); + showInfoTablePopup( + context: context, table: pmv.value.toJson()); + }, + ), + ], + ); + }, + ); + } + + handleDelete() => delayedAction( + context: context, + delayedLoading: deleteDelayed, + instanceHost: pmv.value.instanceHost, + query: DeletePrivateMessage( + privateMessageId: pmv.value.privateMessage.id, + auth: accStore.defaultTokenFor(pmv.value.instanceHost)?.raw, + deleted: !deleted.value, + ), + onSuccess: (val) => deleted.value = val.privateMessage.deleted, + ); + + handleRead() => delayedAction( + context: context, + delayedLoading: readDelayed, + instanceHost: pmv.value.instanceHost, + query: MarkPrivateMessageAsRead( + privateMessageId: pmv.value.privateMessage.id, + auth: accStore.defaultTokenFor(pmv.value.instanceHost)?.raw, + read: !read.value, + ), + // TODO: add notification for notifying parent list + onSuccess: (val) => read.value = val.privateMessage.read, + ); + + if (hideOnRead && read.value) { + return const SizedBox.shrink(); + } + + final body = raw.value + ? selectable.value + ? SelectableText(pmv.value.privateMessage.content) + : Text(pmv.value.privateMessage.content) + : MarkdownText( + pmv.value.privateMessage.content, + instanceHost: pmv.value.instanceHost, + selectable: selectable.value, + ); + + return Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + toMe ? 'from ' : 'to ', + style: TextStyle(color: theme.textTheme.caption.color), + ), + InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => goToUser.fromUserSafe(context, otherSide), + child: Row( + children: [ + if (otherSide.avatar != null) + Padding( + padding: const EdgeInsets.only(right: 5), + child: CachedNetworkImage( + imageUrl: otherSide.avatar, + height: 20, + width: 20, + imageBuilder: (context, imageProvider) => Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + image: DecorationImage( + fit: BoxFit.cover, + image: imageProvider, + ), + ), + ), + errorWidget: (_, __, ___) => const SizedBox.shrink(), + ), + ), + Text( + otherSide.originDisplayName, + style: TextStyle(color: theme.accentColor), + ), + ], + ), + ), + const Spacer(), + if (pmv.value.privateMessage.updated != null) const Text('🖊 '), + Text(pmv.value.privateMessage.updated?.fancy ?? + pmv.value.privateMessage.published.fancy), + const SizedBox(width: 5), + Transform( + transform: Matrix4Transform() + .rotateByCenter((toMe ? -1 : 1) * pi / 2, + const Size(_iconSize, _iconSize)) + .flipVertically( + origin: const Offset(_iconSize / 2, _iconSize / 2)) + .matrix4, + child: const Opacity( + opacity: 0.8, + child: Icon(Icons.reply, size: _iconSize), + ), + ) + ], + ), + const SizedBox(height: 5), + if (pmv.value.privateMessage.deleted) + const Text('deleted by creator', + style: TextStyle(fontStyle: FontStyle.italic)) + else + body, + Row(children: [ + const Spacer(), + TileAction( + icon: moreIcon, + onPressed: showMoreMenu, + tooltip: 'more', + ), + if (toMe) ...[ + TileAction( + iconColor: read.value ? theme.accentColor : null, + icon: Icons.check, + tooltip: 'mark as read', + onPressed: handleRead, + delayedLoading: readDelayed, + ), + TileAction( + icon: Icons.reply, + tooltip: 'reply', + onPressed: () { + showCupertinoModalPopup( + context: context, + builder: (_) => WriteMessagePage.send( + instanceHost: pmv.value.instanceHost, + recipient: otherSide, + )); + }, + ) + ] else ...[ + TileAction( + icon: Icons.edit, + tooltip: 'edit', + onPressed: () async { + final val = await showCupertinoModalPopup( + context: context, + builder: (_) => WriteMessagePage.edit(pmv.value)); + if (pmv != null) pmv.value = val; + }, + ), + TileAction( + delayedLoading: deleteDelayed, + icon: deleted.value ? Icons.restore : Icons.delete, + tooltip: deleted.value ? 'restore' : 'delete', + onPressed: handleDelete, + ), + ] + ]), + const Divider(), + ], + ), + ); + } } diff --git a/lib/pages/instance.dart b/lib/pages/instance.dart index 6f401d9..d1518d0 100644 --- a/lib/pages/instance.dart +++ b/lib/pages/instance.dart @@ -17,6 +17,7 @@ import '../widgets/bottom_modal.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 'communities_list.dart'; import 'modlog_page.dart'; @@ -33,8 +34,8 @@ class InstancePage extends HookWidget { InstancePage({@required this.instanceHost}) : assert(instanceHost != null), - siteFuture = LemmyApiV2(instanceHost).run(GetSite()), - communitiesFuture = LemmyApiV2(instanceHost).run(ListCommunities( + siteFuture = LemmyApiV2(instanceHost).run(const GetSite()), + communitiesFuture = LemmyApiV2(instanceHost).run(const ListCommunities( type: PostListingType.local, sort: SortType.hot, limit: 6)); @override @@ -43,6 +44,7 @@ class InstancePage extends HookWidget { final siteSnap = useFuture(siteFuture); final colorOnCard = textColorBasedOnBackground(theme.cardColor); final accStore = useAccountsStore(); + final scrollController = useScrollController(); if (!siteSnap.hasData) { return Scaffold( @@ -85,15 +87,7 @@ class InstancePage extends HookWidget { leading: const Icon(Icons.info_outline), title: const Text('Nerd stuff'), onTap: () { - showInfoTablePopup(context, { - 'url': instanceHost, - 'creator': '@${site.siteView.creator.name}', - 'version': site.version, - 'enableDownvotes': site.siteView.site.enableDownvotes, - 'enableNsfw': site.siteView.site.enableNsfw, - 'published': site.siteView.site.published, - 'updated': site.siteView.site.updated, - }); + showInfoTablePopup(context: context, table: site.toJson()); }, ), ], @@ -105,14 +99,20 @@ class InstancePage extends HookWidget { body: DefaultTabController( length: 3, child: NestedScrollView( + controller: scrollController, headerSliverBuilder: (context, innerBoxIsScrolled) => [ SliverAppBar( expandedHeight: 250, pinned: true, backgroundColor: theme.cardColor, - title: Text( - site.siteView.site.name, - style: TextStyle(color: colorOnCard), + title: RevealAfterScroll( + after: 150, + fade: true, + scrollController: scrollController, + child: Text( + site.siteView.site.name, + style: TextStyle(color: colorOnCard), + ), ), actions: [ IconButton(icon: const Icon(Icons.share), onPressed: _share), @@ -323,7 +323,7 @@ class _AboutTab extends HookWidget { subtitle: u.user.bio != null ? MarkdownText(u.user.bio, instanceHost: instanceHost) : null, - onTap: () => goToUser.byId(context, instanceHost, u.user.id), + onTap: () => goToUser.fromUserSafe(context, u.user), leading: Avatar(url: u.user.avatar), ), const _Divider(), diff --git a/lib/pages/media_view.dart b/lib/pages/media_view.dart index 40ecea2..ec3cd5f 100644 --- a/lib/pages/media_view.dart +++ b/lib/pages/media_view.dart @@ -71,6 +71,8 @@ class MediaViewPage extends HookWidget { Colors.black.withOpacity(max(0, 1.0 - (offset.value.dy.abs() / 200))), appBar: showButtons.value ? AppBar( + brightness: Brightness.dark, + iconTheme: const IconThemeData(color: Colors.white), backgroundColor: Colors.black38, leading: const CloseButton(), actions: [ diff --git a/lib/pages/search_results.dart b/lib/pages/search_results.dart index 55390cf..2a1c0bd 100644 --- a/lib/pages/search_results.dart +++ b/lib/pages/search_results.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:lemmy_api_client/v2.dart'; -import '../comment_tree.dart'; import '../hooks/stores.dart'; import '../widgets/comment.dart'; import '../widgets/post.dart'; @@ -106,10 +105,7 @@ class _SearchResultsList extends HookWidget { itemBuilder: (data) { switch (type) { case SearchType.comments: - return CommentWidget( - CommentTree(data as CommentView), - postCreatorId: null, - ); + return CommentWidget.fromCommentView(data as CommentView); case SearchType.communities: return CommunitiesListItem(community: data as CommunityView); case SearchType.posts: diff --git a/lib/pages/user.dart b/lib/pages/user.dart index ea7ab6d..27e8e9d 100644 --- a/lib/pages/user.dart +++ b/lib/pages/user.dart @@ -1,9 +1,12 @@ import 'package:esys_flutter_share/esys_flutter_share.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:lemmy_api_client/v2.dart'; +import '../hooks/logged_in_action.dart'; import '../widgets/user_profile.dart'; +import 'write_message.dart'; /// Page showing posts, comments, and general info about a user. class UserPage extends HookWidget { @@ -43,15 +46,12 @@ class UserPage extends HookWidget { appBar: AppBar( actions: [ if (userDetailsSnap.hasData) ...[ - IconButton( - icon: const Icon(Icons.email), - onPressed: () {}, // TODO: go to messaging page - ), + SendMessageButton(userDetailsSnap.data.userView.user), IconButton( icon: const Icon(Icons.share), onPressed: () => Share.text('Share user', userDetailsSnap.data.userView.user.actorId, 'text/plain'), - ) + ), ] ], ), @@ -59,3 +59,24 @@ class UserPage extends HookWidget { ); } } + +class SendMessageButton extends HookWidget { + final UserSafe user; + + const SendMessageButton(this.user); + + @override + Widget build(BuildContext context) { + final loggedInAction = useLoggedInAction(user.instanceHost); + + return IconButton( + icon: const Icon(Icons.email), + onPressed: loggedInAction((token) => showCupertinoModalPopup( + context: context, + builder: (_) => WriteMessagePage.send( + instanceHost: user.instanceHost, + recipient: user, + ))), + ); + } +} diff --git a/lib/pages/users_list.dart b/lib/pages/users_list.dart index dab6749..c6980e1 100644 --- a/lib/pages/users_list.dart +++ b/lib/pages/users_list.dart @@ -52,7 +52,7 @@ class UsersListItem extends StatelessWidget { ), ) : null, - onTap: () => goToUser.byId(context, user.instanceHost, user.user.id), + onTap: () => goToUser.fromUserSafe(context, user.user), leading: Avatar(url: user.user.avatar), ); } diff --git a/lib/pages/write_message.dart b/lib/pages/write_message.dart new file mode 100644 index 0000000..1ed9208 --- /dev/null +++ b/lib/pages/write_message.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:lemmy_api_client/v2.dart'; + +import '../hooks/stores.dart'; +import '../util/extensions/api.dart'; +import '../widgets/markdown_mode_icon.dart'; +import '../widgets/markdown_text.dart'; + +/// Page for writing and editing a private message +class WriteMessagePage extends HookWidget { + final UserSafe recipient; + final String instanceHost; + + /// if it's non null then this page is used for edit + final PrivateMessage privateMessage; + + final GlobalKey scaffoldKey = GlobalKey(); + + final bool _isEdit; + + WriteMessagePage.send({ + @required this.recipient, + @required this.instanceHost, + }) : assert(recipient != null), + assert(instanceHost != null), + privateMessage = null, + _isEdit = false; + + WriteMessagePage.edit(PrivateMessageView pmv) + : privateMessage = pmv.privateMessage, + recipient = pmv.recipient, + instanceHost = pmv.instanceHost, + _isEdit = true; + + @override + Widget build(BuildContext context) { + final accStore = useAccountsStore(); + final showFancy = useState(false); + final bodyController = + useTextEditingController(text: privateMessage?.content); + final loading = useState(false); + + final submit = _isEdit ? 'save' : 'send'; + final title = _isEdit ? 'Edit message' : 'Send message'; + + handleSubmit() async { + if (_isEdit) { + loading.value = true; + try { + final msg = await LemmyApiV2(instanceHost).run(EditPrivateMessage( + auth: accStore.defaultTokenFor(instanceHost)?.raw, + privateMessageId: privateMessage.id, + content: bodyController.text, + )); + Navigator.of(context).pop(msg); + + // ignore: avoid_catches_without_on_clauses + } catch (e) { + scaffoldKey.currentState.showSnackBar(SnackBar( + content: Text(e.toString()), + )); + } + loading.value = false; + } else { + loading.value = true; + try { + await LemmyApiV2(instanceHost).run(CreatePrivateMessage( + auth: accStore.defaultTokenFor(instanceHost)?.raw, + content: bodyController.text, + recipientId: recipient.id, + )); + Navigator.of(context).pop(); + // TODO: maybe send notification so that infinite list + // containing this widget adds new message? + // ignore: avoid_catches_without_on_clauses + } catch (e) { + scaffoldKey.currentState.showSnackBar(SnackBar( + content: Text(e.toString()), + )); + } finally { + loading.value = false; + } + } + } + + final body = IndexedStack( + index: showFancy.value ? 1 : 0, + children: [ + TextField( + controller: bodyController, + keyboardType: TextInputType.multiline, + maxLines: null, + minLines: 5, + autofocus: true, + ), + Padding( + padding: const EdgeInsets.all(16), + child: MarkdownText( + bodyController.text, + instanceHost: instanceHost, + ), + ), + ], + ); + + return Scaffold( + key: scaffoldKey, + appBar: AppBar( + title: Text(title), + leading: const CloseButton(), + actions: [ + IconButton( + icon: markdownModeIcon(fancy: showFancy.value), + onPressed: () => showFancy.value = !showFancy.value, + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + Text('to ${recipient.displayName}'), + const SizedBox(height: 16), + body, + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: loading.value ? () {} : handleSubmit, + child: loading.value + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator()) + : Text(submit), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/stores/accounts_store.dart b/lib/stores/accounts_store.dart index 86fbf2b..61c7c3d 100644 --- a/lib/stores/accounts_store.dart +++ b/lib/stores/accounts_store.dart @@ -29,7 +29,7 @@ class AccountsStore extends ChangeNotifier { // I barely understand what I did. Long story short it casts a // raw json into a nested ObservableMap - nestedMapsCast(T f(Map json)) => HashMap.of( + nestedMapsCast(T f(String jwt)) => HashMap.of( (jsonDecode(prefs.getString(SharedPrefKeys.tokens) ?? '{"lemmy.ml":{}}') as Map) ?.map( @@ -37,8 +37,7 @@ class AccountsStore extends ChangeNotifier { k, HashMap.of( (e as Map)?.map( - (k, e) => MapEntry( - k, e == null ? null : f(e as Map)), + (k, e) => MapEntry(k, e == null ? null : f(e as String)), ), ), ), @@ -46,7 +45,7 @@ class AccountsStore extends ChangeNotifier { ); // set saved settings or create defaults - _tokens = nestedMapsCast((json) => Jwt(json['raw'] as String)); + _tokens = nestedMapsCast((json) => Jwt(json)); _defaultAccount = prefs.getString(SharedPrefKeys.defaultAccount); _defaultAccounts = HashMap.of(Map.castFrom( jsonDecode(prefs.getString(SharedPrefKeys.defaultAccounts) ?? 'null') @@ -155,6 +154,14 @@ class AccountsStore extends ChangeNotifier { return _tokens[instanceHost][_defaultAccounts[instanceHost]]; } + /// returns token for user of a certain id + Jwt tokenForId(String instanceHost, int userId) => + _tokens.containsKey(instanceHost) + ? _tokens[instanceHost] + .values + .firstWhere((val) => val.payload.id == userId, orElse: () => null) + : null; + Jwt tokenFor(String instanceHost, String username) { if (!usernamesFor(instanceHost).contains(username)) { return null; @@ -243,7 +250,7 @@ class AccountsStore extends ChangeNotifier { if (!assumeValid) { try { - await LemmyApiV2(instanceHost).run(GetSite()); + await LemmyApiV2(instanceHost).run(const GetSite()); // ignore: avoid_catches_without_on_clauses } catch (_) { throw Exception('This instance seems to not exist'); diff --git a/lib/util/delayed_action.dart b/lib/util/delayed_action.dart new file mode 100644 index 0000000..e987706 --- /dev/null +++ b/lib/util/delayed_action.dart @@ -0,0 +1,33 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:lemmy_api_client/v2.dart'; + +import '../hooks/delayed_loading.dart'; + +/// Executes an API action that uses [DelayedLoading], has a try-catch +/// that displays a [SnackBar] on the Scaffold.of(context) when the action fails +Future delayedAction({ + @required BuildContext context, + @required DelayedLoading delayedLoading, + @required String instanceHost, + @required LemmyApiQuery query, + Function(T) onSuccess, + Function(T) cleanup, +}) async { + assert(delayedLoading != null); + assert(instanceHost != null); + assert(query != null); + assert(context != null); + + T val; + try { + delayedLoading.start(); + val = await LemmyApiV2(instanceHost).run(query); + onSuccess?.call(val); + // ignore: avoid_catches_without_on_clauses + } catch (e) { + Scaffold.of(context).showSnackBar(SnackBar(content: Text(e.toString()))); + } + cleanup?.call(val); + delayedLoading.cancel(); +} diff --git a/lib/util/extensions/api.dart b/lib/util/extensions/api.dart index 8c7e4bd..abf1a74 100644 --- a/lib/util/extensions/api.dart +++ b/lib/util/extensions/api.dart @@ -26,7 +26,13 @@ extension GetOriginInstanceCommentView on Comment { String _extract(String url) => urlHost(url); -extension DisplayNames on UserSafe { +extension CommunityDisplayNames on CommunitySafe { + String get displayName => '!$name'; + String get originDisplayName => + local ? displayName : '!$name@$originInstanceHost'; +} + +extension UserDisplayNames on UserSafe { String get displayName { if (preferredUsername != null && preferredUsername.isNotEmpty) { return preferredUsername; diff --git a/lib/util/goto.dart b/lib/util/goto.dart index 7972ee9..c3fe89f 100644 --- a/lib/util/goto.dart +++ b/lib/util/goto.dart @@ -1,5 +1,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:lemmy_api_client/v2.dart'; import '../pages/community.dart'; import '../pages/full_post.dart'; @@ -57,6 +58,9 @@ abstract class goToUser { static void byName( BuildContext context, String instanceHost, String userName) => throw UnimplementedError('need to create UserProfile constructor first'); + + static void fromUserSafe(BuildContext context, UserSafe userSafe) => + goToUser.byId(context, userSafe.instanceHost, userSafe.id); } void goToPost(BuildContext context, String instanceHost, int postId) => goTo( diff --git a/lib/widgets/bottom_modal.dart b/lib/widgets/bottom_modal.dart index 2743c3f..f58f5e6 100644 --- a/lib/widgets/bottom_modal.dart +++ b/lib/widgets/bottom_modal.dart @@ -24,8 +24,7 @@ class BottomModal extends StatelessWidget { child: Container( decoration: BoxDecoration( border: Border.all( - color: Colors.grey.withOpacity(0.5), - width: 0.2, + color: Colors.grey.withOpacity(0.3), ), borderRadius: BorderRadius.circular(10), ), diff --git a/lib/widgets/comment.dart b/lib/widgets/comment.dart index e8d1295..9ac3036 100644 --- a/lib/widgets/comment.dart +++ b/lib/widgets/comment.dart @@ -10,6 +10,7 @@ import '../comment_tree.dart'; import '../hooks/delayed_loading.dart'; import '../hooks/logged_in_action.dart'; import '../hooks/stores.dart'; +import '../util/delayed_action.dart'; import '../util/extensions/api.dart'; import '../util/extensions/datetime.dart'; import '../util/goto.dart'; @@ -18,17 +19,20 @@ import '../util/text_color.dart'; import 'avatar.dart'; import 'bottom_modal.dart'; import 'info_table_popup.dart'; +import 'markdown_mode_icon.dart'; import 'markdown_text.dart'; +import 'tile_action.dart'; import 'write_comment.dart'; /// A single comment that renders its replies class CommentWidget extends HookWidget { - final int indent; - final int postCreatorId; + final int depth; final CommentTree commentTree; final bool detached; - + final UserMentionView userMentionView; final bool wasVoted; + final bool canBeMarkedAsRead; + final bool hideOnRead; static const colors = [ Colors.pink, @@ -40,39 +44,39 @@ class CommentWidget extends HookWidget { CommentWidget( this.commentTree, { - this.indent = 0, - @required this.postCreatorId, + this.depth = 0, this.detached = false, - }) : wasVoted = - (commentTree.comment.myVote ?? VoteType.none) != VoteType.none; + this.canBeMarkedAsRead = false, + this.hideOnRead = false, + }) : wasVoted = + (commentTree.comment.myVote ?? VoteType.none) != VoteType.none, + userMentionView = null; + + CommentWidget.fromCommentView( + CommentView cv, { + bool canBeMarkedAsRead = false, + bool hideOnRead = false, + }) : this( + CommentTree(cv), + detached: true, + canBeMarkedAsRead: canBeMarkedAsRead, + hideOnRead: hideOnRead, + ); + + CommentWidget.fromUserMentionView( + this.userMentionView, { + this.hideOnRead = false, + }) : commentTree = + CommentTree(CommentView.fromJson(userMentionView.toJson())), + depth = 0, + wasVoted = (userMentionView.myVote ?? VoteType.none) != VoteType.none, + detached = true, + canBeMarkedAsRead = true; _showCommentInfo(BuildContext context) { final com = commentTree.comment; - showInfoTablePopup(context, { - 'id': com.comment.id, - 'creatorId': com.comment.creatorId, - 'postId': com.comment.postId, - 'postName': com.post.name, - 'parentId': com.comment.parentId, - 'removed': com.comment.removed, - 'read': com.comment.read, - 'published': com.comment.published, - 'updated': com.comment.updated, - 'deleted': com.comment.deleted, - 'apId': com.comment.apId, - 'local': com.comment.local, - 'communityId': com.community.id, - 'communityActorId': com.community.actorId, - 'communityLocal': com.community.local, - 'communityName': com.community.name, - 'communityIcon': com.community.icon, - 'banned': com.creator.banned, - 'bannedFromCommunity': com.creatorBannedFromCommunity, - 'creatorActirId': com.creator.actorId, - 'userId': com.creator.id, - 'upvotes': com.counts.upvotes, - 'downvotes': com.counts.downvotes, - 'score': com.counts.score, + showInfoTablePopup(context: context, table: { + ...com.toJson(), '% of upvotes': '${100 * (com.counts.upvotes / (com.counts.upvotes + com.counts.downvotes))}%', }); @@ -95,32 +99,33 @@ class CommentWidget extends HookWidget { final collapsed = useState(false); final myVote = useState(commentTree.comment.myVote ?? VoteType.none); final isDeleted = useState(commentTree.comment.comment.deleted); + final isRead = useState(commentTree.comment.comment.read); + final delayedVoting = useDelayedLoading(); final delayedDeletion = useDelayedLoading(); final loggedInAction = useLoggedInAction(commentTree.comment.instanceHost); + final newReplies = useState(const []); final comment = commentTree.comment; - handleDelete(Jwt token) async { - final api = LemmyApiV2(token.payload.iss); + if (hideOnRead && isRead.value) { + return const SizedBox.shrink(); + } - delayedDeletion.start(); + handleDelete(Jwt token) { Navigator.of(context).pop(); - try { - final res = await api.run(DeleteComment( + delayedAction( + context: context, + delayedLoading: delayedDeletion, + instanceHost: token.payload.iss, + query: DeleteComment( commentId: comment.comment.id, deleted: !isDeleted.value, auth: token.raw, - )); - isDeleted.value = res.commentView.comment.deleted; - // ignore: avoid_catches_without_on_clauses - } catch (e) { - Scaffold.of(context).showSnackBar( - const SnackBar(content: Text('Failed to delete/restore comment'))); - return; - } - delayedDeletion.cancel(); + ), + onSuccess: (res) => isDeleted.value = res.commentView.comment.deleted, + ); } void _openMoreMenu(BuildContext context) { @@ -162,7 +167,7 @@ class CommentWidget extends HookWidget { }, ), ListTile( - leading: Icon(showRaw.value ? Icons.brush : Icons.build), + leading: markdownModeIcon(fancy: !showRaw.value), title: Text('Show ${showRaw.value ? 'fancy' : 'raw'} text'), onTap: () { showRaw.value = !showRaw.value; @@ -188,29 +193,26 @@ class CommentWidget extends HookWidget { reply() async { final newComment = await showCupertinoModalPopup( context: context, - builder: (_) => WriteComment.toComment(comment), + builder: (_) => WriteComment.toComment( + comment: comment.comment, post: comment.post), ); if (newComment != null) { newReplies.value = [...newReplies.value, CommentTree(newComment)]; } } - vote(VoteType vote, Jwt token) async { - final api = LemmyApiV2(token.payload.iss); - - delayedVoting.start(); - try { - final res = await api.run(CreateCommentLike( - commentId: comment.comment.id, score: vote, auth: token.raw)); - myVote.value = res.commentView.myVote ?? VoteType.none; - // ignore: avoid_catches_without_on_clauses - } catch (e) { - Scaffold.of(context) - .showSnackBar(const SnackBar(content: Text('voting failed :('))); - return; - } - delayedVoting.cancel(); - } + vote(VoteType vote, Jwt token) => delayedAction( + context: context, + delayedLoading: delayedVoting, + instanceHost: token.payload.iss, + query: CreateCommentLike( + commentId: comment.comment.id, + score: vote, + auth: token.raw, + ), + onSuccess: (res) => + myVote.value = res.commentView.myVote ?? VoteType.none, + ); final body = () { if (isDeleted.value) { @@ -261,7 +263,7 @@ class CommentWidget extends HookWidget { if (selectable.value && !isDeleted.value && !comment.comment.removed) - _CommentAction( + TileAction( icon: Icons.content_copy, tooltip: 'copy', onPressed: () { @@ -272,27 +274,32 @@ class CommentWidget extends HookWidget { content: Text('comment copied to clipboard')))); }), const Spacer(), + if (canBeMarkedAsRead) + _MarkAsRead( + commentTree.comment, + onChanged: (val) => isRead.value = val, + ), if (detached) - _CommentAction( + TileAction( icon: Icons.link, onPressed: () => goToPost(context, comment.instanceHost, comment.post.id), tooltip: 'go to post', ), - _CommentAction( + TileAction( icon: Icons.more_horiz, onPressed: () => _openMoreMenu(context), - loading: delayedDeletion.loading, + delayedLoading: delayedDeletion, tooltip: 'more', ), _SaveComment(commentTree.comment), if (!isDeleted.value && !comment.comment.removed) - _CommentAction( + TileAction( icon: Icons.reply, onPressed: loggedInAction((_) => reply()), tooltip: 'reply', ), - _CommentAction( + TileAction( icon: Icons.arrow_upward, iconColor: myVote.value == VoteType.up ? theme.accentColor : null, onPressed: loggedInAction((token) => vote( @@ -301,7 +308,7 @@ class CommentWidget extends HookWidget { )), tooltip: 'upvote', ), - _CommentAction( + TileAction( icon: Icons.arrow_downward, iconColor: myVote.value == VoteType.down ? Colors.red : null, onPressed: loggedInAction( @@ -314,18 +321,19 @@ class CommentWidget extends HookWidget { ), ]); - return GestureDetector( - onLongPress: () => collapsed.value = !collapsed.value, + return InkWell( + onLongPress: + selectable.value ? null : () => collapsed.value = !collapsed.value, child: Column( children: [ Container( padding: const EdgeInsets.all(10), - margin: EdgeInsets.only(left: indent > 1 ? (indent - 1) * 5.0 : 0), + margin: EdgeInsets.only(left: depth > 1 ? (depth - 1) * 5.0 : 0), decoration: BoxDecoration( border: Border( - left: indent > 0 + left: depth > 0 ? BorderSide( - color: colors[indent % colors.length], width: 5) + color: colors[depth % colors.length], width: 5) : BorderSide.none, top: const BorderSide(width: 0.2))), child: Column( @@ -335,8 +343,8 @@ class CommentWidget extends HookWidget { Padding( padding: const EdgeInsets.only(right: 5), child: InkWell( - onTap: () => goToUser.byId( - context, comment.instanceHost, comment.creator.id), + onTap: () => + goToUser.fromUserSafe(context, comment.creator), child: Avatar( url: comment.creator.avatar, radius: 10, @@ -345,14 +353,16 @@ class CommentWidget extends HookWidget { ), ), InkWell( - onTap: () => goToUser.byId( - context, comment.instanceHost, comment.creator.id), + onTap: () => + goToUser.fromUserSafe(context, comment.creator), child: Text(comment.creator.originDisplayName, style: TextStyle( - color: Theme.of(context).accentColor, + color: theme.accentColor, )), ), - if (isOP) _CommentTag('OP', Theme.of(context).accentColor), + if (isOP) _CommentTag('OP', theme.accentColor), + if (comment.creator.admin) + _CommentTag('ADMIN', theme.accentColor), if (comment.creator.banned) const _CommentTag('BANNED', Colors.red), if (comment.creatorBannedFromCommunity) @@ -389,17 +399,58 @@ class CommentWidget extends HookWidget { ), if (!collapsed.value) for (final c in newReplies.value.followedBy(commentTree.children)) - CommentWidget( - c, - indent: indent + 1, - postCreatorId: postCreatorId, - ), + CommentWidget(c, depth: depth + 1), ], ), ); } } +class _MarkAsRead extends HookWidget { + final CommentView commentView; + final ValueChanged onChanged; + + const _MarkAsRead(this.commentView, {this.onChanged}); + + @override + Widget build(BuildContext context) { + final accStore = useAccountsStore(); + + final comment = commentView.comment; + final recipient = commentView.recipient; + final instanceHost = commentView.instanceHost; + final post = commentView.post; + + final isRead = useState(comment.read); + final delayedRead = useDelayedLoading(); + + Future handleMarkAsSeen() => delayedAction( + context: context, + delayedLoading: delayedRead, + instanceHost: instanceHost, + query: MarkCommentAsRead( + commentId: comment.id, + read: !isRead.value, + auth: recipient != null + ? accStore.tokenFor(instanceHost, recipient.name)?.raw + : accStore.tokenForId(instanceHost, post.creatorId)?.raw, + ), + onSuccess: (val) { + isRead.value = val.commentView.comment.read; + onChanged?.call(isRead.value); + }, + ); + + return TileAction( + icon: Icons.check, + delayedLoading: delayedRead, + onPressed: handleMarkAsSeen, + iconColor: isRead.value ? Theme.of(context).accentColor : null, + tooltip: 'mark as ${isRead.value ? 'un' : ''}read', + ); + } +} + class _SaveComment extends HookWidget { final CommentView comment; @@ -411,27 +462,20 @@ class _SaveComment extends HookWidget { final isSaved = useState(comment.saved ?? false); final delayed = useDelayedLoading(); - handleSave(Jwt token) async { - final api = LemmyApiV2(comment.instanceHost); + handleSave(Jwt token) => delayedAction( + context: context, + delayedLoading: delayed, + instanceHost: comment.instanceHost, + query: SaveComment( + commentId: comment.comment.id, + save: !isSaved.value, + auth: token.raw, + ), + onSuccess: (res) => isSaved.value = res.commentView.saved, + ); - delayed.start(); - try { - final res = await api.run(SaveComment( - commentId: comment.comment.id, - save: !isSaved.value, - auth: token.raw, - )); - isSaved.value = res.commentView.saved; - // ignore: avoid_catches_without_on_clauses - } catch (e) { - Scaffold.of(context) - .showSnackBar(const SnackBar(content: Text('saving failed :('))); - } - delayed.cancel(); - } - - return _CommentAction( - loading: delayed.loading, + return TileAction( + delayedLoading: delayed, icon: isSaved.value ? Icons.bookmark : Icons.bookmark_border, onPressed: loggedInAction(delayed.pending ? (_) {} : handleSave), tooltip: '${isSaved.value ? 'unsave' : 'save'} comment', @@ -463,39 +507,3 @@ class _CommentTag extends StatelessWidget { ), ); } - -class _CommentAction extends StatelessWidget { - final IconData icon; - final VoidCallback onPressed; - final String tooltip; - final bool loading; - final Color iconColor; - - const _CommentAction({ - Key key, - this.loading = false, - this.iconColor, - @required this.icon, - @required this.onPressed, - @required this.tooltip, - }) : super(key: key); - - @override - Widget build(BuildContext context) => IconButton( - constraints: BoxConstraints.tight(const Size(32, 26)), - icon: loading - ? SizedBox.fromSize( - size: const Size.square(22), - child: const CircularProgressIndicator()) - : Icon( - icon, - color: iconColor ?? - Theme.of(context).iconTheme.color.withAlpha(190), - ), - splashRadius: 25, - onPressed: onPressed, - iconSize: 22, - tooltip: tooltip, - padding: const EdgeInsets.all(0), - ); -} diff --git a/lib/widgets/comment_section.dart b/lib/widgets/comment_section.dart index 6de1ad7..969b5bf 100644 --- a/lib/widgets/comment_section.dart +++ b/lib/widgets/comment_section.dart @@ -94,14 +94,9 @@ class CommentSection extends HookWidget { ), ) else if (sorting.value == CommentSortType.chat) - for (final com in rawComments) - CommentWidget( - CommentTree(com), - postCreatorId: postCreatorId, - ) + for (final com in rawComments) CommentWidget.fromCommentView(com) else - for (final com in comments) - CommentWidget(com, postCreatorId: postCreatorId), + for (final com in comments) CommentWidget(com), const BottomSafe(kMinInteractiveDimension + kFloatingActionButtonMargin), ]); } diff --git a/lib/widgets/infinite_scroll.dart b/lib/widgets/infinite_scroll.dart index 0789307..1ce4b12 100644 --- a/lib/widgets/infinite_scroll.dart +++ b/lib/widgets/infinite_scroll.dart @@ -73,7 +73,6 @@ class InfiniteScroll extends HookWidget { data.value = []; hasMore.current = true; }; - return controller.dispose; } return null; diff --git a/lib/widgets/info_table_popup.dart b/lib/widgets/info_table_popup.dart index 0e88689..261823c 100644 --- a/lib/widgets/info_table_popup.dart +++ b/lib/widgets/info_table_popup.dart @@ -1,21 +1,47 @@ import 'package:flutter/material.dart'; -void showInfoTablePopup(BuildContext context, Map table) { - showDialog( +import 'bottom_modal.dart'; + +void showInfoTablePopup({ + @required BuildContext context, + @required Map table, + String title, +}) { + assert(context != null); + assert(table != null); + + showBottomModal( context: context, - builder: (c) => SimpleDialog( - contentPadding: const EdgeInsets.symmetric( + title: title, + builder: (context) => Padding( + padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 15, ), - children: [ - Table( - children: table.entries - .map((e) => TableRow( - children: [Text('${e.key}:'), Text(e.value.toString())])) - .toList(), - ), - ], + child: Column( + children: [ + Table(children: [ + for (final e in table.entries) + TableRow(children: [ + Text('${e.key}:'), + if (e.value is Map) + GestureDetector( + onTap: () => showInfoTablePopup( + context: context, + table: e.value as Map, + title: e.key, + ), + child: Text( + '[tap to show]', + style: TextStyle(color: Theme.of(context).accentColor), + ), + ) + else + Text(e.value.toString()) + ]) + ]), + ], + ), ), ); } diff --git a/lib/widgets/markdown_mode_icon.dart b/lib/widgets/markdown_mode_icon.dart new file mode 100644 index 0000000..63af275 --- /dev/null +++ b/lib/widgets/markdown_mode_icon.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +/// shows either brush icon if fancy is true, or build icon if it's false +/// used mostly for pages where markdown editor is used +/// +/// brush icon is rotated to look similarly to build icon +Widget markdownModeIcon({bool fancy}) => fancy + ? const Icon(Icons.build) + : const RotatedBox( + quarterTurns: 1, + child: Icon(Icons.brush), + ); diff --git a/lib/widgets/post.dart b/lib/widgets/post.dart index 448d8a3..8684fa8 100644 --- a/lib/widgets/post.dart +++ b/lib/widgets/post.dart @@ -76,17 +76,10 @@ class PostWidget extends HookWidget { leading: const Icon(Icons.info_outline), title: const Text('Nerd stuff'), onTap: () { - showInfoTablePopup(context, { - 'id': post.post.id, - 'apId': post.post.apId, - 'upvotes': post.counts.upvotes, - 'downvotes': post.counts.downvotes, - 'score': post.counts.score, + showInfoTablePopup(context: context, table: { '% of upvotes': '${(100 * (post.counts.upvotes / (post.counts.upvotes + post.counts.downvotes))).toInt()}%', - 'local': post.post.local, - 'published': post.post.published, - 'updated': post.post.updated ?? 'never', + ...post.toJson(), }); }, ), @@ -124,6 +117,7 @@ class PostWidget extends HookWidget { Padding( padding: const EdgeInsets.only(right: 10), child: InkWell( + borderRadius: BorderRadius.circular(20), onTap: () => goToCommunity.byId( context, instanceHost, post.community.id), child: Avatar( @@ -195,10 +189,9 @@ class PostWidget extends HookWidget { style: const TextStyle( fontWeight: FontWeight.w600), recognizer: TapGestureRecognizer() - ..onTap = () => goToUser.byId( + ..onTap = () => goToUser.fromUserSafe( context, - post.instanceHost, - post.creator.id, + post.creator, ), ), TextSpan( @@ -263,6 +256,7 @@ class PostWidget extends HookWidget { post.post.thumbnailUrl != null) ...[ const Spacer(), InkWell( + borderRadius: BorderRadius.circular(20), onTap: _openLink, child: Stack( children: [ @@ -322,16 +316,27 @@ class PostWidget extends HookWidget { Row( children: [ Flexible( - child: Text(post.post.embedTitle ?? '', - style: theme.textTheme.subtitle1 - .apply(fontWeightDelta: 2))) + child: Text( + post.post.embedTitle ?? '', + style: theme.textTheme.subtitle1 + .apply(fontWeightDelta: 2), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ) ], ), if (post.post.embedDescription != null && post.post.embedDescription.isNotEmpty) Row( children: [ - Flexible(child: Text(post.post.embedDescription)) + Flexible( + child: Text( + post.post.embedDescription, + maxLines: 4, + overflow: TextOverflow.ellipsis, + ), + ) ], ), ], @@ -387,82 +392,85 @@ class PostWidget extends HookWidget { return Container( decoration: BoxDecoration( - boxShadow: const [BoxShadow(blurRadius: 10, color: Colors.black54)], + boxShadow: const [BoxShadow(blurRadius: 10, color: Colors.black45)], color: theme.cardColor, borderRadius: const BorderRadius.all(Radius.circular(20)), ), - child: InkWell( + child: GestureDetector( onTap: fullPost ? null : () => goTo(context, (context) => FullPostPage.fromPostView(post)), - child: Column( - children: [ - info(), - title(), - if (whatType(post.post.url) != MediaType.other && - whatType(post.post.url) != MediaType.none) - postImage() - else if (post.post.url != null && post.post.url.isNotEmpty) - linkPreview(), - if (post.post.body != null && fullPost) - Padding( - padding: const EdgeInsets.all(10), - child: - MarkdownText(post.post.body, instanceHost: instanceHost)), - if (post.post.body != null && !fullPost) - LayoutBuilder( - builder: (context, constraints) { - final span = TextSpan( - text: post.post.body, - ); - final tp = TextPainter( - text: span, - maxLines: 10, - textDirection: Directionality.of(context), - )..layout(maxWidth: constraints.maxWidth - 20); + child: Material( + type: MaterialType.transparency, + child: Column( + children: [ + info(), + title(), + if (whatType(post.post.url) != MediaType.other && + whatType(post.post.url) != MediaType.none) + postImage() + else if (post.post.url != null && post.post.url.isNotEmpty) + linkPreview(), + if (post.post.body != null && fullPost) + Padding( + padding: const EdgeInsets.all(10), + child: MarkdownText(post.post.body, + instanceHost: instanceHost)), + if (post.post.body != null && !fullPost) + LayoutBuilder( + builder: (context, constraints) { + final span = TextSpan( + text: post.post.body, + ); + final tp = TextPainter( + text: span, + maxLines: 10, + textDirection: Directionality.of(context), + )..layout(maxWidth: constraints.maxWidth - 20); - if (tp.didExceedMaxLines) { - return ConstrainedBox( - constraints: BoxConstraints(maxHeight: tp.height), - child: Stack( - alignment: Alignment.bottomCenter, - children: [ - ClipRect( - child: Align( - alignment: Alignment.topCenter, - heightFactor: 0.8, - child: Padding( - padding: const EdgeInsets.all(10), - child: MarkdownText(post.post.body, - instanceHost: instanceHost)), - ), - ), - Container( - height: tp.preferredLineHeight * 2.5, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - theme.cardColor.withAlpha(0), - theme.cardColor, - ], + if (tp.didExceedMaxLines) { + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: tp.height), + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: 0.8, + child: Padding( + padding: const EdgeInsets.all(10), + child: MarkdownText(post.post.body, + instanceHost: instanceHost)), ), ), - ), - ], - ), - ); - } else { - return Padding( - padding: const EdgeInsets.all(10), - child: MarkdownText(post.post.body, - instanceHost: instanceHost)); - } - }, - ), - actions(), - ], + Container( + height: tp.preferredLineHeight * 2.5, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + theme.cardColor.withAlpha(0), + theme.cardColor, + ], + ), + ), + ), + ], + ), + ); + } else { + return Padding( + padding: const EdgeInsets.all(10), + child: MarkdownText(post.post.body, + instanceHost: instanceHost)); + } + }, + ), + actions(), + ], + ), ), ), ); diff --git a/lib/widgets/reveal_after_scroll.dart b/lib/widgets/reveal_after_scroll.dart new file mode 100644 index 0000000..1cc1edd --- /dev/null +++ b/lib/widgets/reveal_after_scroll.dart @@ -0,0 +1,41 @@ +import 'dart:math' show max, min; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// Makes the child reveal itself after given distance +class RevealAfterScroll extends HookWidget { + final Widget child; + + /// distance after which [child] should appear + final int after; + final int transition; + final ScrollController scrollController; + final bool fade; + + const RevealAfterScroll({ + @required this.scrollController, + @required this.child, + @required this.after, + this.transition = 15, + this.fade = false, + }) : assert(scrollController != null), + assert(child != null), + assert(after != null); + + @override + Widget build(BuildContext context) { + useListenable(scrollController); + + final scroll = scrollController.position.pixels; + + return Opacity( + opacity: + fade ? max(0, min(transition, scroll - after + 20)) / transition : 1, + child: Transform.translate( + offset: Offset(0, max(0, after - scroll)), + child: child, + ), + ); + } +} diff --git a/lib/widgets/sortable_infinite_list.dart b/lib/widgets/sortable_infinite_list.dart index b6f3265..3485e61 100644 --- a/lib/widgets/sortable_infinite_list.dart +++ b/lib/widgets/sortable_infinite_list.dart @@ -19,6 +19,7 @@ class SortableInfiniteList extends HookWidget { final InfiniteScrollController controller; final Function onStyleChange; final Widget noItems; + final SortType defaultSort; const SortableInfiniteList({ @required this.fetcher, @@ -26,15 +27,17 @@ class SortableInfiniteList extends HookWidget { this.controller, this.onStyleChange, this.noItems, + this.defaultSort = SortType.active, }) : assert(fetcher != null), - assert(itemBuilder != null); + assert(itemBuilder != null), + assert(defaultSort != null); @override Widget build(BuildContext context) { final defaultController = useInfiniteScrollController(); final isc = controller ?? defaultController; - final sort = useState(SortType.active); + final sort = useState(defaultSort); void changeSorting(SortType newSort) { sort.value = newSort; @@ -92,7 +95,6 @@ class InfiniteCommentList extends StatelessWidget { Widget build(BuildContext context) => SortableInfiniteList( itemBuilder: (comment) => CommentWidget( CommentTree(comment), - postCreatorId: null, detached: true, ), fetcher: fetcher, diff --git a/lib/widgets/tile_action.dart b/lib/widgets/tile_action.dart new file mode 100644 index 0000000..2f6c040 --- /dev/null +++ b/lib/widgets/tile_action.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import '../hooks/delayed_loading.dart'; + +/// [IconButton], usually at the bottom of some tile, that performs an async +/// action that uses [DelayedLoading], has reduced size to be more compact, +/// and has built in spinner +class TileAction extends StatelessWidget { + final IconData icon; + final VoidCallback onPressed; + final String tooltip; + final DelayedLoading delayedLoading; + final Color iconColor; + + const TileAction({ + Key key, + this.delayedLoading, + this.iconColor, + @required this.icon, + @required this.onPressed, + @required this.tooltip, + }) : assert(icon != null), + assert(onPressed != null), + assert(tooltip != null), + super(key: key); + + @override + Widget build(BuildContext context) => IconButton( + constraints: BoxConstraints.tight(const Size(36, 30)), + icon: delayedLoading?.loading ?? false + ? SizedBox.fromSize( + size: const Size.square(22), + child: const CircularProgressIndicator()) + : Icon( + icon, + color: iconColor ?? + Theme.of(context).iconTheme.color.withAlpha(190), + ), + splashRadius: 25, + onPressed: delayedLoading?.pending ?? false ? () {} : onPressed, + iconSize: 25, + tooltip: tooltip, + padding: const EdgeInsets.all(0), + ); +} diff --git a/lib/widgets/write_comment.dart b/lib/widgets/write_comment.dart index 0626b58..aae2d04 100644 --- a/lib/widgets/write_comment.dart +++ b/lib/widgets/write_comment.dart @@ -4,24 +4,22 @@ import 'package:lemmy_api_client/v2.dart'; import '../hooks/delayed_loading.dart'; import '../hooks/stores.dart'; +import 'markdown_mode_icon.dart'; import 'markdown_text.dart'; /// Modal for writing a comment to a given post/comment (aka reply) /// on submit pops the navigator stack with a [CommentView] /// or `null` if cancelled class WriteComment extends HookWidget { - final PostView post; - final CommentView comment; + final Post post; + final Comment comment; - final String instanceHost; final GlobalKey scaffoldKey = GlobalKey(); - WriteComment.toPost(this.post) - : instanceHost = post.instanceHost, - comment = null; - WriteComment.toComment(this.comment) - : instanceHost = comment.instanceHost, - post = null; + WriteComment.toPost(this.post) : comment = null; + WriteComment.toComment({@required this.comment, @required this.post}) + : assert(comment != null), + assert(post != null); @override Widget build(BuildContext context) { @@ -32,15 +30,15 @@ class WriteComment extends HookWidget { final preview = () { final body = MarkdownText( - comment?.comment?.content ?? post.post.body ?? '', - instanceHost: instanceHost, + comment?.content ?? post.body, + instanceHost: post.instanceHost, ); if (post != null) { return Column( children: [ Text( - post.post.name, + post.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), ), const SizedBox(height: 4), @@ -53,22 +51,21 @@ class WriteComment extends HookWidget { }(); handleSubmit() async { - final api = LemmyApiV2(instanceHost); + final api = LemmyApiV2(post.instanceHost); - final token = accStore.defaultTokenFor(instanceHost); + final token = accStore.defaultTokenFor(post.instanceHost); delayed.start(); try { final res = await api.run(CreateComment( content: controller.text, - postId: post?.post?.id ?? comment.post.id, - parentId: comment?.comment?.id, + postId: post.id, + parentId: comment?.id, auth: token.raw, )); Navigator.of(context).pop(res.commentView); // ignore: avoid_catches_without_on_clauses } catch (e) { - print(e); scaffoldKey.currentState.showSnackBar( const SnackBar(content: Text('Failed to post comment'))); } @@ -81,7 +78,7 @@ class WriteComment extends HookWidget { leading: const CloseButton(), actions: [ IconButton( - icon: Icon(showFancy.value ? Icons.build : Icons.brush), + icon: markdownModeIcon(fancy: showFancy.value), onPressed: () => showFancy.value = !showFancy.value, ), ], @@ -110,7 +107,7 @@ class WriteComment extends HookWidget { padding: const EdgeInsets.all(16), child: MarkdownText( controller.text, - instanceHost: instanceHost, + instanceHost: post.instanceHost, ), ) ], diff --git a/pubspec.lock b/pubspec.lock index bbbc3be..9080205 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -232,14 +232,14 @@ packages: name: image_picker url: "https://pub.dartlang.org" source: hosted - version: "0.6.7+21" + version: "0.6.7+22" image_picker_platform_interface: dependency: transitive description: name: image_picker_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.1.1" + version: "1.1.6" intl: dependency: transitive description: @@ -267,7 +267,7 @@ packages: name: lemmy_api_client url: "https://pub.dartlang.org" source: hosted - version: "0.11.0" + version: "0.12.0" markdown: dependency: "direct main" description: @@ -323,7 +323,7 @@ packages: name: package_info url: "https://pub.dartlang.org" source: hosted - version: "0.4.3+2" + version: "0.4.3+4" path: dependency: transitive description: @@ -463,7 +463,7 @@ packages: name: shared_preferences_windows url: "https://pub.dartlang.org" source: hosted - version: "0.0.2+2" + version: "0.0.2+3" sky_engine: dependency: transitive description: flutter @@ -482,7 +482,7 @@ packages: name: sqflite url: "https://pub.dartlang.org" source: hosted - version: "1.3.2+2" + version: "1.3.2+3" sqflite_common: dependency: transitive description: @@ -580,7 +580,7 @@ packages: name: url_launcher_web url: "https://pub.dartlang.org" source: hosted - version: "0.1.5+1" + version: "0.1.5+3" url_launcher_windows: dependency: transitive description: @@ -602,13 +602,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0-nullsafety.3" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" win32: dependency: transitive description: name: win32 url: "https://pub.dartlang.org" source: hosted - version: "1.7.4" + version: "1.7.4+1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4f55e47..2e3c420 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,8 +44,7 @@ dependencies: # utils timeago: ^2.0.27 fuzzy: <1.0.0 - lemmy_api_client: ^0.11.0 - + lemmy_api_client: ^0.12.0 matrix4_transform: ^1.1.7 flutter: