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:firebase_core/firebase_core.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_interface/flutter_timeline_interface.dart';
import 'package:uuid/uuid.dart';
@ -35,7 +34,8 @@ class FirebaseTimelineService implements TimelineService {
@override
Future<TimelinePost> createPost(TimelinePost post) async {
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 imageUrl = await result.ref.getDownloadURL();
var updatedPost = post.copyWith(imageUrl: imageUrl, id: postId);
@ -54,8 +54,17 @@ class FirebaseTimelineService implements TimelineService {
@override
Future<TimelinePost> fetchPostDetails(TimelinePost post) async {
debugPrint('fetchPostDetails');
return post;
var reactions = post.reactions ?? [];
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
@ -124,26 +133,36 @@ class FirebaseTimelineService implements TimelineService {
}
@override
Future<void> reactToPost(
Future<TimelinePost> reactToPost(
TimelinePost post,
TimelinePostReaction reaction, {
Uint8List? image,
}) {
// update the post with the new reaction
_posts = _posts
.map(
(p) => (p.id == post.id)
? p.copyWith(
reaction: p.reaction + 1,
reactions: p.reactions?..add(reaction),
)
: p,
)
.toList();
}) async {
var reactionId = const Uuid().v4();
// also fetch the user information and add it to the reaction
var user = await _userService.getUser(reaction.creatorId);
var updatedReaction = reaction.copyWith(id: reactionId, creator: user);
if (image != null) {
var imageRef = _storage
.ref()
.child('${_options.timelineCollectionName}/${post.id}/$reactionId}');
var result = await imageRef.putData(image);
var imageUrl = await result.ref.getDownloadURL();
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);
return postRef.update({
await postRef.update({
'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,
likedBy: (json['liked_by'] as List<dynamic>?)?.cast<String>(),
reaction: json['reaction'] as int,
reactions: (json['reactions'] as Map<String, dynamic>?)
reactions: (json['reactions'] as List<dynamic>?)
?.map(
(key, value) => MapEntry(
key,
TimelinePostReaction.fromJson(
key,
(e) => TimelinePostReaction.fromJson(
(e as Map).keys.first,
id,
value as Map<String, dynamic>,
),
e.values.first as Map<String, dynamic>,
),
)
.values
.toList(),
createdAt: DateTime.parse(json['created_at'] as String),
reactionEnabled: json['reaction_enabled'] as bool,
@ -140,7 +136,8 @@ class TimelinePost {
'likes': likes,
'liked_by': likedBy,
'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(),
'reaction_enabled': reactionEnabled,
};

View file

@ -52,6 +52,25 @@ class TimelinePostReaction {
/// Reaction creation date.
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>{
id: {
'creator_id': creatorId,

View file

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

View file

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

View file

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

View file

@ -2,39 +2,111 @@
//
// SPDX-License-Identifier: BSD-3-Clause
import 'dart:async';
import 'dart:typed_data';
import 'package:cached_network_image/cached_network_image.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_view/src/config/timeline_options.dart';
import 'package:flutter_timeline_view/src/widgets/reaction_bottom.dart';
import 'package:intl/intl.dart';
class TimelinePostScreen extends StatelessWidget {
class TimelinePostScreen extends StatefulWidget {
const TimelinePostScreen({
required this.userId,
required this.service,
required this.userService,
required this.options,
required this.post,
this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
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;
/// The post to show
final TimelinePost post;
/// The padding around the screen
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
Widget build(BuildContext context) {
var theme = Theme.of(context);
var dateFormat = options.dateformat ??
var dateFormat = widget.options.dateformat ??
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(
children: [
SingleChildScrollView(
child: Padding(
padding: padding,
padding: widget.padding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -42,7 +114,7 @@ class TimelinePostScreen extends StatelessWidget {
Row(
children: [
if (post.creator!.imageUrl != null) ...[
options.userAvatarBuilder?.call(
widget.options.userAvatarBuilder?.call(
post.creator!,
40,
) ??
@ -93,7 +165,7 @@ class TimelinePostScreen extends StatelessWidget {
],
),
Text(
'${post.likes} ${options.translations.likesTitle}',
'${post.likes} ${widget.options.translations.likesTitle}',
style: theme.textTheme.titleSmall,
),
Row(
@ -118,25 +190,27 @@ class TimelinePostScreen extends StatelessWidget {
const SizedBox(height: 4),
Text(
'${dateFormat.format(post.createdAt)} '
'${options.translations.postAt} '
'${widget.options.translations.postAt} '
'${timeFormat.format(post.createdAt)}',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 12),
if (post.reactionEnabled) ...[
Text(
options.translations.commentsTitle,
widget.options.translations.commentsTitle,
style: theme.textTheme.displaySmall,
),
for (var reaction
in post.reactions ?? <TimelinePostReaction>[]) ...[
const SizedBox(height: 8),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: reaction.imageUrl != null
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
children: [
if (reaction.creator?.imageUrl != null &&
reaction.creator!.imageUrl!.isNotEmpty) ...[
options.userAvatarBuilder?.call(
widget.options.userAvatarBuilder?.call(
reaction.creator!,
25,
) ??
@ -148,38 +222,101 @@ class TimelinePostScreen extends StatelessWidget {
),
],
const SizedBox(width: 10),
if (reaction.creator?.fullName != null) ...[
if (reaction.imageUrl != null) ...[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
reaction.creator!.fullName!,
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,
),
),
],
const SizedBox(width: 10),
// TODO(anyone): show image if the user send one
),
),
] else ...[
Expanded(
child: Text(
reaction.reaction ?? '',
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
softWrap: true,
],
),
),
),
],
],
),
const SizedBox(height: 100),
],
const SizedBox(height: 120),
],
],
),
),
),
if (post.reactionEnabled)
Align(
alignment: Alignment.bottomCenter,
child: ReactionBottom(
messageInputBuilder: options.textInputBuilder!,
onPressSelectImage: () async {},
onReactionSubmit: (reaction) async {},
translations: options.translations,
iconColor: options.theme.iconColor,
messageInputBuilder: widget.options.textInputBuilder!,
onPressSelectImage: () async {
// open the image picker
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,
),
),
],