add inbox page + other stuff (#164)

This commit is contained in:
Filip Krawczyk 2021-02-24 20:52:18 +01:00 committed by GitHub
parent 5789cfb01d
commit 19b2688316
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1099 additions and 344 deletions

View File

@ -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

View File

@ -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;
}

View File

@ -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]);

View File

@ -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) {

View File

@ -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(

View File

@ -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) => <Widget>[
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),
),
]
],

View File

@ -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,
),
],

View File

@ -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<CommentView>(
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(

View File

@ -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),

View File

@ -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<String>(
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<CommentView>(
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<UserMentionView>(
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<PrivateMessageView>(
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<PrivateMessageView>(
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<PrivateMessageView>(
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<PrivateMessageView>(
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(),
],
),
);
}
}

View File

@ -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) => <Widget>[
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(),

View File

@ -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: [

View File

@ -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:

View File

@ -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,
))),
);
}
}

View File

@ -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),
);
}

View File

@ -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<ScaffoldState> 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),
),
),
],
),
),
);
}
}

View File

@ -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>(T f(Map<String, dynamic> json)) => HashMap.of(
nestedMapsCast<T>(T f(String jwt)) => HashMap.of(
(jsonDecode(prefs.getString(SharedPrefKeys.tokens) ??
'{"lemmy.ml":{}}') as Map<String, dynamic>)
?.map(
@ -37,8 +37,7 @@ class AccountsStore extends ChangeNotifier {
k,
HashMap.of(
(e as Map<String, dynamic>)?.map(
(k, e) => MapEntry(
k, e == null ? null : f(e as Map<String, dynamic>)),
(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');

View File

@ -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<void> delayedAction<T>({
@required BuildContext context,
@required DelayedLoading delayedLoading,
@required String instanceHost,
@required LemmyApiQuery<T> 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<T>(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();
}

View File

@ -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;

View File

@ -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(

View File

@ -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),
),

View File

@ -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 <CommentTree>[]);
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<FullCommentView>(
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<CommentView>(
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<FullCommentView>(
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<bool> 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<void> handleMarkAsSeen() => delayedAction<FullCommentView>(
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<FullCommentView>(
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),
);
}

View File

@ -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),
]);
}

View File

@ -73,7 +73,6 @@ class InfiniteScroll<T> extends HookWidget {
data.value = [];
hasMore.current = true;
};
return controller.dispose;
}
return null;

View File

@ -1,21 +1,47 @@
import 'package:flutter/material.dart';
void showInfoTablePopup(BuildContext context, Map<String, dynamic> table) {
showDialog(
import 'bottom_modal.dart';
void showInfoTablePopup({
@required BuildContext context,
@required Map<String, dynamic> 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<String, dynamic>)
GestureDetector(
onTap: () => showInfoTablePopup(
context: context,
table: e.value as Map<String, dynamic>,
title: e.key,
),
child: Text(
'[tap to show]',
style: TextStyle(color: Theme.of(context).accentColor),
),
)
else
Text(e.value.toString())
])
]),
],
),
),
);
}

View File

@ -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),
);

View File

@ -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(),
],
),
),
),
);

View File

@ -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,
),
);
}
}

View File

@ -19,6 +19,7 @@ class SortableInfiniteList<T> 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<T> 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<CommentView>(
itemBuilder: (comment) => CommentWidget(
CommentTree(comment),
postCreatorId: null,
detached: true,
),
fetcher: fetcher,

View File

@ -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),
);
}

View File

@ -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<ScaffoldState> 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,
),
)
],

View File

@ -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:

View File

@ -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: