From 753ecc039eefd154c9c3cf9c439102fd8bf0ea60 Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Tue, 21 Nov 2023 20:02:01 +0100 Subject: [PATCH] feat: allow post deletion and add changenotifiersystem --- .../service/firebase_timeline_service.dart | 21 ++- .../lib/src/model/timeline_post.dart | 2 +- .../lib/src/services/timeline_service.dart | 3 +- .../lib/src/config/timeline_options.dart | 7 +- .../lib/src/screens/timeline_post_screen.dart | 52 ++++++-- .../lib/src/screens/timeline_screen.dart | 123 ++++++++---------- .../lib/src/widgets/timeline_post_widget.dart | 38 +++++- 7 files changed, 156 insertions(+), 90 deletions(-) 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 77ef4e4..9693f1d 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,11 +7,12 @@ 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'; -class FirebaseTimelineService implements TimelineService { +class FirebaseTimelineService with ChangeNotifier implements TimelineService { FirebaseTimelineService({ required TimelineUserService userService, FirebaseApp? app, @@ -41,15 +42,18 @@ class FirebaseTimelineService implements TimelineService { var updatedPost = post.copyWith(imageUrl: imageUrl, id: postId); var postRef = _db.collection(_options.timelineCollectionName).doc(updatedPost.id); - _posts.add(updatedPost); await postRef.set(updatedPost.toJson()); + _posts.add(updatedPost); + notifyListeners(); return updatedPost; } @override Future deletePost(TimelinePost post) async { + _posts = _posts.where((element) => element.id != post.id).toList(); var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); - return postRef.delete(); + await postRef.delete(); + notifyListeners(); } @override @@ -64,11 +68,13 @@ class FirebaseTimelineService implements TimelineService { } var updatedPost = post.copyWith(reactions: updatedReactions); _posts = _posts.map((p) => (p.id == post.id) ? updatedPost : p).toList(); + notifyListeners(); return updatedPost; } @override Future> fetchPosts(String? category) async { + debugPrint('fetching posts from firebase $category!!!'); var snapshot = (category != null) ? await _db .collection(_options.timelineCollectionName) @@ -84,6 +90,7 @@ class FirebaseTimelineService implements TimelineService { posts.add(post); } _posts = posts; + notifyListeners(); return posts; } @@ -109,6 +116,7 @@ class FirebaseTimelineService implements TimelineService { 'likes': FieldValue.increment(1), 'liked_by': FieldValue.arrayUnion([userId]), }); + notifyListeners(); return updatedPost; } @@ -129,6 +137,7 @@ class FirebaseTimelineService implements TimelineService { 'likes': FieldValue.increment(-1), 'liked_by': FieldValue.arrayRemove([userId]), }); + notifyListeners(); return updatedPost; } @@ -161,6 +170,12 @@ class FirebaseTimelineService implements TimelineService { 'reaction': FieldValue.increment(1), 'reactions': FieldValue.arrayUnion([updatedReaction.toJson()]), }); + _posts = _posts + .map( + (p) => p.id == post.id ? updatedPost : p, + ) + .toList(); + notifyListeners(); 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 213f728..9b8c3ae 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart @@ -137,7 +137,7 @@ class TimelinePost { 'liked_by': likedBy, 'reaction': reaction, // reactions is a list of maps so we need to convert it to a map - 'reactions': reactions?.map((e) => e.toJson()).toList() ?? {}, + 'reactions': reactions?.map((e) => e.toJson()).toList() ?? [], 'created_at': createdAt.toIso8601String(), 'reaction_enabled': reactionEnabled, }; 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 8305d1b..6e60f1a 100644 --- a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart +++ b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart @@ -4,10 +4,11 @@ import 'dart:typed_data'; +import 'package:flutter/material.dart'; import 'package:flutter_timeline_interface/src/model/timeline_post.dart'; import 'package:flutter_timeline_interface/src/model/timeline_reaction.dart'; -abstract class TimelineService { +abstract class TimelineService with ChangeNotifier { Future deletePost(TimelinePost post); Future createPost(TimelinePost post); Future> fetchPosts(String? category); 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 ced3f32..5015244 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart @@ -15,7 +15,8 @@ class TimelineOptions { this.translations = const TimelineTranslations(), this.imagePickerConfig = const ImagePickerConfig(), this.imagePickerTheme = const ImagePickerTheme(), - this.sortCommentsAscending = false, + this.allowAllDeletion = false, + this.sortCommentsAscending = true, this.sortPostsAscending = false, this.dateformat, this.timeFormat, @@ -39,6 +40,10 @@ class TimelineOptions { /// Whether to sort posts ascending or descending final bool sortPostsAscending; + /// Allow all posts to be deleted instead of + /// only the posts of the current user + final bool allowAllDeletion; + final TimelineTranslations translations; final ButtonBuilder? buttonBuilder; 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 68ef907..fd6083c 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 @@ -20,6 +20,7 @@ class TimelinePostScreen extends StatefulWidget { required this.userService, required this.options, required this.post, + required this.onPostDelete, this.onUserTap, this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 16), super.key, @@ -46,6 +47,8 @@ class TimelinePostScreen extends StatefulWidget { /// If this is not null, the user can tap on the user avatar or name final Function(String userId)? onUserTap; + final VoidCallback onPostDelete; + @override State createState() => _TimelinePostScreenState(); } @@ -57,19 +60,19 @@ class _TimelinePostScreenState extends State { @override void initState() { super.initState(); - unawaited(loadPostDetails()); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await 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; @@ -102,8 +105,8 @@ class _TimelinePostScreenState extends State { var post = this.post!; post.reactions?.sort( (a, b) => widget.options.sortCommentsAscending - ? b.createdAt.compareTo(a.createdAt) - : a.createdAt.compareTo(b.createdAt), + ? a.createdAt.compareTo(b.createdAt) + : b.createdAt.compareTo(a.createdAt), ); return Stack( @@ -146,10 +149,38 @@ class _TimelinePostScreenState extends State { ), ), const Spacer(), - widget.options.theme.moreIcon ?? - const Icon( - Icons.more_horiz_rounded, - ), + if (widget.options.allowAllDeletion || + post.creator?.userId == widget.userId) + PopupMenuButton( + onSelected: (value) async { + if (value == 'delete') { + await widget.service.deletePost(post); + widget.onPostDelete(); + } + }, + itemBuilder: (BuildContext context) => + >[ + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Text(widget.options.translations.deletePost), + const SizedBox(width: 8), + widget.options.theme.deleteIcon ?? + Icon( + Icons.delete, + color: widget.options.theme.iconColor, + ), + ], + ), + ), + ], + child: widget.options.theme.moreIcon ?? + Icon( + Icons.more_horiz_rounded, + color: widget.options.theme.iconColor, + ), + ), ], ), const SizedBox(height: 8), @@ -202,8 +233,9 @@ class _TimelinePostScreenState extends State { const SizedBox(width: 8), if (post.reactionEnabled) widget.options.theme.commentIcon ?? - const Icon( + Icon( Icons.chat_bubble_outline_rounded, + color: widget.options.theme.iconColor, ), ], ), diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart index 09409c8..7b07a72 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart @@ -60,7 +60,6 @@ class TimelineScreen extends StatefulWidget { class _TimelineScreenState extends State { late ScrollController controller; - late List posts; bool isLoading = true; @override @@ -73,74 +72,74 @@ class _TimelineScreenState extends State { @override Widget build(BuildContext context) { if (isLoading && widget.posts == null) { - // Show loading indicator while data is being fetched return const Center(child: CircularProgressIndicator()); } - var posts = widget.posts ?? this.posts; - posts = posts - .where( - (p) => - widget.timelineCategoryFilter == null || - p.category == widget.timelineCategoryFilter, - ) - .toList(); - - // sort posts by date - posts.sort( - (a, b) => widget.options.sortPostsAscending - ? b.createdAt.compareTo(a.createdAt) - : a.createdAt.compareTo(b.createdAt), - ); - // Build the list of posts - return SingleChildScrollView( - controller: controller, - child: Column( - children: [ - ...posts.map( - (post) => Padding( - padding: widget.padding, - child: TimelinePostWidget( - userId: widget.userId, - options: widget.options, - post: post, - height: widget.timelinePostHeight, - onTap: () => widget.onPostTap.call(post), - onTapLike: () async => updatePostInList( - await widget.service.likePost(widget.userId, post), + return ListenableBuilder( + listenable: widget.service, + builder: (context, _) { + var posts = widget.posts ?? + widget.service.getPosts(widget.timelineCategoryFilter); + posts = posts + .where( + (p) => + widget.timelineCategoryFilter == null || + p.category == widget.timelineCategoryFilter, + ) + .toList(); + + // sort posts by date + posts.sort( + (a, b) => widget.options.sortPostsAscending + ? a.createdAt.compareTo(b.createdAt) + : b.createdAt.compareTo(a.createdAt), + ); + return SingleChildScrollView( + controller: controller, + child: Column( + children: [ + ...posts.map( + (post) => Padding( + padding: widget.padding, + child: TimelinePostWidget( + userId: widget.userId, + options: widget.options, + post: post, + height: widget.timelinePostHeight, + onTap: () => widget.onPostTap.call(post), + onTapLike: () async => + widget.service.likePost(widget.userId, post), + onTapUnlike: () async => + widget.service.unlikePost(widget.userId, post), + onPostDelete: () async => widget.service.deletePost(post), + onUserTap: widget.onUserTap, + ), ), - onTapUnlike: () async => updatePostInList( - await widget.service.unlikePost(widget.userId, post), - ), - onUserTap: widget.onUserTap, ), - ), + if (posts.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + widget.timelineCategoryFilter == null + ? widget.options.translations.noPosts + : widget.options.translations.noPostsWithFilter, + ), + ), + ), + ], ), - if (posts.isEmpty) - Center( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - widget.timelineCategoryFilter == null - ? widget.options.translations.noPosts - : widget.options.translations.noPostsWithFilter, - ), - ), - ), - ], - ), + ); + }, ); } Future loadPosts() async { if (widget.posts != null) return; try { - // Fetching posts from the service - var fetchedPosts = - await widget.service.fetchPosts(widget.timelineCategoryFilter); + await widget.service.fetchPosts(widget.timelineCategoryFilter); setState(() { - posts = fetchedPosts; isLoading = false; }); } on Exception catch (e) { @@ -151,18 +150,4 @@ class _TimelineScreenState extends State { }); } } - - 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]; - }); - } - } } diff --git a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart index 4733230..4ee162b 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart +++ b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart @@ -13,9 +13,10 @@ class TimelinePostWidget extends StatelessWidget { required this.options, required this.post, required this.height, + required this.onTap, required this.onTapLike, required this.onTapUnlike, - required this.onTap, + required this.onPostDelete, this.onUserTap, super.key, }); @@ -29,6 +30,7 @@ class TimelinePostWidget extends StatelessWidget { final VoidCallback onTap; final VoidCallback onTapLike; final VoidCallback onTapUnlike; + final VoidCallback onPostDelete; /// If this is not null, the user can tap on the user avatar or name final Function(String userId)? onUserTap; @@ -76,10 +78,36 @@ class TimelinePostWidget extends StatelessWidget { ), ), const Spacer(), - options.theme.moreIcon ?? - const Icon( - Icons.more_horiz_rounded, - ), + if (options.allowAllDeletion || post.creator?.userId == userId) + PopupMenuButton( + onSelected: (value) { + if (value == 'delete') { + onPostDelete(); + } + }, + itemBuilder: (BuildContext context) => + >[ + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Text(options.translations.deletePost), + const SizedBox(width: 8), + options.theme.deleteIcon ?? + Icon( + Icons.delete, + color: options.theme.iconColor, + ), + ], + ), + ), + ], + child: options.theme.moreIcon ?? + Icon( + Icons.more_horiz_rounded, + color: options.theme.iconColor, + ), + ), ], ), const SizedBox(height: 8),