feat: buddy merge

This commit is contained in:
Niels Gorter 2024-01-17 13:14:55 +01:00
parent c8cc325a95
commit 60747d30d8
7 changed files with 431 additions and 105 deletions

View file

@ -14,16 +14,22 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
go_router: any go_router: any
flutter_timeline_view: # 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:
# git:
# url: https://github.com/Iconica-Development/flutter_timeline
# path: packages/flutter_timeline_interface
# ref: 1.0.0
flutter_timeline_interface: flutter_timeline_interface:
git: path: ../flutter_timeline_interface
url: https://github.com/Iconica-Development/flutter_timeline flutter_timeline_view:
path: packages/flutter_timeline_interface path: ../flutter_timeline_view
ref: 1.0.0
dev_dependencies: dev_dependencies:
flutter_lints: ^2.0.0 flutter_lints: ^2.0.0

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,
@ -71,6 +82,21 @@ class TimelineOptions {
/// ImagePickerConfig can be used to define the /// ImagePickerConfig can be used to define the
/// 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;
} }
typedef ButtonBuilder = Widget Function( typedef ButtonBuilder = Widget Function(

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 {
@ -223,11 +224,40 @@ 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
width: double.infinity, ? TappableImage(
imageUrl: post.imageUrl!, likeAndDislikeIcon: widget
fit: BoxFit.fitHeight, .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( const SizedBox(

View file

@ -100,6 +100,7 @@ class _TimelineScreenState extends State<TimelineScreen> {
(post) => Padding( (post) => Padding(
padding: widget.padding, padding: widget.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
theme.textTheme.titleMedium, .postCreatorTitleStyle ??
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,40 +118,67 @@ 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
width: double.infinity, ? TappableImage(
imageUrl: post.imageUrl!, likeAndDislikeIcon:
fit: BoxFit.fitWidth, 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, height: 8,
), ),
// post information // post information
Row( if (widget.options.iconsWithValues)
children: [ Row(
if (post.likedBy?.contains(userId) ?? false) ...[ children: [
InkWell( TextButton.icon(
onTap: onTapUnlike, onPressed: () async {
child: options.theme.likedIcon ?? 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( Icon(
Icons.thumb_up_rounded, widget.post.likedBy?.contains(widget.userId) ?? false
color: options.theme.iconColor, ? Icons.favorite
), : Icons.favorite_outline_outlined,
),
] else ...[
InkWell(
onTap: onTapLike,
child: options.theme.likeIcon ??
Icon(
Icons.thumb_up_alt_outlined,
color: options.theme.iconColor,
), ),
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) else
options.theme.commentIcon ?? Row(
Icon( children: [
Icons.chat_bubble_outline_rounded, if (widget.post.likedBy?.contains(widget.userId) ??
color: options.theme.iconColor, 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( const SizedBox(
height: 8, height: 8,
), ),
Text( if (widget.options.itemInfoBuilder != null) ...[
'${post.likes} ${options.translations.likesTitle}', widget.options.itemInfoBuilder!(
style: options.theme.textStyles.listPostLikeTitleAndAmount ?? post: widget.post,
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,
),
],
), ),
), ] else ...[
const SizedBox(height: 4), Text(
Text( '${widget.post.likes} '
options.translations.viewPost, '${widget.options.translations.likesTitle}',
style: options.theme.textStyles.viewPostStyle ?? style: widget
theme.textTheme.bodySmall, .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!(),
], ],
), ),
), ),

View file

@ -19,11 +19,13 @@ dependencies:
dotted_border: ^2.1.0 dotted_border: ^2.1.0
flutter_html: ^3.0.0-beta.2 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: flutter_timeline_interface:
git: path: ../flutter_timeline_interface
url: https://github.com/Iconica-Development/flutter_timeline
path: packages/flutter_timeline_interface
ref: 1.0.0
flutter_image_picker: flutter_image_picker:
git: git:
url: https://github.com/Iconica-Development/flutter_image_picker url: https://github.com/Iconica-Development/flutter_image_picker