Merge pull request #8 from Iconica-Development/feature/buddy_merge

feat: buddy merge
This commit is contained in:
Gorter-dev 2024-01-17 14:03:49 +01:00 committed by GitHub
commit e61da6873d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 424 additions and 100 deletions

View file

@ -14,12 +14,14 @@ dependencies:
flutter:
sdk: flutter
go_router: any
flutter_timeline_view:
path: ../flutter_timeline_view
# git:
# url: https://github.com/Iconica-Development/flutter_timeline
# path: packages/flutter_timeline_view
# ref: 1.0.0
flutter_timeline_interface:
path: ../flutter_timeline_interface
# git:

View file

@ -19,10 +19,21 @@ class TimelineOptions {
this.allowAllDeletion = false,
this.sortCommentsAscending = true,
this.sortPostsAscending = false,
this.doubleTapTolike = false,
this.iconsWithValues = false,
this.likeAndDislikeIconsForDoubleTap = const (
Icon(
Icons.favorite_rounded,
color: Color(0xFFC3007A),
),
null,
),
this.itemInfoBuilder,
this.dateFormat,
this.timeFormat,
this.buttonBuilder,
this.textInputBuilder,
this.dividerBuilder,
this.userAvatarBuilder,
this.anonymousAvatarBuilder,
this.nameBuilder,
@ -74,6 +85,21 @@ class TimelineOptions {
/// size and quality for the uploaded image.
final ImagePickerConfig imagePickerConfig;
/// Whether to allow double tap to like
final bool doubleTapTolike;
/// The icons to display when double tap to like is enabled
final (Icon?, Icon?) likeAndDislikeIconsForDoubleTap;
/// Whether to display the icons with values
final bool iconsWithValues;
/// The builder for the item info, all below the like and comment buttons
final Widget Function({required TimelinePost post})? itemInfoBuilder;
/// The builder for the divider
final Widget Function()? dividerBuilder;
/// The padding between posts in the timeline
final EdgeInsets padding;

View file

@ -12,6 +12,7 @@ import 'package:flutter_image_picker/flutter_image_picker.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
import 'package:flutter_timeline_view/src/config/timeline_options.dart';
import 'package:flutter_timeline_view/src/widgets/reaction_bottom.dart';
import 'package:flutter_timeline_view/src/widgets/tappable_image.dart';
import 'package:intl/intl.dart';
class TimelinePostScreen extends StatefulWidget {
@ -225,11 +226,40 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
const SizedBox(height: 8),
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: CachedNetworkImage(
width: double.infinity,
imageUrl: post.imageUrl!,
fit: BoxFit.fitHeight,
),
child: widget.options.doubleTapTolike
? TappableImage(
likeAndDislikeIcon: widget
.options.likeAndDislikeIconsForDoubleTap,
post: post,
userId: widget.userId,
onLike: ({required bool liked}) async {
var userId = widget.userId;
late TimelinePost result;
if (!liked) {
result = await widget.service.likePost(
userId,
post,
);
} else {
result = await widget.service.unlikePost(
userId,
post,
);
}
await loadPostDetails();
return result.likedBy?.contains(userId) ??
false;
},
)
: CachedNetworkImage(
width: double.infinity,
imageUrl: post.imageUrl!,
fit: BoxFit.fitHeight,
),
),
],
const SizedBox(

View file

@ -97,6 +97,7 @@ class _TimelineScreenState extends State<TimelineScreen> {
(post) => Padding(
padding: widget.options.padding,
child: TimelinePostWidget(
service: widget.service,
userId: widget.userId,
options: widget.options,
post: post,

View file

@ -0,0 +1,168 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
class TappableImage extends StatefulWidget {
const TappableImage({
required this.post,
required this.onLike,
required this.userId,
required this.likeAndDislikeIcon,
super.key,
});
final TimelinePost post;
final String userId;
final Future<bool> Function({required bool liked}) onLike;
final (Icon?, Icon?) likeAndDislikeIcon;
@override
State<TappableImage> createState() => _TappableImageState();
}
class _TappableImageState extends State<TappableImage>
with SingleTickerProviderStateMixin {
late AnimationController animationController;
late Animation<double> animation;
bool loading = false;
@override
void initState() {
super.initState();
animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 350),
);
animation = CurvedAnimation(
parent: animationController,
curve: Curves.ease,
);
animationController.addListener(listener);
}
void listener() {
setState(() {});
}
@override
void dispose() {
animationController.removeListener(listener);
animationController.dispose();
super.dispose();
}
void startAnimation() {
animationController.forward();
}
void reverseAnimation() {
animationController.reverse();
}
@override
Widget build(BuildContext context) => InkWell(
onDoubleTap: () async {
if (loading) {
return;
}
loading = true;
await animationController.forward();
var liked = await widget.onLike(
liked: widget.post.likedBy?.contains(
widget.userId,
) ??
false,
);
if (context.mounted) {
await showDialog(
barrierDismissible: false,
barrierColor: Colors.transparent,
context: context,
builder: (context) => HeartAnimation(
duration: const Duration(milliseconds: 200),
liked: liked,
likeAndDislikeIcon: widget.likeAndDislikeIcon,
),
);
}
await animationController.reverse();
loading = false;
},
child: Transform.translate(
offset: Offset(0, animation.value * -32),
child: Transform.scale(
scale: 1 + animation.value * 0.1,
child: CachedNetworkImage(
imageUrl: widget.post.imageUrl ?? '',
width: double.infinity,
fit: BoxFit.fitHeight,
),
),
),
);
}
class HeartAnimation extends StatefulWidget {
const HeartAnimation({
required this.duration,
required this.liked,
required this.likeAndDislikeIcon,
super.key,
});
final Duration duration;
final bool liked;
final (Icon?, Icon?) likeAndDislikeIcon;
@override
State<HeartAnimation> createState() => _HeartAnimationState();
}
class _HeartAnimationState extends State<HeartAnimation> {
late bool active;
@override
void initState() {
super.initState();
active = widget.liked;
unawaited(
Future.delayed(const Duration(milliseconds: 100)).then((value) async {
active = widget.liked;
var navigator = Navigator.of(context);
await Future.delayed(widget.duration);
navigator.pop();
}),
);
}
@override
Widget build(BuildContext context) => AnimatedOpacity(
opacity: widget.likeAndDislikeIcon.$1 != null &&
widget.likeAndDislikeIcon.$2 != null
? 1
: active
? 1
: 0,
duration: widget.duration,
curve: Curves.decelerate,
child: AnimatedScale(
scale: widget.likeAndDislikeIcon.$1 != null &&
widget.likeAndDislikeIcon.$2 != null
? 10
: active
? 10
: 1,
duration: widget.duration,
child: active
? widget.likeAndDislikeIcon.$1
: widget.likeAndDislikeIcon.$2,
),
);
}

View file

@ -6,8 +6,9 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
import 'package:flutter_timeline_view/src/config/timeline_options.dart';
import 'package:flutter_timeline_view/src/widgets/tappable_image.dart';
class TimelinePostWidget extends StatelessWidget {
class TimelinePostWidget extends StatefulWidget {
const TimelinePostWidget({
required this.userId,
required this.options,
@ -17,6 +18,7 @@ class TimelinePostWidget extends StatelessWidget {
required this.onTapLike,
required this.onTapUnlike,
required this.onPostDelete,
required this.service,
this.onUserTap,
super.key,
});
@ -33,44 +35,51 @@ class TimelinePostWidget extends StatelessWidget {
final VoidCallback onTapLike;
final VoidCallback onTapUnlike;
final VoidCallback onPostDelete;
final TimelineService service;
/// If this is not null, the user can tap on the user avatar or name
final Function(String userId)? onUserTap;
@override
State<TimelinePostWidget> createState() => _TimelinePostWidgetState();
}
class _TimelinePostWidgetState extends State<TimelinePostWidget> {
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return InkWell(
onTap: onTap,
onTap: widget.onTap,
child: SizedBox(
height: post.imageUrl != null ? height : null,
height: widget.post.imageUrl != null ? widget.height : null,
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (post.creator != null)
if (widget.post.creator != null)
InkWell(
onTap: onUserTap != null
? () => onUserTap?.call(post.creator!.userId)
onTap: widget.onUserTap != null
? () =>
widget.onUserTap?.call(widget.post.creator!.userId)
: null,
child: Row(
children: [
if (post.creator!.imageUrl != null) ...[
options.userAvatarBuilder?.call(
post.creator!,
if (widget.post.creator!.imageUrl != null) ...[
widget.options.userAvatarBuilder?.call(
widget.post.creator!,
40,
) ??
CircleAvatar(
radius: 20,
backgroundImage: CachedNetworkImageProvider(
post.creator!.imageUrl!,
widget.post.creator!.imageUrl!,
),
),
] else ...[
options.anonymousAvatarBuilder?.call(
post.creator!,
widget.options.anonymousAvatarBuilder?.call(
widget.post.creator!,
40,
) ??
const CircleAvatar(
@ -82,22 +91,24 @@ class TimelinePostWidget extends StatelessWidget {
],
const SizedBox(width: 10),
Text(
options.nameBuilder?.call(post.creator) ??
post.creator?.fullName ??
options.translations.anonymousUser,
style:
options.theme.textStyles.postCreatorTitleStyle ??
theme.textTheme.titleMedium,
widget.options.nameBuilder
?.call(widget.post.creator) ??
widget.post.creator?.fullName ??
widget.options.translations.anonymousUser,
style: widget.options.theme.textStyles
.postCreatorTitleStyle ??
theme.textTheme.titleMedium,
),
],
),
),
const Spacer(),
if (options.allowAllDeletion || post.creator?.userId == userId)
if (widget.options.allowAllDeletion ||
widget.post.creator?.userId == widget.userId)
PopupMenuButton(
onSelected: (value) {
if (value == 'delete') {
onPostDelete();
widget.onPostDelete();
}
},
itemBuilder: (BuildContext context) =>
@ -107,40 +118,67 @@ class TimelinePostWidget extends StatelessWidget {
child: Row(
children: [
Text(
options.translations.deletePost,
style: options.theme.textStyles.deletePostStyle ??
widget.options.translations.deletePost,
style: widget.options.theme.textStyles
.deletePostStyle ??
theme.textTheme.bodyMedium,
),
const SizedBox(width: 8),
options.theme.deleteIcon ??
widget.options.theme.deleteIcon ??
Icon(
Icons.delete,
color: options.theme.iconColor,
color: widget.options.theme.iconColor,
),
],
),
),
],
child: options.theme.moreIcon ??
child: widget.options.theme.moreIcon ??
Icon(
Icons.more_horiz_rounded,
color: options.theme.iconColor,
color: widget.options.theme.iconColor,
),
),
],
),
// image of the post
if (post.imageUrl != null) ...[
if (widget.post.imageUrl != null) ...[
const SizedBox(height: 8),
Flexible(
flex: height != null ? 1 : 0,
flex: widget.height != null ? 1 : 0,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: CachedNetworkImage(
width: double.infinity,
imageUrl: post.imageUrl!,
fit: BoxFit.fitWidth,
),
child: widget.options.doubleTapTolike
? TappableImage(
likeAndDislikeIcon:
widget.options.likeAndDislikeIconsForDoubleTap,
post: widget.post,
userId: widget.userId,
onLike: ({required bool liked}) async {
var userId = widget.userId;
late TimelinePost result;
if (!liked) {
result = await widget.service.likePost(
userId,
widget.post,
);
} else {
result = await widget.service.unlikePost(
userId,
widget.post,
);
}
return result.likedBy?.contains(userId) ?? false;
},
)
: CachedNetworkImage(
width: double.infinity,
imageUrl: widget.post.imageUrl!,
fit: BoxFit.fitWidth,
),
),
),
],
@ -148,78 +186,137 @@ class TimelinePostWidget extends StatelessWidget {
height: 8,
),
// post information
Row(
children: [
if (post.likedBy?.contains(userId) ?? false) ...[
InkWell(
onTap: onTapUnlike,
child: Container(
color: Colors.transparent,
child: options.theme.likedIcon ??
Icon(
Icons.thumb_up_rounded,
color: options.theme.iconColor,
size: options.iconSize,
),
),
if (widget.options.iconsWithValues)
Row(
children: [
TextButton.icon(
onPressed: () async {
var userId = widget.userId;
var liked =
widget.post.likedBy?.contains(userId) ?? false;
if (!liked) {
await widget.service.likePost(
userId,
widget.post,
);
} else {
await widget.service.unlikePost(
userId,
widget.post,
);
}
},
icon: widget.options.theme.likeIcon ??
Icon(
widget.post.likedBy?.contains(widget.userId) ?? false
? Icons.favorite
: Icons.favorite_outline_outlined,
),
label: Text('${widget.post.likes}'),
),
] else ...[
InkWell(
onTap: onTapLike,
child: Container(
color: Colors.transparent,
child: options.theme.likeIcon ??
Icon(
Icons.thumb_up_alt_outlined,
color: options.theme.iconColor,
size: options.iconSize,
if (widget.post.reactionEnabled)
TextButton.icon(
onPressed: widget.onTap,
icon: widget.options.theme.commentIcon ??
const Icon(
Icons.chat_bubble_outline_outlined,
),
label: Text('${widget.post.reaction}'),
),
),
],
const SizedBox(width: 8),
if (post.reactionEnabled)
options.theme.commentIcon ??
Icon(
Icons.chat_bubble_outline_rounded,
color: options.theme.iconColor,
size: options.iconSize,
)
else
Row(
children: [
if (widget.post.likedBy?.contains(widget.userId) ??
false) ...[
InkWell(
onTap: widget.onTapUnlike,
child: Container(
color: Colors.transparent,
child: widget.options.theme.likedIcon ??
Icon(
Icons.thumb_up_rounded,
color: widget.options.theme.iconColor,
size: widget.options.iconSize,
),
),
],
),
),
] else ...[
InkWell(
onTap: widget.onTapLike,
child: Container(
color: Colors.transparent,
child: widget.options.theme.likedIcon ??
Icon(
Icons.thumb_up_rounded,
color: widget.options.theme.iconColor,
size: widget.options.iconSize,
),
),
),
],
const SizedBox(width: 8),
if (widget.post.reactionEnabled) ...[
Container(
color: Colors.transparent,
child: widget.options.theme.commentIcon ??
Icon(
Icons.chat_bubble_outline_rounded,
color: widget.options.theme.iconColor,
size: widget.options.iconSize,
),
),
],
],
),
const SizedBox(
height: 8,
),
Text(
'${post.likes} ${options.translations.likesTitle}',
style: options.theme.textStyles.listPostLikeTitleAndAmount ??
theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
Text.rich(
TextSpan(
text: options.nameBuilder?.call(post.creator) ??
post.creator?.fullName ??
options.translations.anonymousUser,
style: options.theme.textStyles.listCreatorNameStyle ??
theme.textTheme.titleSmall,
children: [
const TextSpan(text: ' '),
TextSpan(
text: post.title,
style: options.theme.textStyles.listPostTitleStyle ??
theme.textTheme.bodyMedium,
),
],
if (widget.options.itemInfoBuilder != null) ...[
widget.options.itemInfoBuilder!(
post: widget.post,
),
),
const SizedBox(height: 4),
Text(
options.translations.viewPost,
style: options.theme.textStyles.viewPostStyle ??
theme.textTheme.bodySmall,
),
] else ...[
Text(
'${widget.post.likes} '
'${widget.options.translations.likesTitle}',
style: widget
.options.theme.textStyles.listPostLikeTitleAndAmount ??
theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
Text.rich(
TextSpan(
text: widget.options.nameBuilder?.call(widget.post.creator) ??
widget.post.creator?.fullName ??
widget.options.translations.anonymousUser,
style: widget.options.theme.textStyles.listCreatorNameStyle ??
theme.textTheme.titleSmall,
children: [
const TextSpan(text: ' '),
TextSpan(
text: widget.post.title,
style:
widget.options.theme.textStyles.listPostTitleStyle ??
theme.textTheme.bodyMedium,
),
],
),
),
const SizedBox(height: 4),
Text(
widget.options.translations.viewPost,
style: widget.options.theme.textStyles.viewPostStyle ??
theme.textTheme.bodySmall,
),
],
if (widget.options.dividerBuilder != null)
widget.options.dividerBuilder!(),
],
),
),