feat: add timeline reactions

This commit is contained in:
Freek van de Ven 2023-11-20 20:31:17 +01:00
parent e8822d92a3
commit 4f2aba4cc4
7 changed files with 274 additions and 87 deletions

View file

@ -7,7 +7,6 @@ import 'dart:typed_data';
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_storage/firebase_storage.dart'; import 'package:firebase_storage/firebase_storage.dart';
import 'package:flutter/material.dart';
import 'package:flutter_timeline_firebase/src/config/firebase_timeline_options.dart'; import 'package:flutter_timeline_firebase/src/config/firebase_timeline_options.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
@ -35,7 +34,8 @@ class FirebaseTimelineService implements TimelineService {
@override @override
Future<TimelinePost> createPost(TimelinePost post) async { Future<TimelinePost> createPost(TimelinePost post) async {
var postId = const Uuid().v4(); var postId = const Uuid().v4();
var imageRef = _storage.ref().child('timeline/$postId'); var imageRef =
_storage.ref().child('${_options.timelineCollectionName}/$postId');
var result = await imageRef.putData(post.image!); var result = await imageRef.putData(post.image!);
var imageUrl = await result.ref.getDownloadURL(); var imageUrl = await result.ref.getDownloadURL();
var updatedPost = post.copyWith(imageUrl: imageUrl, id: postId); var updatedPost = post.copyWith(imageUrl: imageUrl, id: postId);
@ -54,8 +54,17 @@ class FirebaseTimelineService implements TimelineService {
@override @override
Future<TimelinePost> fetchPostDetails(TimelinePost post) async { Future<TimelinePost> fetchPostDetails(TimelinePost post) async {
debugPrint('fetchPostDetails'); var reactions = post.reactions ?? [];
return post; var updatedReactions = <TimelinePostReaction>[];
for (var reaction in reactions) {
var user = await _userService.getUser(reaction.creatorId);
if (user != null) {
updatedReactions.add(reaction.copyWith(creator: user));
}
}
var updatedPost = post.copyWith(reactions: updatedReactions);
_posts = _posts.map((p) => (p.id == post.id) ? updatedPost : p).toList();
return updatedPost;
} }
@override @override
@ -124,26 +133,36 @@ class FirebaseTimelineService implements TimelineService {
} }
@override @override
Future<void> reactToPost( Future<TimelinePost> reactToPost(
TimelinePost post, TimelinePost post,
TimelinePostReaction reaction, { TimelinePostReaction reaction, {
Uint8List? image, Uint8List? image,
}) { }) async {
// update the post with the new reaction var reactionId = const Uuid().v4();
_posts = _posts // also fetch the user information and add it to the reaction
.map( var user = await _userService.getUser(reaction.creatorId);
(p) => (p.id == post.id) var updatedReaction = reaction.copyWith(id: reactionId, creator: user);
? p.copyWith( if (image != null) {
reaction: p.reaction + 1, var imageRef = _storage
reactions: p.reactions?..add(reaction), .ref()
) .child('${_options.timelineCollectionName}/${post.id}/$reactionId}');
: p, var result = await imageRef.putData(image);
) var imageUrl = await result.ref.getDownloadURL();
.toList(); updatedReaction = updatedReaction.copyWith(imageUrl: imageUrl);
}
var updatedPost = post.copyWith(
reaction: post.reaction + 1,
reactions: post.reactions?..add(updatedReaction),
);
var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
return postRef.update({ await postRef.update({
'reaction': FieldValue.increment(1), 'reaction': FieldValue.increment(1),
'reactions': FieldValue.arrayUnion([reaction.toJson()]), // 'reactions' is a map of reactions, so we need to add the new reaction
// to the map
'reactions': FieldValue.arrayUnion([updatedReaction.toJson()]),
}); });
return updatedPost;
} }
} }

View file

@ -39,18 +39,14 @@ class TimelinePost {
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 Map<String, dynamic>?) reactions: (json['reactions'] as List<dynamic>?)
?.map( ?.map(
(key, value) => MapEntry( (e) => TimelinePostReaction.fromJson(
key, (e as Map).keys.first,
TimelinePostReaction.fromJson( id,
key, e.values.first as Map<String, dynamic>,
id,
value as Map<String, dynamic>,
),
), ),
) )
.values
.toList(), .toList(),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
reactionEnabled: json['reaction_enabled'] as bool, reactionEnabled: json['reaction_enabled'] as bool,
@ -140,7 +136,8 @@ class TimelinePost {
'likes': likes, 'likes': likes,
'liked_by': likedBy, 'liked_by': likedBy,
'reaction': reaction, 'reaction': reaction,
'reactions': reactions?.map((e) => e.toJson()).toList(), // reactions is a list of maps so we need to convert it to a map
'reactions': reactions?.map((e) => e.toJson()).toList() ?? {},
'created_at': createdAt.toIso8601String(), 'created_at': createdAt.toIso8601String(),
'reaction_enabled': reactionEnabled, 'reaction_enabled': reactionEnabled,
}; };

View file

@ -52,6 +52,25 @@ class TimelinePostReaction {
/// Reaction creation date. /// Reaction creation date.
final DateTime createdAt; final DateTime createdAt;
TimelinePostReaction copyWith({
String? id,
String? postId,
String? creatorId,
TimelinePosterUserModel? creator,
String? reaction,
String? imageUrl,
DateTime? createdAt,
}) =>
TimelinePostReaction(
id: id ?? this.id,
postId: postId ?? this.postId,
creatorId: creatorId ?? this.creatorId,
creator: creator ?? this.creator,
reaction: reaction ?? this.reaction,
imageUrl: imageUrl ?? this.imageUrl,
createdAt: createdAt ?? this.createdAt,
);
Map<String, dynamic> toJson() => <String, dynamic>{ Map<String, dynamic> toJson() => <String, dynamic>{
id: { id: {
'creator_id': creatorId, 'creator_id': creatorId,

View file

@ -13,7 +13,7 @@ abstract class TimelineService {
Future<List<TimelinePost>> fetchPosts(String? category); Future<List<TimelinePost>> fetchPosts(String? category);
List<TimelinePost> getPosts(String? category); List<TimelinePost> getPosts(String? category);
Future<TimelinePost> fetchPostDetails(TimelinePost post); Future<TimelinePost> fetchPostDetails(TimelinePost post);
Future<void> reactToPost( Future<TimelinePost> reactToPost(
TimelinePost post, TimelinePost post,
TimelinePostReaction reaction, { TimelinePostReaction reaction, {
Uint8List image, Uint8List image,

View file

@ -15,6 +15,7 @@ class TimelineOptions {
this.translations = const TimelineTranslations(), this.translations = const TimelineTranslations(),
this.imagePickerConfig = const ImagePickerConfig(), this.imagePickerConfig = const ImagePickerConfig(),
this.imagePickerTheme = const ImagePickerTheme(), this.imagePickerTheme = const ImagePickerTheme(),
this.sortCommentsAscending = false,
this.dateformat, this.dateformat,
this.timeFormat, this.timeFormat,
this.buttonBuilder, this.buttonBuilder,
@ -31,6 +32,9 @@ class TimelineOptions {
/// The format to display the post time in /// The format to display the post time in
final DateFormat? timeFormat; final DateFormat? timeFormat;
/// Whether to sort comments ascending or descending
final bool sortCommentsAscending;
final TimelineTranslations translations; final TimelineTranslations translations;
final ButtonBuilder? buttonBuilder; final ButtonBuilder? buttonBuilder;

View file

@ -7,6 +7,9 @@ import 'package:flutter/material.dart';
@immutable @immutable
class TimelineTranslations { class TimelineTranslations {
const TimelineTranslations({ const TimelineTranslations({
this.anonymousUser = 'Anonymous user',
this.noPosts = 'No posts yet',
this.noPostsWithFilter = 'No posts with this filter',
this.title = 'Title', this.title = 'Title',
this.content = 'Content', this.content = 'Content',
this.contentDescription = 'What do you want to share?', this.contentDescription = 'What do you want to share?',
@ -20,10 +23,16 @@ class TimelineTranslations {
this.viewPost = 'View post', this.viewPost = 'View post',
this.likesTitle = 'Likes', this.likesTitle = 'Likes',
this.commentsTitle = 'Comments', this.commentsTitle = 'Comments',
this.firstComment = 'Be the first to comment',
this.writeComment = 'Write your comment here...', this.writeComment = 'Write your comment here...',
this.postAt = 'at', this.postAt = 'at',
this.postLoadingError = 'Something went wrong while loading the post',
}); });
final String noPosts;
final String noPostsWithFilter;
final String anonymousUser;
final String title; final String title;
final String content; final String content;
final String contentDescription; final String contentDescription;
@ -39,4 +48,6 @@ class TimelineTranslations {
final String likesTitle; final String likesTitle;
final String commentsTitle; final String commentsTitle;
final String writeComment; final String writeComment;
final String firstComment;
final String postLoadingError;
} }

View file

@ -2,39 +2,111 @@
// //
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
import 'dart:async';
import 'dart:typed_data';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
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:intl/intl.dart'; import 'package:intl/intl.dart';
class TimelinePostScreen extends StatelessWidget { class TimelinePostScreen extends StatefulWidget {
const TimelinePostScreen({ const TimelinePostScreen({
required this.userId,
required this.service,
required this.userService,
required this.options, required this.options,
required this.post, required this.post,
this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 16), this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
super.key, super.key,
}); });
/// The user id of the current user
final String userId;
/// The timeline service to fetch the post details
final TimelineService service;
/// The user service to fetch the profile picture of the user
final TimelineUserService userService;
/// Options to configure the timeline screens
final TimelineOptions options; final TimelineOptions options;
/// The post to show
final TimelinePost post; final TimelinePost post;
/// The padding around the screen /// The padding around the screen
final EdgeInsets padding; final EdgeInsets padding;
@override
State<TimelinePostScreen> createState() => _TimelinePostScreenState();
}
class _TimelinePostScreenState extends State<TimelinePostScreen> {
TimelinePost? post;
bool isLoading = true;
@override
void initState() {
super.initState();
unawaited(loadPostDetails());
}
Future<void> loadPostDetails() async {
try {
// Assuming fetchPostDetails is an async function returning a TimelinePost
var loadedPost = await widget.service.fetchPostDetails(widget.post);
setState(() {
post = loadedPost;
isLoading = false;
});
} on Exception catch (e) {
// Handle any errors here
debugPrint('Error loading post: $e');
setState(() {
isLoading = false;
});
}
}
void updatePost(TimelinePost newPost) {
setState(() {
post = newPost;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var theme = Theme.of(context); var theme = Theme.of(context);
var dateFormat = options.dateformat ?? var dateFormat = widget.options.dateformat ??
DateFormat('dd/MM/yyyy', Localizations.localeOf(context).languageCode); DateFormat('dd/MM/yyyy', Localizations.localeOf(context).languageCode);
var timeFormat = options.timeFormat ?? DateFormat('HH:mm'); var timeFormat = widget.options.timeFormat ?? DateFormat('HH:mm');
if (isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (this.post == null) {
return Center(
child: Text(widget.options.translations.postLoadingError),
);
}
var post = this.post!;
post.reactions?.sort(
(a, b) => widget.options.sortCommentsAscending
? b.createdAt.compareTo(a.createdAt)
: a.createdAt.compareTo(b.createdAt),
);
return Stack( return Stack(
children: [ children: [
SingleChildScrollView( SingleChildScrollView(
child: Padding( child: Padding(
padding: padding, padding: widget.padding,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -42,7 +114,7 @@ class TimelinePostScreen extends StatelessWidget {
Row( Row(
children: [ children: [
if (post.creator!.imageUrl != null) ...[ if (post.creator!.imageUrl != null) ...[
options.userAvatarBuilder?.call( widget.options.userAvatarBuilder?.call(
post.creator!, post.creator!,
40, 40,
) ?? ) ??
@ -93,7 +165,7 @@ class TimelinePostScreen extends StatelessWidget {
], ],
), ),
Text( Text(
'${post.likes} ${options.translations.likesTitle}', '${post.likes} ${widget.options.translations.likesTitle}',
style: theme.textTheme.titleSmall, style: theme.textTheme.titleSmall,
), ),
Row( Row(
@ -118,70 +190,135 @@ class TimelinePostScreen extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'${dateFormat.format(post.createdAt)} ' '${dateFormat.format(post.createdAt)} '
'${options.translations.postAt} ' '${widget.options.translations.postAt} '
'${timeFormat.format(post.createdAt)}', '${timeFormat.format(post.createdAt)}',
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
if (post.reactionEnabled) ...[
Text( Text(
options.translations.commentsTitle, widget.options.translations.commentsTitle,
style: theme.textTheme.displaySmall, style: theme.textTheme.displaySmall,
), ),
for (var reaction for (var reaction
in post.reactions ?? <TimelinePostReaction>[]) ...[ in post.reactions ?? <TimelinePostReaction>[]) ...[
const SizedBox(height: 8), const SizedBox(height: 16),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: reaction.imageUrl != null
children: [ ? CrossAxisAlignment.start
if (reaction.creator?.imageUrl != null && : CrossAxisAlignment.center,
reaction.creator!.imageUrl!.isNotEmpty) ...[ children: [
options.userAvatarBuilder?.call( if (reaction.creator?.imageUrl != null &&
reaction.creator!, reaction.creator!.imageUrl!.isNotEmpty) ...[
25, widget.options.userAvatarBuilder?.call(
) ?? reaction.creator!,
CircleAvatar( 25,
radius: 20, ) ??
backgroundImage: CachedNetworkImageProvider( CircleAvatar(
reaction.creator!.imageUrl!, radius: 20,
backgroundImage: CachedNetworkImageProvider(
reaction.creator!.imageUrl!,
),
),
],
const SizedBox(width: 10),
if (reaction.imageUrl != null) ...[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
reaction.creator?.fullName ??
widget.options.translations.anonymousUser,
style: theme.textTheme.titleSmall,
),
Padding(
padding: const EdgeInsets.all(8.0),
child: CachedNetworkImage(
imageUrl: reaction.imageUrl!,
fit: BoxFit.fitWidth,
),
),
],
),
),
] else ...[
Expanded(
child: Text.rich(
TextSpan(
text: reaction.creator?.fullName ??
widget.options.translations.anonymousUser,
style: theme.textTheme.titleSmall,
children: [
const TextSpan(text: ' '),
TextSpan(
text: reaction.reaction ?? '',
style: theme.textTheme.bodyMedium,
),
// text should go to new line
],
), ),
), ),
),
],
], ],
const SizedBox(width: 10), ),
if (reaction.creator?.fullName != null) ...[ ],
Text( const SizedBox(height: 120),
reaction.creator!.fullName!,
style: theme.textTheme.titleSmall,
),
],
const SizedBox(width: 10),
// TODO(anyone): show image if the user send one
Expanded(
child: Text(
reaction.reaction ?? '',
style: theme.textTheme.bodyMedium,
// text should go to new line
softWrap: true,
),
),
],
),
const SizedBox(height: 100),
], ],
], ],
), ),
), ),
), ),
Align( if (post.reactionEnabled)
alignment: Alignment.bottomCenter, Align(
child: ReactionBottom( alignment: Alignment.bottomCenter,
messageInputBuilder: options.textInputBuilder!, child: ReactionBottom(
onPressSelectImage: () async {}, messageInputBuilder: widget.options.textInputBuilder!,
onReactionSubmit: (reaction) async {}, onPressSelectImage: () async {
translations: options.translations, // open the image picker
iconColor: options.theme.iconColor, var result = await showModalBottomSheet<Uint8List?>(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(8.0),
color: Colors.black,
child: ImagePicker(
imagePickerConfig: widget.options.imagePickerConfig,
imagePickerTheme: widget.options.imagePickerTheme,
),
),
);
if (result != null) {
updatePost(
await widget.service.reactToPost(
post,
TimelinePostReaction(
id: '',
postId: post.id,
creatorId: widget.userId,
createdAt: DateTime.now(),
),
image: result,
),
);
}
},
onReactionSubmit: (reaction) async => updatePost(
await widget.service.reactToPost(
post,
TimelinePostReaction(
id: '',
postId: post.id,
reaction: reaction,
creatorId: widget.userId,
createdAt: DateTime.now(),
),
),
),
translations: widget.options.translations,
iconColor: widget.options.theme.iconColor,
),
), ),
),
], ],
); );
} }