diff --git a/packages/flutter_timeline/pubspec.yaml b/packages/flutter_timeline/pubspec.yaml index 69e1e87..3564c97 100644 --- a/packages/flutter_timeline/pubspec.yaml +++ b/packages/flutter_timeline/pubspec.yaml @@ -14,16 +14,22 @@ dependencies: flutter: sdk: flutter go_router: any - flutter_timeline_view: - git: - url: https://github.com/Iconica-Development/flutter_timeline - path: packages/flutter_timeline_view - ref: 1.0.0 + # flutter_timeline_view: + # git: + # url: https://github.com/Iconica-Development/flutter_timeline + # path: packages/flutter_timeline_view + # ref: 1.0.0 + # flutter_timeline_interface: + # git: + # url: https://github.com/Iconica-Development/flutter_timeline + # path: packages/flutter_timeline_interface + # ref: 1.0.0 + flutter_timeline_interface: - git: - url: https://github.com/Iconica-Development/flutter_timeline - path: packages/flutter_timeline_interface - ref: 1.0.0 + path: ../flutter_timeline_interface + flutter_timeline_view: + path: ../flutter_timeline_view + dev_dependencies: flutter_lints: ^2.0.0 diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart index 42e390a..2c84c6f 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart @@ -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, @@ -71,6 +82,21 @@ class TimelineOptions { /// ImagePickerConfig can be used to define the /// 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; } typedef ButtonBuilder = Widget Function( diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart index 6082096..b54f40e 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart @@ -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 { @@ -223,11 +224,40 @@ class _TimelinePostScreenState extends State { 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( diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart index cf0f214..dad384c 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart @@ -100,6 +100,7 @@ class _TimelineScreenState extends State { (post) => Padding( padding: widget.padding, child: TimelinePostWidget( + service: widget.service, userId: widget.userId, options: widget.options, post: post, diff --git a/packages/flutter_timeline_view/lib/src/widgets/tappable_image.dart b/packages/flutter_timeline_view/lib/src/widgets/tappable_image.dart new file mode 100644 index 0000000..24fe999 --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/widgets/tappable_image.dart @@ -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 Function({required bool liked}) onLike; + final (Icon?, Icon?) likeAndDislikeIcon; + + @override + State createState() => _TappableImageState(); +} + +class _TappableImageState extends State + with SingleTickerProviderStateMixin { + late AnimationController animationController; + late Animation 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 createState() => _HeartAnimationState(); +} + +class _HeartAnimationState extends State { + 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, + ), + ); +} diff --git a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart index 88a21e6..b81bd28 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart +++ b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart @@ -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 createState() => _TimelinePostWidgetState(); +} + +class _TimelinePostWidgetState extends State { @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,69 +186,124 @@ class TimelinePostWidget extends StatelessWidget { height: 8, ), // post information - Row( - children: [ - if (post.likedBy?.contains(userId) ?? false) ...[ - InkWell( - onTap: onTapUnlike, - child: options.theme.likedIcon ?? + 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( - Icons.thumb_up_rounded, - color: options.theme.iconColor, - ), - ), - ] else ...[ - InkWell( - onTap: onTapLike, - child: options.theme.likeIcon ?? - Icon( - Icons.thumb_up_alt_outlined, - color: options.theme.iconColor, + widget.post.likedBy?.contains(widget.userId) ?? false + ? Icons.favorite + : Icons.favorite_outline_outlined, ), + label: Text('${widget.post.likes}'), ), + 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, - ), - ], - ), + ) + else + Row( + children: [ + if (widget.post.likedBy?.contains(widget.userId) ?? + false) ...[ + InkWell( + onTap: widget.onTapUnlike, + child: widget.options.theme.likedIcon ?? + Icon( + Icons.thumb_up_rounded, + color: widget.options.theme.iconColor, + ), + ), + ] else ...[ + InkWell( + onTap: widget.onTapLike, + child: widget.options.theme.likeIcon ?? + Icon( + Icons.thumb_up_alt_outlined, + color: widget.options.theme.iconColor, + ), + ), + ], + const SizedBox(width: 8), + if (widget.post.reactionEnabled) + widget.options.theme.commentIcon ?? + Icon( + Icons.chat_bubble_outline_rounded, + color: widget.options.theme.iconColor, + ), + ], + ), + 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!(), ], ), ), diff --git a/packages/flutter_timeline_view/pubspec.yaml b/packages/flutter_timeline_view/pubspec.yaml index 96ade42..01cf972 100644 --- a/packages/flutter_timeline_view/pubspec.yaml +++ b/packages/flutter_timeline_view/pubspec.yaml @@ -19,11 +19,13 @@ dependencies: dotted_border: ^2.1.0 flutter_html: ^3.0.0-beta.2 + # flutter_timeline_interface: + # git: + # url: https://github.com/Iconica-Development/flutter_timeline + # path: packages/flutter_timeline_interface + # ref: 1.0.0 flutter_timeline_interface: - git: - url: https://github.com/Iconica-Development/flutter_timeline - path: packages/flutter_timeline_interface - ref: 1.0.0 + path: ../flutter_timeline_interface flutter_image_picker: git: url: https://github.com/Iconica-Development/flutter_image_picker