mirror of
https://github.com/Iconica-Development/flutter_timeline.git
synced 2025-05-19 10:33:44 +02:00
feat: add timeline reactions
This commit is contained in:
parent
e8822d92a3
commit
4f2aba4cc4
7 changed files with 274 additions and 87 deletions
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
id,
|
||||
value as Map<String, dynamic>,
|
||||
),
|
||||
(e) => TimelinePostReaction.fromJson(
|
||||
(e as Map).keys.first,
|
||||
id,
|
||||
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,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,70 +190,135 @@ 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),
|
||||
|
||||
Text(
|
||||
options.translations.commentsTitle,
|
||||
style: theme.textTheme.displaySmall,
|
||||
),
|
||||
for (var reaction
|
||||
in post.reactions ?? <TimelinePostReaction>[]) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (reaction.creator?.imageUrl != null &&
|
||||
reaction.creator!.imageUrl!.isNotEmpty) ...[
|
||||
options.userAvatarBuilder?.call(
|
||||
reaction.creator!,
|
||||
25,
|
||||
) ??
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
reaction.creator!.imageUrl!,
|
||||
if (post.reactionEnabled) ...[
|
||||
Text(
|
||||
widget.options.translations.commentsTitle,
|
||||
style: theme.textTheme.displaySmall,
|
||||
),
|
||||
for (var reaction
|
||||
in post.reactions ?? <TimelinePostReaction>[]) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: reaction.imageUrl != null
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (reaction.creator?.imageUrl != null &&
|
||||
reaction.creator!.imageUrl!.isNotEmpty) ...[
|
||||
widget.options.userAvatarBuilder?.call(
|
||||
reaction.creator!,
|
||||
25,
|
||||
) ??
|
||||
CircleAvatar(
|
||||
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(
|
||||
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),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 120),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ReactionBottom(
|
||||
messageInputBuilder: options.textInputBuilder!,
|
||||
onPressSelectImage: () async {},
|
||||
onReactionSubmit: (reaction) async {},
|
||||
translations: options.translations,
|
||||
iconColor: options.theme.iconColor,
|
||||
if (post.reactionEnabled)
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ReactionBottom(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue