feat: add like functionality

This commit is contained in:
Freek van de Ven 2023-11-21 12:33:46 +01:00
parent 4f2aba4cc4
commit 8792079fa4
7 changed files with 221 additions and 108 deletions

View file

@ -93,43 +93,43 @@ class FirebaseTimelineService implements TimelineService {
.toList(); .toList();
@override @override
Future<void> likePost(String userId, TimelinePost post) { Future<TimelinePost> likePost(String userId, TimelinePost post) async {
// update the post with the new like // update the post with the new like
var updatedPost = post.copyWith(
likes: post.likes + 1,
likedBy: post.likedBy?..add(userId),
);
_posts = _posts _posts = _posts
.map( .map(
(p) => (p.id == post.id) (p) => p.id == post.id ? updatedPost : p,
? p.copyWith(
likes: p.likes + 1,
likedBy: p.likedBy?..add(userId),
)
: p,
) )
.toList(); .toList();
var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
return postRef.update({ await postRef.update({
'likes': FieldValue.increment(1), 'likes': FieldValue.increment(1),
'liked_by': FieldValue.arrayUnion([userId]), 'liked_by': FieldValue.arrayUnion([userId]),
}); });
return updatedPost;
} }
@override @override
Future<void> unlikePost(String userId, TimelinePost post) { Future<TimelinePost> unlikePost(String userId, TimelinePost post) async {
// update the post with the new like // update the post with the new like
var updatedPost = post.copyWith(
likes: post.likes - 1,
likedBy: post.likedBy?..remove(userId),
);
_posts = _posts _posts = _posts
.map( .map(
(p) => (p.id == post.id) (p) => p.id == post.id ? updatedPost : p,
? p.copyWith(
likes: p.likes - 1,
likedBy: p.likedBy?..remove(userId),
)
: p,
) )
.toList(); .toList();
var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
return postRef.update({ await postRef.update({
'likes': FieldValue.increment(-1), 'likes': FieldValue.increment(-1),
'liked_by': FieldValue.arrayRemove([userId]), 'liked_by': FieldValue.arrayRemove([userId]),
}); });
return updatedPost;
} }
@override @override
@ -159,8 +159,6 @@ class FirebaseTimelineService implements TimelineService {
var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
await postRef.update({ await postRef.update({
'reaction': FieldValue.increment(1), 'reaction': FieldValue.increment(1),
// 'reactions' is a map of reactions, so we need to add the new reaction
// to the map
'reactions': FieldValue.arrayUnion([updatedReaction.toJson()]), 'reactions': FieldValue.arrayUnion([updatedReaction.toJson()]),
}); });
return updatedPost; return updatedPost;

View file

@ -37,7 +37,7 @@ class TimelinePost {
imageUrl: json['image_url'] as String?, imageUrl: json['image_url'] as String?,
content: json['content'] as String, content: json['content'] as String,
likes: json['likes'] as int, likes: json['likes'] as int,
likedBy: (json['liked_by'] as List<dynamic>?)?.cast<String>(), likedBy: (json['liked_by'] as List<dynamic>?)?.cast<String>() ?? [],
reaction: json['reaction'] as int, reaction: json['reaction'] as int,
reactions: (json['reactions'] as List<dynamic>?) reactions: (json['reactions'] as List<dynamic>?)
?.map( ?.map(

View file

@ -18,6 +18,6 @@ abstract class TimelineService {
TimelinePostReaction reaction, { TimelinePostReaction reaction, {
Uint8List image, Uint8List image,
}); });
Future<void> likePost(String userId, TimelinePost post); Future<TimelinePost> likePost(String userId, TimelinePost post);
Future<void> unlikePost(String userId, TimelinePost post); Future<TimelinePost> unlikePost(String userId, TimelinePost post);
} }

View file

@ -8,7 +8,23 @@ import 'package:flutter/material.dart';
class TimelineTheme { class TimelineTheme {
const TimelineTheme({ const TimelineTheme({
this.iconColor, this.iconColor,
this.likeIcon,
this.commentIcon,
this.likedIcon,
this.sendIcon,
}); });
final Color? iconColor; final Color? iconColor;
/// The icon to display when the post is not yet liked
final Widget? likeIcon;
/// The icon to display to indicate that a post has comments enabled
final Widget? commentIcon;
/// The icon to display when the post is liked
final Widget? likedIcon;
/// The icon to display to submit a comment
final Widget? sendIcon;
} }

View file

@ -148,41 +148,73 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
), ),
], ],
// post information // post information
Row( Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
children: [ children: [
// like icon if (post.likedBy?.contains(widget.userId) ?? false) ...[
IconButton( InkWell(
onPressed: () {}, onTap: () async {
icon: const Icon(Icons.thumb_up_rounded), updatePost(
await widget.service.unlikePost(
widget.userId,
post,
), ),
// comment icon );
IconButton( },
onPressed: () {}, child: widget.options.theme.likedIcon ??
icon: const Icon( Icon(
Icons.chat_bubble_outline_rounded, Icons.thumb_up_rounded,
color: widget.options.theme.iconColor,
),
),
] else ...[
InkWell(
onTap: () async {
updatePost(
await widget.service.likePost(
widget.userId,
post,
),
);
},
child: widget.options.theme.likeIcon ??
Icon(
Icons.thumb_up_alt_outlined,
color: widget.options.theme.iconColor,
), ),
), ),
], ],
const SizedBox(width: 8),
if (post.reactionEnabled)
widget.options.theme.commentIcon ??
const Icon(
Icons.chat_bubble_outline_rounded,
),
],
),
), ),
Text( Text(
'${post.likes} ${widget.options.translations.likesTitle}', '${post.likes} ${widget.options.translations.likesTitle}',
style: theme.textTheme.titleSmall, style: theme.textTheme.titleSmall,
), ),
Row( const SizedBox(height: 4),
crossAxisAlignment: CrossAxisAlignment.center, Text.rich(
children: [ TextSpan(
Text( text: post.creator?.fullName ??
post.creator?.fullName ?? '', widget.options.translations.anonymousUser,
style: theme.textTheme.titleSmall, style: theme.textTheme.titleSmall,
), children: [
const SizedBox(width: 8), const TextSpan(text: ' '),
Text( TextSpan(
post.title, text: post.title,
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyMedium,
overflow: TextOverflow.fade,
), ),
], ],
), ),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text( Text(
post.content, post.content,
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyMedium,

View file

@ -2,6 +2,8 @@
// //
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
import 'dart:async';
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';
@ -20,6 +22,7 @@ class TimelineScreen extends StatefulWidget {
super.key, super.key,
}); });
/// The user id of the current user
final String userId; final String userId;
final TimelineService service; final TimelineService service;
@ -42,40 +45,80 @@ class TimelineScreen extends StatefulWidget {
class _TimelineScreenState extends State<TimelineScreen> { class _TimelineScreenState extends State<TimelineScreen> {
late ScrollController controller; late ScrollController controller;
late List<TimelinePost> posts;
bool isLoading = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
controller = widget.controller ?? ScrollController(); controller = widget.controller ?? ScrollController();
unawaited(loadPosts());
}
Future<void> loadPosts() async {
try {
// Fetching posts from the service
var fetchedPosts =
await widget.service.fetchPosts(widget.timelineCategoryFilter);
setState(() {
posts = fetchedPosts;
isLoading = false;
});
} on Exception catch (e) {
// Handle errors here
debugPrint('Error loading posts: $e');
setState(() {
isLoading = false;
});
}
}
void updatePostInList(TimelinePost updatedPost) {
if (posts.any((p) => p.id == updatedPost.id))
setState(() {
posts = posts
.map((p) => (p.id == updatedPost.id) ? updatedPost : p)
.toList();
});
else {
setState(() {
posts = [updatedPost, ...posts];
});
}
} }
@override @override
Widget build(BuildContext context) => FutureBuilder( Widget build(BuildContext context) {
// ignore: discarded_futures if (isLoading) {
future: widget.service.fetchPosts(widget.timelineCategoryFilter), // Show loading indicator while data is being fetched
builder: (context, snapshot) { return const Center(child: CircularProgressIndicator());
if (snapshot.hasData && snapshot.data != null) { }
// Build the list of posts
return SingleChildScrollView( return SingleChildScrollView(
controller: controller,
child: Column( child: Column(
children: [ children: posts
for (var post in snapshot.data!) .map(
if (widget.timelineCategoryFilter == null || (post) => Padding(
post.category == widget.timelineCategoryFilter)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),
child: TimelinePostWidget( child: TimelinePostWidget(
userId: widget.userId,
options: widget.options, options: widget.options,
post: post, post: post,
height: widget.timelinePostHeight, height: widget.timelinePostHeight,
onTap: () => widget.onPostTap.call(post), onTap: () => widget.onPostTap.call(post),
onTapLike: () async => updatePostInList(
await widget.service.likePost(widget.userId, post),
),
onTapUnlike: () async => updatePostInList(
await widget.service.unlikePost(widget.userId, post),
), ),
), ),
], ),
)
.toList(),
), ),
); );
} else {
return const Center(child: CircularProgressIndicator());
} }
},
);
} }

View file

@ -5,18 +5,25 @@ import 'package:flutter_timeline_view/src/config/timeline_options.dart';
class TimelinePostWidget extends StatelessWidget { class TimelinePostWidget extends StatelessWidget {
const TimelinePostWidget({ const TimelinePostWidget({
required this.userId,
required this.options, required this.options,
required this.post, required this.post,
required this.height, required this.height,
this.onTap, required this.onTapLike,
required this.onTapUnlike,
required this.onTap,
super.key, super.key,
}); });
/// The user id of the current user
final String userId;
final TimelineOptions options; final TimelineOptions options;
final TimelinePost post; final TimelinePost post;
final double height; final double height;
final VoidCallback? onTap; final VoidCallback onTap;
final VoidCallback onTapLike;
final VoidCallback onTapUnlike;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -69,41 +76,58 @@ class TimelinePostWidget extends StatelessWidget {
), ),
], ],
// post information // post information
Row( Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
children: [ children: [
// like icon if (post.likedBy?.contains(userId) ?? false) ...[
IconButton( InkWell(
onPressed: () {}, onTap: onTapUnlike,
icon: const Icon(Icons.thumb_up_rounded), child: options.theme.likedIcon ??
Icon(
Icons.thumb_up_rounded,
color: options.theme.iconColor,
), ),
// comment icon ),
IconButton( ] else ...[
onPressed: () {}, InkWell(
icon: const Icon( onTap: onTapLike,
Icons.chat_bubble_outline_rounded, child: options.theme.likeIcon ??
Icon(
Icons.thumb_up_alt_outlined,
color: options.theme.iconColor,
), ),
), ),
], ],
const SizedBox(width: 8),
if (post.reactionEnabled)
options.theme.commentIcon ??
const Icon(
Icons.chat_bubble_outline_rounded,
),
],
),
), ),
Text( Text(
'${post.likes} ${options.translations.likesTitle}', '${post.likes} ${options.translations.likesTitle}',
style: theme.textTheme.titleSmall, style: theme.textTheme.titleSmall,
), ),
Row( const SizedBox(height: 4),
crossAxisAlignment: CrossAxisAlignment.center, Text.rich(
children: [ TextSpan(
Text( text: post.creator?.fullName ??
post.creator?.fullName ?? '', options.translations.anonymousUser,
style: theme.textTheme.titleSmall, style: theme.textTheme.titleSmall,
), children: [
const SizedBox(width: 8), const TextSpan(text: ' '),
Text( TextSpan(
post.title, text: post.title,
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyMedium,
overflow: TextOverflow.fade,
), ),
], ],
), ),
overflow: TextOverflow.ellipsis,
),
Text( Text(
options.translations.viewPost, options.translations.viewPost,
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,