596 lines
21 KiB
Dart
596 lines
21 KiB
Dart
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:lemmy_api_client/v3.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:url_launcher/url_launcher.dart' as ul;
|
|
|
|
import '../hooks/delayed_loading.dart';
|
|
import '../hooks/logged_in_action.dart';
|
|
import '../hooks/stores.dart';
|
|
import '../l10n/l10n.dart';
|
|
import '../pages/create_post.dart';
|
|
import '../pages/full_post.dart';
|
|
import '../stores/accounts_store.dart';
|
|
import '../url_launcher.dart';
|
|
import '../util/cleanup_url.dart';
|
|
import '../util/extensions/api.dart';
|
|
import '../util/extensions/datetime.dart';
|
|
import '../util/goto.dart';
|
|
import '../util/more_icon.dart';
|
|
import '../util/share.dart';
|
|
import 'avatar.dart';
|
|
import 'bottom_modal.dart';
|
|
import 'fullscreenable_image.dart';
|
|
import 'info_table_popup.dart';
|
|
import 'markdown_text.dart';
|
|
import 'save_post_button.dart';
|
|
|
|
enum MediaType {
|
|
image,
|
|
gallery,
|
|
video,
|
|
other,
|
|
none,
|
|
}
|
|
|
|
MediaType whatType(String? url) {
|
|
if (url == null || url.isEmpty) return MediaType.none;
|
|
|
|
// TODO: make detection more nuanced
|
|
if (url.endsWith('.jpg') ||
|
|
url.endsWith('.jpeg') ||
|
|
url.endsWith('.png') ||
|
|
url.endsWith('.gif') ||
|
|
url.endsWith('.webp') ||
|
|
url.endsWith('.bmp') ||
|
|
url.endsWith('.wbpm')) {
|
|
return MediaType.image;
|
|
}
|
|
return MediaType.other;
|
|
}
|
|
|
|
/// A post overview card
|
|
class PostWidget extends HookWidget {
|
|
final PostView post;
|
|
final String instanceHost;
|
|
final bool fullPost;
|
|
|
|
PostWidget(this.post, {this.fullPost = false})
|
|
: instanceHost = post.instanceHost;
|
|
|
|
// == ACTIONS ==
|
|
|
|
static void showMoreMenu({
|
|
required BuildContext context,
|
|
required PostView post,
|
|
bool fullPost = false,
|
|
}) {
|
|
final isMine = context
|
|
.read<AccountsStore>()
|
|
.defaultUserDataFor(post.instanceHost)
|
|
?.userId ==
|
|
post.creator.id;
|
|
|
|
showBottomModal(
|
|
context: context,
|
|
builder: (context) => Column(
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.open_in_browser),
|
|
title: const Text('Open in browser'),
|
|
onTap: () async => await ul.canLaunch(post.post.apId)
|
|
? ul.launch(post.post.apId)
|
|
: ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text("can't open in browser"))),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.info_outline),
|
|
title: const Text('Nerd stuff'),
|
|
onTap: () {
|
|
showInfoTablePopup(context: context, table: {
|
|
'% of upvotes':
|
|
'${(100 * (post.counts.upvotes / (post.counts.upvotes + post.counts.downvotes))).toInt()}%',
|
|
...post.toJson(),
|
|
});
|
|
},
|
|
),
|
|
if (isMine)
|
|
ListTile(
|
|
leading: const Icon(Icons.edit),
|
|
title: const Text('Edit'),
|
|
onTap: () async {
|
|
final postView = await showCupertinoModalPopup<PostView>(
|
|
context: context,
|
|
builder: (_) => CreatePostPage.edit(post.post),
|
|
);
|
|
|
|
if (postView != null) {
|
|
Navigator.of(context).pop();
|
|
if (fullPost) {
|
|
await goToReplace(
|
|
context,
|
|
(_) => FullPostPage.fromPostView(postView),
|
|
);
|
|
} else {
|
|
await goTo(
|
|
context,
|
|
(_) => FullPostPage.fromPostView(postView),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// == UI ==
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
void _openLink(String url) =>
|
|
linkLauncher(context: context, url: url, instanceHost: instanceHost);
|
|
|
|
final urlDomain = () {
|
|
if (whatType(post.post.url) == MediaType.none) return null;
|
|
|
|
return urlHost(post.post.url!);
|
|
}();
|
|
|
|
/// assemble info section
|
|
Widget info() => Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(10),
|
|
child: Row(
|
|
children: [
|
|
Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (post.community.icon != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 10),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(20),
|
|
onTap: () => goToCommunity.byId(
|
|
context, instanceHost, post.community.id),
|
|
child: Avatar(
|
|
url: post.community.icon,
|
|
noBlank: true,
|
|
radius: 20,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
RichText(
|
|
overflow:
|
|
TextOverflow.ellipsis, // TODO: fix overflowing
|
|
text: TextSpan(
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
color: theme.textTheme.bodyText1?.color),
|
|
children: [
|
|
const TextSpan(
|
|
text: '!',
|
|
style:
|
|
TextStyle(fontWeight: FontWeight.w300)),
|
|
TextSpan(
|
|
text: post.community.name,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w600),
|
|
recognizer: TapGestureRecognizer()
|
|
..onTap = () => goToCommunity.byId(
|
|
context,
|
|
instanceHost,
|
|
post.community.id)),
|
|
const TextSpan(
|
|
text: '@',
|
|
style:
|
|
TextStyle(fontWeight: FontWeight.w300)),
|
|
TextSpan(
|
|
text: post.post.originInstanceHost,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w600),
|
|
recognizer: TapGestureRecognizer()
|
|
..onTap = () => goToInstance(context,
|
|
post.post.originInstanceHost)),
|
|
],
|
|
),
|
|
)
|
|
],
|
|
),
|
|
Row(
|
|
children: [
|
|
RichText(
|
|
overflow: TextOverflow.ellipsis,
|
|
text: TextSpan(
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: theme.textTheme.bodyText1?.color),
|
|
children: [
|
|
TextSpan(
|
|
text: L10n.of(context)!.by,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w300),
|
|
),
|
|
TextSpan(
|
|
text: ' ${post.creator.originPreferredName}',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w600),
|
|
recognizer: TapGestureRecognizer()
|
|
..onTap = () => goToUser.fromPersonSafe(
|
|
context,
|
|
post.creator,
|
|
),
|
|
),
|
|
TextSpan(
|
|
text:
|
|
' · ${post.post.published.fancyShort}'),
|
|
if (post.post.locked)
|
|
const TextSpan(text: ' · 🔒'),
|
|
if (post.post.stickied)
|
|
const TextSpan(text: ' · 📌'),
|
|
if (post.post.nsfw) const TextSpan(text: ' · '),
|
|
if (post.post.nsfw)
|
|
TextSpan(
|
|
text: L10n.of(context)!.nsfw,
|
|
style:
|
|
const TextStyle(color: Colors.red)),
|
|
if (urlDomain != null)
|
|
TextSpan(text: ' · $urlDomain'),
|
|
if (post.post.removed)
|
|
const TextSpan(text: ' · REMOVED'),
|
|
if (post.post.deleted)
|
|
const TextSpan(text: ' · DELETED'),
|
|
],
|
|
),
|
|
)
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const Spacer(),
|
|
if (!fullPost)
|
|
Column(
|
|
children: [
|
|
IconButton(
|
|
onPressed: () =>
|
|
showMoreMenu(context: context, post: post),
|
|
icon: Icon(moreIcon),
|
|
padding: const EdgeInsets.all(0),
|
|
visualDensity: VisualDensity.compact,
|
|
)
|
|
],
|
|
)
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
/// assemble title section
|
|
Widget title() => Padding(
|
|
padding: const EdgeInsets.only(left: 10, right: 10, bottom: 10),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 100,
|
|
child: Text(
|
|
post.post.name,
|
|
textAlign: TextAlign.left,
|
|
softWrap: true,
|
|
style: const TextStyle(
|
|
fontSize: 18, fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
if (whatType(post.post.url) == MediaType.other &&
|
|
post.post.thumbnailUrl != null) ...[
|
|
const Spacer(),
|
|
InkWell(
|
|
borderRadius: BorderRadius.circular(20),
|
|
onTap: () => _openLink(post.post.url!),
|
|
child: Stack(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(20),
|
|
child: CachedNetworkImage(
|
|
imageUrl: post.post.thumbnailUrl!,
|
|
width: 70,
|
|
height: 70,
|
|
fit: BoxFit.cover,
|
|
errorWidget: (context, url, error) =>
|
|
Text(error.toString()),
|
|
),
|
|
),
|
|
const Positioned(
|
|
top: 8,
|
|
right: 8,
|
|
child: Icon(
|
|
Icons.launch,
|
|
size: 20,
|
|
),
|
|
)
|
|
],
|
|
),
|
|
)
|
|
]
|
|
],
|
|
),
|
|
);
|
|
|
|
/// assemble link preview
|
|
Widget linkPreview() {
|
|
assert(post.post.url != null);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.all(10),
|
|
child: InkWell(
|
|
onTap: () => _openLink(post.post.url!),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
border: Border.all(
|
|
color: Theme.of(context).iconTheme.color!.withAlpha(170)),
|
|
borderRadius: BorderRadius.circular(5)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(10),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Spacer(),
|
|
Text('$urlDomain ',
|
|
style: theme.textTheme.caption
|
|
?.apply(fontStyle: FontStyle.italic)),
|
|
const Icon(Icons.launch, size: 12),
|
|
],
|
|
),
|
|
Row(
|
|
children: [
|
|
Flexible(
|
|
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!,
|
|
maxLines: 4,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
)
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// assemble image
|
|
Widget postImage() {
|
|
assert(post.post.url != null);
|
|
|
|
return FullscreenableImage(
|
|
url: post.post.url!,
|
|
child: CachedNetworkImage(
|
|
imageUrl: post.post.url!,
|
|
errorWidget: (_, __, ___) => const Icon(Icons.warning),
|
|
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: [
|
|
const Icon(Icons.comment),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
flex: 999,
|
|
child: Text(
|
|
L10n.of(context)!.number_of_comments(post.counts.comments),
|
|
overflow: TextOverflow.fade,
|
|
softWrap: false,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
if (!fullPost)
|
|
IconButton(
|
|
icon: const Icon(Icons.share),
|
|
onPressed: () => share(post.post.apId, context: context),
|
|
),
|
|
if (!fullPost) SavePostButton(post),
|
|
_Voting(post),
|
|
],
|
|
),
|
|
);
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
boxShadow: const [BoxShadow(blurRadius: 10, color: Colors.black45)],
|
|
color: theme.cardColor,
|
|
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
|
),
|
|
child: GestureDetector(
|
|
onTap: fullPost
|
|
? null
|
|
: () => goTo(context, (context) => FullPostPage.fromPostView(post)),
|
|
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,
|
|
selectable: true,
|
|
),
|
|
),
|
|
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,
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
} else {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(10),
|
|
child: MarkdownText(post.post.body!,
|
|
instanceHost: instanceHost));
|
|
}
|
|
},
|
|
),
|
|
actions(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Voting extends HookWidget {
|
|
final PostView post;
|
|
|
|
final bool wasVoted;
|
|
|
|
_Voting(this.post)
|
|
: wasVoted = (post.myVote ?? VoteType.none) != VoteType.none;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final myVote = useState(post.myVote ?? VoteType.none);
|
|
final loading = useDelayedLoading();
|
|
final showScores =
|
|
useConfigStoreSelect((configStore) => configStore.showScores);
|
|
final loggedInAction = useLoggedInAction(post.instanceHost);
|
|
|
|
vote(VoteType vote, Jwt token) async {
|
|
final api = LemmyApiV3(post.instanceHost);
|
|
|
|
loading.start();
|
|
try {
|
|
final res = await api.run(
|
|
CreatePostLike(postId: post.post.id, score: vote, auth: token.raw));
|
|
myVote.value = res.myVote ?? VoteType.none;
|
|
} catch (e) {
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(const SnackBar(content: Text('voting failed :(')));
|
|
return;
|
|
}
|
|
loading.cancel();
|
|
}
|
|
|
|
return Row(
|
|
children: [
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.arrow_upward,
|
|
color: myVote.value == VoteType.up ? theme.accentColor : null,
|
|
),
|
|
onPressed: loggedInAction(
|
|
(token) => vote(
|
|
myVote.value == VoteType.up ? VoteType.none : VoteType.up,
|
|
token,
|
|
),
|
|
),
|
|
),
|
|
if (loading.loading)
|
|
const SizedBox(
|
|
width: 20, height: 20, child: CircularProgressIndicator())
|
|
else if (showScores)
|
|
Text(NumberFormat.compact()
|
|
.format(post.counts.score + (wasVoted ? 0 : myVote.value.value))),
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.arrow_downward,
|
|
color: myVote.value == VoteType.down ? Colors.red : null,
|
|
),
|
|
onPressed: loggedInAction(
|
|
(token) => vote(
|
|
myVote.value == VoteType.down ? VoteType.none : VoteType.down,
|
|
token,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|