diff --git a/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart b/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart index 3048832..54bb9b2 100644 --- a/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart +++ b/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart @@ -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 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 fetchPostDetails(TimelinePost post) async { - debugPrint('fetchPostDetails'); - return post; + var reactions = post.reactions ?? []; + var updatedReactions = []; + 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 reactToPost( + Future 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; } } diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart index 8fe1eb9..68780e2 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart @@ -39,18 +39,14 @@ class TimelinePost { likes: json['likes'] as int, likedBy: (json['liked_by'] as List?)?.cast(), reaction: json['reaction'] as int, - reactions: (json['reactions'] as Map?) + reactions: (json['reactions'] as List?) ?.map( - (key, value) => MapEntry( - key, - TimelinePostReaction.fromJson( - key, - id, - value as Map, - ), + (e) => TimelinePostReaction.fromJson( + (e as Map).keys.first, + id, + e.values.first as Map, ), ) - .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, }; diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart index 76b22ca..0b8b231 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart @@ -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 toJson() => { id: { 'creator_id': creatorId, diff --git a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart index d00c911..8791e1c 100644 --- a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart +++ b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart @@ -13,7 +13,7 @@ abstract class TimelineService { Future> fetchPosts(String? category); List getPosts(String? category); Future fetchPostDetails(TimelinePost post); - Future reactToPost( + Future reactToPost( TimelinePost post, TimelinePostReaction reaction, { Uint8List image, diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart index 5649978..d62890f 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart @@ -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; diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart b/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart index df33ff7..c24f96f 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart @@ -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; } diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart index 8c5effe..5577137 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart @@ -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 createState() => _TimelinePostScreenState(); +} + +class _TimelinePostScreenState extends State { + TimelinePost? post; + bool isLoading = true; + + @override + void initState() { + super.initState(); + unawaited(loadPostDetails()); + } + + Future 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 ?? []) ...[ - 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 ?? []) ...[ + 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( + 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, + ), ), - ), ], ); }