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: flutter:
sdk: flutter sdk: flutter
go_router: any go_router: any
flutter_timeline_view: flutter_timeline_view:
path: ../flutter_timeline_view path: ../flutter_timeline_view
# git: # git:
# url: https://github.com/Iconica-Development/flutter_timeline # url: https://github.com/Iconica-Development/flutter_timeline
# path: packages/flutter_timeline_view # path: packages/flutter_timeline_view
# ref: 1.0.0 # ref: 1.0.0
flutter_timeline_interface: flutter_timeline_interface:
path: ../flutter_timeline_interface path: ../flutter_timeline_interface
# git: # git:

View file

@ -19,10 +19,21 @@ class TimelineOptions {
this.allowAllDeletion = false, this.allowAllDeletion = false,
this.sortCommentsAscending = true, this.sortCommentsAscending = true,
this.sortPostsAscending = false, 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.dateFormat,
this.timeFormat, this.timeFormat,
this.buttonBuilder, this.buttonBuilder,
this.textInputBuilder, this.textInputBuilder,
this.dividerBuilder,
this.userAvatarBuilder, this.userAvatarBuilder,
this.anonymousAvatarBuilder, this.anonymousAvatarBuilder,
this.nameBuilder, this.nameBuilder,
@ -74,6 +85,21 @@ class TimelineOptions {
/// size and quality for the uploaded image. /// size and quality for the uploaded image.
final ImagePickerConfig imagePickerConfig; 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 /// The padding between posts in the timeline
final EdgeInsets padding; 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_interface/flutter_timeline_interface.dart';
import 'package:flutter_timeline_view/src/config/timeline_options.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/reaction_bottom.dart';
import 'package:flutter_timeline_view/src/widgets/tappable_image.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
class TimelinePostScreen extends StatefulWidget { class TimelinePostScreen extends StatefulWidget {
@ -225,7 +226,36 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
ClipRRect( ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: CachedNetworkImage( 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, width: double.infinity,
imageUrl: post.imageUrl!, imageUrl: post.imageUrl!,
fit: BoxFit.fitHeight, fit: BoxFit.fitHeight,

View file

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