feat: allow post deletion and add changenotifiersystem

This commit is contained in:
Freek van de Ven 2023-11-21 20:02:01 +01:00
parent ca4ec03002
commit 753ecc039e
7 changed files with 156 additions and 90 deletions

View file

@ -7,11 +7,12 @@ 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';
class FirebaseTimelineService implements TimelineService { class FirebaseTimelineService with ChangeNotifier implements TimelineService {
FirebaseTimelineService({ FirebaseTimelineService({
required TimelineUserService userService, required TimelineUserService userService,
FirebaseApp? app, FirebaseApp? app,
@ -41,15 +42,18 @@ class FirebaseTimelineService implements TimelineService {
var updatedPost = post.copyWith(imageUrl: imageUrl, id: postId); var updatedPost = post.copyWith(imageUrl: imageUrl, id: postId);
var postRef = var postRef =
_db.collection(_options.timelineCollectionName).doc(updatedPost.id); _db.collection(_options.timelineCollectionName).doc(updatedPost.id);
_posts.add(updatedPost);
await postRef.set(updatedPost.toJson()); await postRef.set(updatedPost.toJson());
_posts.add(updatedPost);
notifyListeners();
return updatedPost; return updatedPost;
} }
@override @override
Future<void> deletePost(TimelinePost post) async { Future<void> deletePost(TimelinePost post) async {
_posts = _posts.where((element) => element.id != post.id).toList();
var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
return postRef.delete(); await postRef.delete();
notifyListeners();
} }
@override @override
@ -64,11 +68,13 @@ class FirebaseTimelineService implements TimelineService {
} }
var updatedPost = post.copyWith(reactions: updatedReactions); var updatedPost = post.copyWith(reactions: updatedReactions);
_posts = _posts.map((p) => (p.id == post.id) ? updatedPost : p).toList(); _posts = _posts.map((p) => (p.id == post.id) ? updatedPost : p).toList();
notifyListeners();
return updatedPost; return updatedPost;
} }
@override @override
Future<List<TimelinePost>> fetchPosts(String? category) async { Future<List<TimelinePost>> fetchPosts(String? category) async {
debugPrint('fetching posts from firebase $category!!!');
var snapshot = (category != null) var snapshot = (category != null)
? await _db ? await _db
.collection(_options.timelineCollectionName) .collection(_options.timelineCollectionName)
@ -84,6 +90,7 @@ class FirebaseTimelineService implements TimelineService {
posts.add(post); posts.add(post);
} }
_posts = posts; _posts = posts;
notifyListeners();
return posts; return posts;
} }
@ -109,6 +116,7 @@ class FirebaseTimelineService implements TimelineService {
'likes': FieldValue.increment(1), 'likes': FieldValue.increment(1),
'liked_by': FieldValue.arrayUnion([userId]), 'liked_by': FieldValue.arrayUnion([userId]),
}); });
notifyListeners();
return updatedPost; return updatedPost;
} }
@ -129,6 +137,7 @@ class FirebaseTimelineService implements TimelineService {
'likes': FieldValue.increment(-1), 'likes': FieldValue.increment(-1),
'liked_by': FieldValue.arrayRemove([userId]), 'liked_by': FieldValue.arrayRemove([userId]),
}); });
notifyListeners();
return updatedPost; return updatedPost;
} }
@ -161,6 +170,12 @@ class FirebaseTimelineService implements TimelineService {
'reaction': FieldValue.increment(1), 'reaction': FieldValue.increment(1),
'reactions': FieldValue.arrayUnion([updatedReaction.toJson()]), 'reactions': FieldValue.arrayUnion([updatedReaction.toJson()]),
}); });
_posts = _posts
.map(
(p) => p.id == post.id ? updatedPost : p,
)
.toList();
notifyListeners();
return updatedPost; return updatedPost;
} }
} }

View file

@ -137,7 +137,7 @@ class TimelinePost {
'liked_by': likedBy, 'liked_by': likedBy,
'reaction': reaction, 'reaction': reaction,
// reactions is a list of maps so we need to convert it to a map // 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(), 'created_at': createdAt.toIso8601String(),
'reaction_enabled': reactionEnabled, 'reaction_enabled': reactionEnabled,
}; };

View file

@ -4,10 +4,11 @@
import 'dart:typed_data'; 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_post.dart';
import 'package:flutter_timeline_interface/src/model/timeline_reaction.dart'; import 'package:flutter_timeline_interface/src/model/timeline_reaction.dart';
abstract class TimelineService { abstract class TimelineService with ChangeNotifier {
Future<void> deletePost(TimelinePost post); Future<void> deletePost(TimelinePost post);
Future<TimelinePost> createPost(TimelinePost post); Future<TimelinePost> createPost(TimelinePost post);
Future<List<TimelinePost>> fetchPosts(String? category); Future<List<TimelinePost>> fetchPosts(String? category);

View file

@ -15,7 +15,8 @@ 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.allowAllDeletion = false,
this.sortCommentsAscending = true,
this.sortPostsAscending = false, this.sortPostsAscending = false,
this.dateformat, this.dateformat,
this.timeFormat, this.timeFormat,
@ -39,6 +40,10 @@ class TimelineOptions {
/// Whether to sort posts ascending or descending /// Whether to sort posts ascending or descending
final bool sortPostsAscending; 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 TimelineTranslations translations;
final ButtonBuilder? buttonBuilder; final ButtonBuilder? buttonBuilder;

View file

@ -20,6 +20,7 @@ class TimelinePostScreen extends StatefulWidget {
required this.userService, required this.userService,
required this.options, required this.options,
required this.post, required this.post,
required this.onPostDelete,
this.onUserTap, this.onUserTap,
this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 16), this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
super.key, 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 /// If this is not null, the user can tap on the user avatar or name
final Function(String userId)? onUserTap; final Function(String userId)? onUserTap;
final VoidCallback onPostDelete;
@override @override
State<TimelinePostScreen> createState() => _TimelinePostScreenState(); State<TimelinePostScreen> createState() => _TimelinePostScreenState();
} }
@ -57,19 +60,19 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
unawaited(loadPostDetails()); WidgetsBinding.instance.addPostFrameCallback((_) async {
await loadPostDetails();
});
} }
Future<void> loadPostDetails() async { Future<void> loadPostDetails() async {
try { try {
// Assuming fetchPostDetails is an async function returning a TimelinePost
var loadedPost = await widget.service.fetchPostDetails(widget.post); var loadedPost = await widget.service.fetchPostDetails(widget.post);
setState(() { setState(() {
post = loadedPost; post = loadedPost;
isLoading = false; isLoading = false;
}); });
} on Exception catch (e) { } on Exception catch (e) {
// Handle any errors here
debugPrint('Error loading post: $e'); debugPrint('Error loading post: $e');
setState(() { setState(() {
isLoading = false; isLoading = false;
@ -102,8 +105,8 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
var post = this.post!; var post = this.post!;
post.reactions?.sort( post.reactions?.sort(
(a, b) => widget.options.sortCommentsAscending (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( return Stack(
@ -146,9 +149,37 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
), ),
), ),
const Spacer(), const Spacer(),
widget.options.theme.moreIcon ?? if (widget.options.allowAllDeletion ||
const Icon( post.creator?.userId == widget.userId)
PopupMenuButton(
onSelected: (value) async {
if (value == 'delete') {
await widget.service.deletePost(post);
widget.onPostDelete();
}
},
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
PopupMenuItem<String>(
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, Icons.more_horiz_rounded,
color: widget.options.theme.iconColor,
),
), ),
], ],
), ),
@ -202,8 +233,9 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
const SizedBox(width: 8), const SizedBox(width: 8),
if (post.reactionEnabled) if (post.reactionEnabled)
widget.options.theme.commentIcon ?? widget.options.theme.commentIcon ??
const Icon( Icon(
Icons.chat_bubble_outline_rounded, Icons.chat_bubble_outline_rounded,
color: widget.options.theme.iconColor,
), ),
], ],
), ),

View file

@ -60,7 +60,6 @@ class TimelineScreen extends StatefulWidget {
class _TimelineScreenState extends State<TimelineScreen> { class _TimelineScreenState extends State<TimelineScreen> {
late ScrollController controller; late ScrollController controller;
late List<TimelinePost> posts;
bool isLoading = true; bool isLoading = true;
@override @override
@ -73,11 +72,15 @@ class _TimelineScreenState extends State<TimelineScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (isLoading && widget.posts == null) { if (isLoading && widget.posts == null) {
// Show loading indicator while data is being fetched
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
var posts = widget.posts ?? this.posts; // Build the list of posts
return ListenableBuilder(
listenable: widget.service,
builder: (context, _) {
var posts = widget.posts ??
widget.service.getPosts(widget.timelineCategoryFilter);
posts = posts posts = posts
.where( .where(
(p) => (p) =>
@ -89,11 +92,9 @@ class _TimelineScreenState extends State<TimelineScreen> {
// sort posts by date // sort posts by date
posts.sort( posts.sort(
(a, b) => widget.options.sortPostsAscending (a, b) => widget.options.sortPostsAscending
? b.createdAt.compareTo(a.createdAt) ? a.createdAt.compareTo(b.createdAt)
: a.createdAt.compareTo(b.createdAt), : b.createdAt.compareTo(a.createdAt),
); );
// Build the list of posts
return SingleChildScrollView( return SingleChildScrollView(
controller: controller, controller: controller,
child: Column( child: Column(
@ -107,12 +108,11 @@ class _TimelineScreenState extends State<TimelineScreen> {
post: post, post: post,
height: widget.timelinePostHeight, height: widget.timelinePostHeight,
onTap: () => widget.onPostTap.call(post), onTap: () => widget.onPostTap.call(post),
onTapLike: () async => updatePostInList( onTapLike: () async =>
await widget.service.likePost(widget.userId, post), widget.service.likePost(widget.userId, post),
), onTapUnlike: () async =>
onTapUnlike: () async => updatePostInList( widget.service.unlikePost(widget.userId, post),
await widget.service.unlikePost(widget.userId, post), onPostDelete: () async => widget.service.deletePost(post),
),
onUserTap: widget.onUserTap, onUserTap: widget.onUserTap,
), ),
), ),
@ -131,16 +131,15 @@ class _TimelineScreenState extends State<TimelineScreen> {
], ],
), ),
); );
},
);
} }
Future<void> loadPosts() async { Future<void> loadPosts() async {
if (widget.posts != null) return; if (widget.posts != null) return;
try { try {
// Fetching posts from the service
var fetchedPosts =
await widget.service.fetchPosts(widget.timelineCategoryFilter); await widget.service.fetchPosts(widget.timelineCategoryFilter);
setState(() { setState(() {
posts = fetchedPosts;
isLoading = false; isLoading = false;
}); });
} on Exception catch (e) { } on Exception catch (e) {
@ -151,18 +150,4 @@ class _TimelineScreenState extends State<TimelineScreen> {
}); });
} }
} }
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];
});
}
}
} }

View file

@ -13,9 +13,10 @@ class TimelinePostWidget extends StatelessWidget {
required this.options, required this.options,
required this.post, required this.post,
required this.height, required this.height,
required this.onTap,
required this.onTapLike, required this.onTapLike,
required this.onTapUnlike, required this.onTapUnlike,
required this.onTap, required this.onPostDelete,
this.onUserTap, this.onUserTap,
super.key, super.key,
}); });
@ -29,6 +30,7 @@ class TimelinePostWidget extends StatelessWidget {
final VoidCallback onTap; final VoidCallback onTap;
final VoidCallback onTapLike; final VoidCallback onTapLike;
final VoidCallback onTapUnlike; final VoidCallback onTapUnlike;
final VoidCallback onPostDelete;
/// If this is not null, the user can tap on the user avatar or name /// If this is not null, the user can tap on the user avatar or name
final Function(String userId)? onUserTap; final Function(String userId)? onUserTap;
@ -76,9 +78,35 @@ class TimelinePostWidget extends StatelessWidget {
), ),
), ),
const Spacer(), const Spacer(),
options.theme.moreIcon ?? if (options.allowAllDeletion || post.creator?.userId == userId)
const Icon( PopupMenuButton(
onSelected: (value) {
if (value == 'delete') {
onPostDelete();
}
},
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
PopupMenuItem<String>(
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, Icons.more_horiz_rounded,
color: options.theme.iconColor,
),
), ),
], ],
), ),