Merge pull request #21 from krawieck/post

This commit is contained in:
Marcin Wojnarowski 2020-08-31 21:43:42 +02:00 committed by GitHub
commit cf0d1167cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 681 additions and 286 deletions

33
lib/comment_tree.dart Normal file
View File

@ -0,0 +1,33 @@
import 'package:lemmy_api_client/lemmy_api_client.dart';
class CommentTree {
CommentView comment;
List<CommentTree> children;
CommentTree(this.comment, [this.children]) {
children ??= [];
}
static List<CommentTree> fromList(List<CommentView> comments) {
CommentTree gatherChildren(CommentTree parent) {
for (var el in comments) {
if (el.parentId == parent.comment.id) {
parent.children.add(gatherChildren(CommentTree(el)));
}
}
return parent;
}
var parents = <CommentTree>[];
// first pass to get all the parents
for (var i = 0; i < comments.length; i++) {
if (comments[i].parentId == null) {
parents.add(CommentTree(comments[i]));
}
}
var result = parents.map(gatherChildren).toList();
return result;
}
}

View File

@ -1,284 +0,0 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:esys_flutter_share/esys_flutter_share.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:lemmy_api_client/src/models/post.dart';
import 'package:timeago/timeago.dart' as timeago;
class PostWidget extends StatelessWidget {
final PostView post;
final String hostUrl;
/// nullable
final String linkPostDomain;
ThemeData _theme;
//https://images-assets.nasa.gov/image/PIA04921/PIA04921~orig.jpg
PostWidget(this.post)
: hostUrl = post.communityActorId.split('/')[2],
linkPostDomain = post.url != null ? post.url.split('/')[2] : null;
@override
Widget build(BuildContext context) {
_theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
boxShadow: [BoxShadow(blurRadius: 10, color: Colors.black54)],
color: _theme.colorScheme.surface,
borderRadius: BorderRadius.all(Radius.circular(20)),
),
child: InkWell(
onTap: () {
print('GO TO POST');
},
child: Column(
children: [
_info(),
_title(),
_content(),
_actions(),
],
),
),
);
}
Widget _info() {
return Column(children: [
Padding(
padding: const EdgeInsets.all(10),
child: Row(children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
if (post.communityIcon != null)
Padding(
padding: const EdgeInsets.only(right: 10),
child: InkWell(
onTap: () => print('GO TO COMMUNITY'),
child: SizedBox(
height: 40,
width: 40,
child: CachedNetworkImage(
imageBuilder: (context, imageProvider) => Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
image: DecorationImage(
fit: BoxFit.cover,
image: imageProvider,
),
),
),
imageUrl: post.communityIcon,
errorWidget: (context, url, error) =>
Text(error.toString()),
),
),
),
),
],
),
Column(
children: [
Row(children: [
RichText(
overflow: TextOverflow.ellipsis, // @TODO: fix overflowing
text: TextSpan(
style: TextStyle(
fontSize: 15, color: _theme.textTheme.bodyText1.color),
children: [
TextSpan(
text: '!',
style: TextStyle(fontWeight: FontWeight.w300)),
TextSpan(
text: post.communityName,
style: TextStyle(fontWeight: FontWeight.w600),
recognizer: TapGestureRecognizer()
..onTap = () => print('GO TO COMMUNITY')),
TextSpan(
text: '@',
style: TextStyle(fontWeight: FontWeight.w300)),
TextSpan(
text: hostUrl,
style: TextStyle(fontWeight: FontWeight.w600),
recognizer: TapGestureRecognizer()
..onTap = () => print('GO TO INSTANCE')),
],
),
)
]),
Row(children: [
RichText(
overflow: TextOverflow.ellipsis,
text: TextSpan(
style: TextStyle(
fontSize: 13,
color: _theme.textTheme.bodyText1.color),
children: [
TextSpan(
text: 'by',
style: TextStyle(fontWeight: FontWeight.w300)),
TextSpan(
text:
''' ${post.creatorPreferredUsername ?? post.creatorName}''',
style: TextStyle(fontWeight: FontWeight.w600),
recognizer: TapGestureRecognizer()
..onTap = () => print('GO TO USER'),
),
TextSpan(
text:
''' · ${timeago.format(post.published, locale: 'en_short')}'''),
if (linkPostDomain != null)
TextSpan(text: ' · $linkPostDomain'),
if (post.locked) TextSpan(text: ' · 🔒'),
],
))
]),
],
crossAxisAlignment: CrossAxisAlignment.start,
),
Spacer(),
Column(
children: [
IconButton(
onPressed: () => print('POPUP MENU'),
icon: Icon(Icons.more_vert),
)
],
)
]),
),
]);
}
Widget _title() {
return Padding(
padding: const EdgeInsets.only(left: 10, right: 10, bottom: 10),
child: Row(
children: [
Flexible(
child: Text(
'${post.name}',
textAlign: TextAlign.left,
softWrap: true,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
),
if (post.thumbnailUrl != null)
InkWell(
onTap: () => print('OPEN LINK'),
child: Stack(children: [
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: CachedNetworkImage(
imageUrl: post.thumbnailUrl,
width: 70,
height: 70,
fit: BoxFit.cover,
errorWidget: (context, url, error) =>
Text(error.toString()),
)),
Positioned(
top: 8,
right: 8,
child: Icon(
Icons.launch,
size: 20,
),
)
]),
)
],
),
);
}
Widget _content() {
if (post.url == null) return Container();
// naive implementation for now but will have
// to add system for detecting type of media
if (post.url.endsWith('.jpg') ||
post.url.endsWith('.png') ||
post.url.endsWith('.gif')) {
return CachedNetworkImage(
imageUrl: post.url,
progressIndicatorBuilder: (context, url, progress) =>
CircularProgressIndicator(value: progress.progress),
);
} else {
return _linkPreview();
}
}
Widget _linkPreview() {
assert(post.url != null);
var url = post.url.split('/')[2];
if (url.startsWith('www.')) {
url = url.substring(4);
}
return Padding(
padding: const EdgeInsets.all(10),
child: InkWell(
onTap: () => print('OPEN LINK'),
child: Container(
decoration: BoxDecoration(
border: Border.all(width: 1),
borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
children: [
Row(children: [
Spacer(),
Text('$url ',
style: _theme.textTheme.caption
.apply(fontStyle: FontStyle.italic)),
Icon(Icons.launch, size: 12),
]),
Row(children: [
Flexible(
child: Text(post.embedTitle,
style: _theme.textTheme.subtitle1
.apply(fontWeightDelta: 2)))
]),
Row(children: [Flexible(child: Text(post.embedDescription))]),
],
),
),
),
),
);
}
Widget _actions() {
return Padding(
padding: const EdgeInsets.fromLTRB(10, 5, 10, 5),
child: Row(
children: [
Icon(Icons.comment),
post.numberOfComments == 1
? Text(' 1 comment')
: Text(' ${post.numberOfComments} comments'),
Spacer(),
IconButton(
icon: Icon(Icons.share),
onPressed: () => Share.text('Share post url', post.apId,
'text/plain')), // @TODO: find a way to mark it as url
IconButton(
icon: post.saved == true
? Icon(Icons.bookmark)
: Icon(Icons.bookmark_border),
onPressed: () => print('SAVE')),
IconButton(
icon: Icon(Icons.arrow_upward), onPressed: () => print('UPVOTE')),
Text(post.score.toString()),
IconButton(
icon: Icon(Icons.arrow_downward),
onPressed: () => print('DOWNVOTE')),
],
),
);
}
}

10
lib/url_launcher.dart Normal file
View File

@ -0,0 +1,10 @@
import 'package:url_launcher/url_launcher.dart' as ul;
Future<void> urlLauncher(String url) async {
if (await ul.canLaunch(url)) {
await ul.launch(url);
} else {
throw Exception();
// TODO: handle opening links to stuff in app
}
}

143
lib/widgets/comment.dart Normal file
View File

@ -0,0 +1,143 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../comment_tree.dart';
import 'markdown_text.dart';
class Comment extends StatelessWidget {
final int indent;
final int postCreatorId;
final CommentTree commentTree;
Comment(
this.commentTree, {
this.indent = 0,
@required this.postCreatorId,
});
void _goToUser() {
print('GO TO USER');
}
bool get isOP => commentTree.comment.creatorId == postCreatorId;
@override
Widget build(BuildContext context) {
var comment = commentTree.comment;
// decide which username to use
var username;
if (comment.creatorPreferredUsername != null &&
comment.creatorPreferredUsername != '') {
username = comment.creatorPreferredUsername;
} else {
username = '@${comment.creatorName}';
}
var body;
if (comment.deleted) {
body = Flexible(
child: Text(
'comment deleted by creator',
style: TextStyle(fontStyle: FontStyle.italic),
));
} else if (comment.removed) {
body = Flexible(
child: Text(
'comment deleted by moderator',
style: TextStyle(fontStyle: FontStyle.italic),
));
} else {
body = Flexible(child: MarkdownText(commentTree.comment.content));
}
return Column(
children: [
Container(
child: Column(
children: [
Row(children: [
if (comment.creatorAvatar != null)
InkWell(
onTap: _goToUser,
child: Padding(
padding: const EdgeInsets.only(right: 5),
child: CachedNetworkImage(
imageUrl: comment.creatorAvatar,
height: 20,
width: 20,
imageBuilder: (context, imageProvider) => Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
image: DecorationImage(
fit: BoxFit.cover,
image: imageProvider,
),
),
),
),
),
),
InkWell(
child: Text(username,
style: TextStyle(
color: Theme.of(context).accentColor,
)),
onLongPress: _goToUser,
),
if (isOP) CommentTag('OP', Theme.of(context).accentColor),
if (comment.banned) CommentTag('BANNED', Colors.red),
if (comment.bannedFromCommunity)
CommentTag('BANNED FROM COMMUNITY', Colors.red),
Spacer(),
Text(comment.score.toString()),
]),
Row(children: [body]),
Row(children: [
Spacer(),
// actions go here
])
],
),
padding: EdgeInsets.all(10),
margin: EdgeInsets.only(left: indent > 1 ? (indent - 1) * 5.0 : 0),
decoration: BoxDecoration(
border: Border(
left: indent > 0
? BorderSide(color: Colors.red, width: 5)
: BorderSide.none,
top: BorderSide(width: 0.2))),
),
for (var c in commentTree.children)
Comment(
c,
indent: indent + 1,
postCreatorId: postCreatorId,
),
],
);
}
}
class CommentTag extends StatelessWidget {
final String text;
final Color bgColor;
const CommentTag(this.text, this.bgColor);
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(left: 5),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(5)),
color: bgColor,
),
padding: EdgeInsets.symmetric(horizontal: 3, vertical: 2),
child: Text(text,
style: TextStyle(
color: Colors.white,
fontSize: Theme.of(context).textTheme.bodyText1.fontSize - 5,
fontWeight: FontWeight.w800,
)),
),
);
}

View File

@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/lemmy_api_client.dart';
import '../comment_tree.dart';
import 'comment.dart';
/// Manages comments section, sorts them
class CommentSection extends HookWidget {
final List<CommentView> rawComments;
final List<CommentTree> comments;
final int postCreatorId;
CommentSection(this.rawComments, {@required this.postCreatorId})
: comments = CommentTree.fromList(rawComments),
assert(postCreatorId != null);
@override
Widget build(BuildContext context) => Column(children: [
// sorting menu goes here
if (comments.isEmpty)
Padding(
padding: EdgeInsets.symmetric(vertical: 50),
child: Text(
'no comments yet',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
for (var com in comments) Comment(com, postCreatorId: postCreatorId),
]);
}

View File

@ -0,0 +1,37 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:markdown/markdown.dart' as md;
import '../url_launcher.dart';
class MarkdownText extends StatelessWidget {
final String text;
MarkdownText(this.text);
@override
Widget build(BuildContext context) => MarkdownBody(
data: text,
extensionSet: md.ExtensionSet.gitHubWeb,
onTapLink: (href) {
urlLauncher(href)
.catchError((e) => Scaffold.of(context).showSnackBar(SnackBar(
content: Row(
children: [
Icon(Icons.warning),
Text("couldn't open link"),
],
),
)));
},
imageBuilder: (uri, title, alt) => CachedNetworkImage(
imageUrl: uri.toString(),
errorWidget: (context, url, error) => Row(
children: [
Icon(Icons.warning),
Text("couldn't load image, ${error.toString()}")
],
),
),
);
}

361
lib/widgets/post.dart Normal file
View File

@ -0,0 +1,361 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:esys_flutter_share/esys_flutter_share.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:lemmy_api_client/lemmy_api_client.dart';
import 'package:timeago/timeago.dart' as timeago;
import 'markdown_text.dart';
enum MediaType {
image,
gallery,
video,
other,
}
MediaType whatType(String url) {
if (url == null) return MediaType.other;
// TODO: make detection more nuanced
if (url.endsWith('.jpg') || url.endsWith('.png') || url.endsWith('.gif')) {
return MediaType.image;
}
return MediaType.other;
}
class Post extends StatelessWidget {
final PostView post;
final String instanceUrl;
/// nullable
final String postUrlDomain;
Post(this.post)
: instanceUrl = post.communityActorId.split('/')[2],
postUrlDomain = post.url != null ? post.url.split('/')[2] : null;
// == ACTIONS ==
void _openLink() {
print('OPEN LINK');
}
void _goToUser() {
print('GO TO USER');
}
void _goToPost(BuildContext context) {
print('GO TO POST');
}
void _goToCommunity() {
print('GO TO COMMUNITY');
}
void _goToInstance() {
print('GO TO INSTANCE');
}
void _openFullImage() {
print('OPEN FULL IMAGE');
}
// POST ACTIONS
void _savePost() {
print('SAVE POST');
}
void _upvotePost() {
print('UPVOTE POST');
}
void _downvotePost() {
print('DOWNVOTE POST');
}
void _showMoreMenu() {
print('SHOW MORE MENU');
}
// == UI ==
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// TODO: add NSFW, locked, removed, deleted, stickied
/// assemble info section
Widget info() => Column(children: [
Padding(
padding: const EdgeInsets.all(10),
child: Row(children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
if (post.communityIcon != null)
Padding(
padding: const EdgeInsets.only(right: 10),
child: InkWell(
onTap: _goToCommunity,
child: SizedBox(
height: 40,
width: 40,
child: CachedNetworkImage(
imageBuilder: (context, imageProvider) => Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
image: DecorationImage(
fit: BoxFit.cover,
image: imageProvider,
),
),
),
imageUrl: post.communityIcon,
errorWidget: (context, url, error) =>
Text(error.toString()),
),
),
),
),
],
),
Column(
children: [
Row(children: [
RichText(
overflow: TextOverflow.ellipsis, // TODO: fix overflowing
text: TextSpan(
style: TextStyle(
fontSize: 15,
color: theme.textTheme.bodyText1.color),
children: [
TextSpan(
text: '!',
style: TextStyle(fontWeight: FontWeight.w300)),
TextSpan(
text: post.communityName,
style: TextStyle(fontWeight: FontWeight.w600),
recognizer: TapGestureRecognizer()
..onTap = _goToCommunity),
TextSpan(
text: '@',
style: TextStyle(fontWeight: FontWeight.w300)),
TextSpan(
text: instanceUrl,
style: TextStyle(fontWeight: FontWeight.w600),
recognizer: TapGestureRecognizer()
..onTap = _goToInstance),
],
),
)
]),
Row(children: [
RichText(
overflow: TextOverflow.ellipsis,
text: TextSpan(
style: TextStyle(
fontSize: 13,
color: theme.textTheme.bodyText1.color),
children: [
TextSpan(
text: 'by',
style: TextStyle(fontWeight: FontWeight.w300)),
TextSpan(
text:
''' ${post.creatorPreferredUsername ?? post.creatorName}''',
style: TextStyle(fontWeight: FontWeight.w600),
recognizer: TapGestureRecognizer()
..onTap = _goToUser,
),
TextSpan(
text:
''' · ${timeago.format(post.published, locale: 'en_short')}'''),
if (postUrlDomain != null)
TextSpan(text: ' · $postUrlDomain'),
if (post.locked) TextSpan(text: ' · 🔒'),
],
))
]),
],
crossAxisAlignment: CrossAxisAlignment.start,
),
Spacer(),
Column(
children: [
IconButton(
onPressed: _showMoreMenu,
icon: Icon(Icons.more_vert),
)
],
)
]),
),
]);
/// assemble title section
Widget title() => Padding(
padding: const EdgeInsets.only(left: 10, right: 10, bottom: 10),
child: Row(
children: [
Flexible(
child: Text(
'${post.name}',
textAlign: TextAlign.left,
softWrap: true,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
),
if (post.url != null &&
whatType(post.url) == MediaType.other &&
post.thumbnailUrl != null)
Spacer(),
if (post.url != null &&
whatType(post.url) == MediaType.other &&
post.thumbnailUrl != null)
InkWell(
onTap: _openLink,
child: Stack(children: [
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: CachedNetworkImage(
imageUrl: post.thumbnailUrl,
width: 70,
height: 70,
fit: BoxFit.cover,
errorWidget: (context, url, error) =>
Text(error.toString()),
)),
Positioned(
top: 8,
right: 8,
child: Icon(
Icons.launch,
size: 20,
),
)
]),
)
],
),
);
/// assemble link preview
Widget linkPreview() {
assert(post.url != null);
var url = post.url.split('/')[2];
if (url.startsWith('www.')) {
url = url.substring(4);
}
return Padding(
padding: const EdgeInsets.all(10),
child: InkWell(
onTap: _openLink,
child: Container(
decoration: BoxDecoration(
border: Border.all(width: 1),
borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
children: [
Row(children: [
Spacer(),
Text('$url ',
style: theme.textTheme.caption
.apply(fontStyle: FontStyle.italic)),
Icon(Icons.launch, size: 12),
]),
Row(children: [
Flexible(
child: Text(post.embedTitle,
style: theme.textTheme.subtitle1
.apply(fontWeightDelta: 2)))
]),
Row(children: [Flexible(child: Text(post.embedDescription))]),
],
),
),
),
),
);
}
/// assemble image
Widget postImage() {
assert(post.url != null);
return InkWell(
onTap: _openFullImage,
child: CachedNetworkImage(
imageUrl: post.url,
progressIndicatorBuilder: (context, url, progress) =>
CircularProgressIndicator(value: progress.progress),
),
);
}
/// assemble actions section
Widget actions() => Padding(
padding: const EdgeInsets.fromLTRB(10, 5, 10, 5),
child: Row(
children: [
Icon(Icons.comment),
Expanded(
flex: 999,
child: Text(
''' ${NumberFormat.compact().format(post.numberOfComments)} comment${post.numberOfComments == 1 ? '' : 's'}''',
overflow: TextOverflow.fade,
softWrap: false,
),
),
Spacer(),
IconButton(
icon: Icon(Icons.share),
onPressed: () => Share.text('Share post url', post.apId,
'text/plain')), // TODO: find a way to mark it as url
IconButton(
icon: post.saved == true
? Icon(Icons.bookmark)
: Icon(Icons.bookmark_border),
onPressed: _savePost),
IconButton(
icon: Icon(Icons.arrow_upward), onPressed: _upvotePost),
Text(NumberFormat.compact().format(post.score)),
IconButton(
icon: Icon(Icons.arrow_downward), onPressed: _downvotePost),
],
),
);
return Container(
decoration: BoxDecoration(
boxShadow: [BoxShadow(blurRadius: 10, color: Colors.black54)],
color: theme.colorScheme.surface,
borderRadius: BorderRadius.all(Radius.circular(20)),
),
child: InkWell(
onTap: () => _goToPost(context),
child: Column(
children: [
info(),
title(),
if (whatType(post.url) != MediaType.other)
postImage()
else if (post.url != null)
linkPreview(),
if (post.body != null)
Padding(
padding: const EdgeInsets.all(10),
child: MarkdownText(post.body)),
actions(),
],
),
),
);
}
}

View File

@ -244,11 +244,23 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.13.2"
flutter_markdown:
dependency: "direct main"
description:
name: flutter_markdown
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.3"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
@ -325,7 +337,7 @@ packages:
name: lemmy_api_client
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.3"
version: "0.2.1"
logging:
dependency: transitive
description:
@ -333,6 +345,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.11.4"
markdown:
dependency: "direct main"
description:
name: markdown
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.8"
matcher:
dependency: transitive
description:
@ -431,6 +450,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.1"
platform_detect:
dependency: transitive
description:
name: platform_detect
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.0"
plugin_platform_interface:
dependency: transitive
description:
@ -590,6 +616,41 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
url: "https://pub.dartlang.org"
source: hosted
version: "5.5.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.1+1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.1+7"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.8"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.3"
uuid:
dependency: transitive
description:

View File

@ -21,11 +21,14 @@ environment:
sdk: '>=2.7.0 <3.0.0'
dependencies:
url_launcher: ^5.5.1
markdown: ^2.1.8
flutter_markdown: ^0.4.3
esys_flutter_share: ^1.0.2
flutter_hooks: ^0.13.2
cached_network_image: ^2.2.0+1
timeago: ^2.0.27
lemmy_api_client: ^0.1.3
lemmy_api_client: ^0.2.1
flutter:
sdk: flutter