diff --git a/README.md b/README.md index 2ea41d3..7242285 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ List getTimelineStoryRoutes() => getTimelineStoryRoutes( service: FirebaseTimelineService(), userService: FirebaseUserService(), userId: currentUserId, - optionsBuilder: (context) {}, + optionsBuilder: (context) => FirebaseOptions(), ), ); ``` @@ -79,13 +79,12 @@ TimelineScreen( userId: currentUserId, service: timelineService, options: timelineOptions, - onPostTap: (post) {} ), ```` `TimelineScreen` is supplied with a standard `TimelinePostScreen` which opens the detail page of the selected post. Needed parameter like `TimelineService` and `TimelineOptions` will be the same as the ones supplied to the `TimelineScreen`. -The standard `TimelinePostScreen` can be overridden by supplying `onPostTap` as shown below. +The standard `TimelinePostScreen` can be overridden by defining `onPostTap` as shown below. ``` TimelineScreen( diff --git a/packages/flutter_timeline/example/lib/apps/go_router/app.dart b/packages/flutter_timeline/example/lib/apps/go_router/app.dart index a4ccbe9..7f222a7 100644 --- a/packages/flutter_timeline/example/lib/apps/go_router/app.dart +++ b/packages/flutter_timeline/example/lib/apps/go_router/app.dart @@ -4,7 +4,7 @@ import 'package:flutter_timeline/flutter_timeline.dart'; import 'package:go_router/go_router.dart'; List getTimelineRoutes() => getTimelineStoryRoutes( - getConfig(TimelineService( + configuration: getConfig(TimelineService( postService: LocalTimelinePostService(), )), ); diff --git a/packages/flutter_timeline/example/lib/apps/navigator/app.dart b/packages/flutter_timeline/example/lib/apps/navigator/app.dart index 36e3c44..aed79d5 100644 --- a/packages/flutter_timeline/example/lib/apps/navigator/app.dart +++ b/packages/flutter_timeline/example/lib/apps/navigator/app.dart @@ -31,8 +31,7 @@ class MyHomePage extends StatefulWidget { } class _MyHomePageState extends State { - var timelineService = - TimelineService(postService: LocalTimelinePostService()); + var timelineService = TimelineService(postService: LocalTimelinePostService()); var timelineOptions = options; @override diff --git a/packages/flutter_timeline/example/lib/apps/widgets/app.dart b/packages/flutter_timeline/example/lib/apps/widgets/app.dart index 2292e17..1ac4aad 100644 --- a/packages/flutter_timeline/example/lib/apps/widgets/app.dart +++ b/packages/flutter_timeline/example/lib/apps/widgets/app.dart @@ -32,8 +32,7 @@ class MyHomePage extends StatefulWidget { } class _MyHomePageState extends State { - var timelineService = - TimelineService(postService: LocalTimelinePostService()); + var timelineService = TimelineService(postService: LocalTimelinePostService()); var timelineOptions = options; @override diff --git a/packages/flutter_timeline/lib/src/flutter_timeline_navigator_userstory.dart b/packages/flutter_timeline/lib/src/flutter_timeline_navigator_userstory.dart index 341b73a..6c708f4 100644 --- a/packages/flutter_timeline/lib/src/flutter_timeline_navigator_userstory.dart +++ b/packages/flutter_timeline/lib/src/flutter_timeline_navigator_userstory.dart @@ -5,47 +5,80 @@ import 'package:flutter/material.dart'; import 'package:flutter_timeline/flutter_timeline.dart'; -Widget timeLineNavigatorUserStory( - TimelineUserStoryConfiguration configuration, - BuildContext context, -) => - _timelineScreenRoute(configuration, context); +Widget timeLineNavigatorUserStory({ + required BuildContext context, + TimelineUserStoryConfiguration? configuration, +}) { + var config = configuration ?? + TimelineUserStoryConfiguration( + userId: 'test_user', + service: TimelineService( + postService: LocalTimelinePostService(), + ), + optionsBuilder: (context) => const TimelineOptions(), + ); -Widget _timelineScreenRoute( - TimelineUserStoryConfiguration configuration, - BuildContext context, -) => - TimelineScreen( - service: configuration.service, - options: configuration.optionsBuilder(context), - userId: configuration.userId, - onPostTap: (post) async => - configuration.onPostTap?.call(context, post) ?? - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => - _postDetailScreenRoute(configuration, context, post), + return _timelineScreenRoute(configuration: config, context: context); +} + +Widget _timelineScreenRoute({ + required BuildContext context, + TimelineUserStoryConfiguration? configuration, +}) { + var config = configuration ?? + TimelineUserStoryConfiguration( + userId: 'test_user', + service: TimelineService( + postService: LocalTimelinePostService(), + ), + optionsBuilder: (context) => const TimelineOptions(), + ); + + return TimelineScreen( + service: config.service, + options: config.optionsBuilder(context), + userId: config.userId, + onPostTap: (post) async => + config.onPostTap?.call(context, post) ?? + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => _postDetailScreenRoute( + configuration: config, + context: context, + post: post, ), ), - onUserTap: (userId) { - configuration.onUserTap?.call(context, userId); - }, - filterEnabled: configuration.filterEnabled, - postWidgetBuilder: configuration.postWidgetBuilder, - ); + ), + onUserTap: (userId) { + config.onUserTap?.call(context, userId); + }, + filterEnabled: config.filterEnabled, + postWidgetBuilder: config.postWidgetBuilder, + ); +} -Widget _postDetailScreenRoute( - TimelineUserStoryConfiguration configuration, - BuildContext context, - TimelinePost post, -) => - TimelinePostScreen( - userId: configuration.userId, - service: configuration.service, - options: configuration.optionsBuilder(context), - post: post, - onPostDelete: () async { - configuration.onPostDelete?.call(context, post) ?? - await configuration.service.postService.deletePost(post); - }, - ); +Widget _postDetailScreenRoute({ + required BuildContext context, + required TimelinePost post, + TimelineUserStoryConfiguration? configuration, +}) { + var config = configuration ?? + TimelineUserStoryConfiguration( + userId: 'test_user', + service: TimelineService( + postService: LocalTimelinePostService(), + ), + optionsBuilder: (context) => const TimelineOptions(), + ); + + return TimelinePostScreen( + userId: config.userId, + service: config.service, + options: config.optionsBuilder(context), + post: post, + onPostDelete: () async { + config.onPostDelete?.call(context, post) ?? + await config.service.postService.deletePost(post); + }, + ); +} diff --git a/packages/flutter_timeline/lib/src/flutter_timeline_userstory.dart b/packages/flutter_timeline/lib/src/flutter_timeline_userstory.dart index 1609338..0023867 100644 --- a/packages/flutter_timeline/lib/src/flutter_timeline_userstory.dart +++ b/packages/flutter_timeline/lib/src/flutter_timeline_userstory.dart @@ -3,72 +3,79 @@ // SPDX-License-Identifier: BSD-3-Clause import 'package:flutter/material.dart'; +import 'package:flutter_timeline/flutter_timeline.dart'; import 'package:flutter_timeline/src/go_router.dart'; -import 'package:flutter_timeline/src/models/timeline_configuration.dart'; -import 'package:flutter_timeline/src/routes.dart'; -import 'package:flutter_timeline_view/flutter_timeline_view.dart'; import 'package:go_router/go_router.dart'; -List getTimelineStoryRoutes( - TimelineUserStoryConfiguration configuration, -) => - [ - GoRoute( - path: TimelineUserStoryRoutes.timelineHome, - pageBuilder: (context, state) { - var timelineScreen = TimelineScreen( - userId: configuration.userId, - onUserTap: (user) => configuration.onUserTap?.call(context, user), - service: configuration.service, - options: configuration.optionsBuilder(context), - onPostTap: (post) async => - configuration.onPostTap?.call(context, post) ?? - await context.push( - TimelineUserStoryRoutes.timelineViewPath(post.id), - ), - filterEnabled: configuration.filterEnabled, - postWidgetBuilder: configuration.postWidgetBuilder, - ); +List getTimelineStoryRoutes({ + TimelineUserStoryConfiguration? configuration, +}) { + var config = configuration ??= TimelineUserStoryConfiguration( + userId: 'test_user', + service: TimelineService( + postService: LocalTimelinePostService(), + ), + optionsBuilder: (context) => const TimelineOptions(), + ); - return buildScreenWithoutTransition( - context: context, - state: state, - child: configuration.openPageBuilder?.call( - context, - timelineScreen, - ) ?? - Scaffold( - body: timelineScreen, - ), - ); - }, - ), - GoRoute( - path: TimelineUserStoryRoutes.timelineView, - pageBuilder: (context, state) { - var post = configuration.service.postService - .getPost(state.pathParameters['post']!)!; + return [ + GoRoute( + path: TimelineUserStoryRoutes.timelineHome, + pageBuilder: (context, state) { + var timelineScreen = TimelineScreen( + userId: config.userId, + onUserTap: (user) => config.onUserTap?.call(context, user), + service: config.service, + options: config.optionsBuilder(context), + onPostTap: (post) async => + config.onPostTap?.call(context, post) ?? + await context.push( + TimelineUserStoryRoutes.timelineViewPath(post.id), + ), + filterEnabled: config.filterEnabled, + postWidgetBuilder: config.postWidgetBuilder, + ); - var timelinePostWidget = TimelinePostScreen( - userId: configuration.userId, - options: configuration.optionsBuilder(context), - service: configuration.service, - post: post, - onPostDelete: () => configuration.onPostDelete?.call(context, post), - onUserTap: (user) => configuration.onUserTap?.call(context, user), - ); + return buildScreenWithoutTransition( + context: context, + state: state, + child: config.openPageBuilder?.call( + context, + timelineScreen, + ) ?? + Scaffold( + body: timelineScreen, + ), + ); + }, + ), + GoRoute( + path: TimelineUserStoryRoutes.timelineView, + pageBuilder: (context, state) { + var post = + config.service.postService.getPost(state.pathParameters['post']!)!; - return buildScreenWithoutTransition( - context: context, - state: state, - child: configuration.openPageBuilder?.call( - context, - timelinePostWidget, - ) ?? - Scaffold( - body: timelinePostWidget, - ), - ); - }, - ), - ]; + var timelinePostWidget = TimelinePostScreen( + userId: config.userId, + options: config.optionsBuilder(context), + service: config.service, + post: post, + onPostDelete: () => config.onPostDelete?.call(context, post), + onUserTap: (user) => config.onUserTap?.call(context, user), + ); + + return buildScreenWithoutTransition( + context: context, + state: state, + child: config.openPageBuilder?.call( + context, + timelinePostWidget, + ) ?? + Scaffold( + body: timelinePostWidget, + ), + ); + }, + ), + ]; +} diff --git a/packages/flutter_timeline_firebase/lib/flutter_timeline_firebase.dart b/packages/flutter_timeline_firebase/lib/flutter_timeline_firebase.dart index 9ad1f86..84481b0 100644 --- a/packages/flutter_timeline_firebase/lib/flutter_timeline_firebase.dart +++ b/packages/flutter_timeline_firebase/lib/flutter_timeline_firebase.dart @@ -6,5 +6,5 @@ library flutter_timeline_firebase; export 'src/config/firebase_timeline_options.dart'; -export 'src/service/firebase_timeline_service.dart'; +export 'src/service/firebase_post_service.dart'; export 'src/service/firebase_user_service.dart'; diff --git a/packages/flutter_timeline_firebase/lib/src/service/firebase_post_service.dart b/packages/flutter_timeline_firebase/lib/src/service/firebase_post_service.dart new file mode 100644 index 0000000..8215e71 --- /dev/null +++ b/packages/flutter_timeline_firebase/lib/src/service/firebase_post_service.dart @@ -0,0 +1,352 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +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_firebase/src/models/firebase_user_document.dart'; +import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; +import 'package:uuid/uuid.dart'; + +class FirebaseTimelinePostService extends TimelinePostService + with TimelineUserService { + FirebaseTimelinePostService({ + required TimelineUserService userService, + FirebaseApp? app, + options = const FirebaseTimelineOptions(), + }) { + var appInstance = app ?? Firebase.app(); + _db = FirebaseFirestore.instanceFor(app: appInstance); + _storage = FirebaseStorage.instanceFor(app: appInstance); + _userService = userService; + _options = options; + } + + late FirebaseFirestore _db; + late FirebaseStorage _storage; + late TimelineUserService _userService; + late FirebaseTimelineOptions _options; + + final Map _users = {}; + + @override + Future createPost(TimelinePost post) async { + var postId = const Uuid().v4(); + var user = await _userService.getUser(post.creatorId); + var updatedPost = post.copyWith(id: postId, creator: user); + if (post.image != null) { + var imageRef = + _storage.ref().child('${_options.timelineCollectionName}/$postId'); + var result = await imageRef.putData(post.image!); + var imageUrl = await result.ref.getDownloadURL(); + updatedPost = updatedPost.copyWith(imageUrl: imageUrl); + } + var postRef = + _db.collection(_options.timelineCollectionName).doc(updatedPost.id); + 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); + await postRef.delete(); + notifyListeners(); + } + + @override + Future deletePostReaction( + TimelinePost post, + String reactionId, + ) async { + if (post.reactions != null && post.reactions!.isNotEmpty) { + var reaction = + post.reactions!.firstWhere((element) => element.id == reactionId); + var updatedPost = post.copyWith( + reaction: post.reaction - 1, + reactions: (post.reactions ?? [])..remove(reaction), + ); + posts = posts + .map( + (p) => p.id == post.id ? updatedPost : p, + ) + .toList(); + var postRef = + _db.collection(_options.timelineCollectionName).doc(post.id); + await postRef.update({ + 'reaction': FieldValue.increment(-1), + 'reactions': FieldValue.arrayRemove( + [reaction.toJsonWithMicroseconds()], + ), + }); + notifyListeners(); + return updatedPost; + } + return post; + } + + @override + Future fetchPostDetails(TimelinePost post) async { + 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(); + notifyListeners(); + return updatedPost; + } + + @override + Future> fetchPosts(String? category) async { + debugPrint('fetching posts from firebase with category: $category'); + var snapshot = (category != null) + ? await _db + .collection(_options.timelineCollectionName) + .where('category', isEqualTo: category) + .get() + : await _db.collection(_options.timelineCollectionName).get(); + + var posts = []; + for (var doc in snapshot.docs) { + var data = doc.data(); + var user = await _userService.getUser(data['creator_id']); + var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user); + posts.add(post); + } + + notifyListeners(); + return posts; + } + + @override + Future> fetchPostsPaginated( + String? category, + int limit, + ) async { + // only take posts that are in our category + var oldestPost = posts + .where( + (element) => category == null || element.category == category, + ) + .fold( + posts.first, + (previousValue, element) => + (previousValue.createdAt.isBefore(element.createdAt)) + ? previousValue + : element, + ); + var snapshot = (category != null) + ? await _db + .collection(_options.timelineCollectionName) + .where('category', isEqualTo: category) + .orderBy('created_at', descending: true) + .startAfter([oldestPost]) + .limit(limit) + .get() + : await _db + .collection(_options.timelineCollectionName) + .orderBy('created_at', descending: true) + .startAfter([oldestPost.createdAt]) + .limit(limit) + .get(); + // add the new posts to the list + var newPosts = []; + for (var doc in snapshot.docs) { + var data = doc.data(); + var user = await _userService.getUser(data['creator_id']); + var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user); + newPosts.add(post); + } + posts = [...posts, ...newPosts]; + notifyListeners(); + return newPosts; + } + + @override + Future fetchPost(TimelinePost post) async { + var doc = await _db + .collection(_options.timelineCollectionName) + .doc(post.id) + .get(); + var data = doc.data(); + if (data == null) return post; + var user = await _userService.getUser(data['creator_id']); + var updatedPost = TimelinePost.fromJson(doc.id, data).copyWith( + creator: user, + ); + posts = posts.map((p) => (p.id == post.id) ? updatedPost : p).toList(); + notifyListeners(); + return updatedPost; + } + + @override + Future> refreshPosts(String? category) async { + // fetch all posts between now and the newest posts we have + var newestPostWeHave = posts + .where( + (element) => category == null || element.category == category, + ) + .fold( + posts.first, + (previousValue, element) => + (previousValue.createdAt.isAfter(element.createdAt)) + ? previousValue + : element, + ); + var snapshot = (category != null) + ? await _db + .collection(_options.timelineCollectionName) + .where('category', isEqualTo: category) + .orderBy('created_at', descending: true) + .endBefore([newestPostWeHave.createdAt]).get() + : await _db + .collection(_options.timelineCollectionName) + .orderBy('created_at', descending: true) + .endBefore([newestPostWeHave.createdAt]).get(); + // add the new posts to the list + var newPosts = []; + for (var doc in snapshot.docs) { + var data = doc.data(); + var user = await _userService.getUser(data['creator_id']); + var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user); + newPosts.add(post); + } + posts = [...posts, ...newPosts]; + notifyListeners(); + return newPosts; + } + + @override + TimelinePost? getPost(String postId) => + (posts.any((element) => element.id == postId)) + ? posts.firstWhere((element) => element.id == postId) + : null; + + @override + List getPosts(String? category) => posts + .where((element) => category == null || element.category == category) + .toList(); + + @override + Future likePost(String userId, TimelinePost post) async { + // update the post with the new like + var updatedPost = post.copyWith( + likes: post.likes + 1, + likedBy: post.likedBy?..add(userId), + ); + posts = posts + .map( + (p) => p.id == post.id ? updatedPost : p, + ) + .toList(); + var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); + await postRef.update({ + 'likes': FieldValue.increment(1), + 'liked_by': FieldValue.arrayUnion([userId]), + }); + notifyListeners(); + return updatedPost; + } + + @override + Future unlikePost(String userId, TimelinePost post) async { + // update the post with the new like + var updatedPost = post.copyWith( + likes: post.likes - 1, + likedBy: post.likedBy?..remove(userId), + ); + posts = posts + .map( + (p) => p.id == post.id ? updatedPost : p, + ) + .toList(); + var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); + await postRef.update({ + 'likes': FieldValue.increment(-1), + 'liked_by': FieldValue.arrayRemove([userId]), + }); + notifyListeners(); + return updatedPost; + } + + @override + Future reactToPost( + TimelinePost post, + TimelinePostReaction reaction, { + Uint8List? image, + }) 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); + await postRef.update({ + 'reaction': FieldValue.increment(1), + 'reactions': FieldValue.arrayUnion([updatedReaction.toJson()]), + }); + posts = posts + .map( + (p) => p.id == post.id ? updatedPost : p, + ) + .toList(); + notifyListeners(); + return updatedPost; + } + + CollectionReference get _userCollection => _db + .collection(_options.usersCollectionName) + .withConverter( + fromFirestore: (snapshot, _) => FirebaseUserDocument.fromJson( + snapshot.data()!, + snapshot.id, + ), + toFirestore: (user, _) => user.toJson(), + ); + @override + Future getUser(String userId) async { + if (_users.containsKey(userId)) { + return _users[userId]!; + } + var data = (await _userCollection.doc(userId).get()).data(); + + var user = data == null + ? TimelinePosterUserModel(userId: userId) + : TimelinePosterUserModel( + userId: userId, + firstName: data.firstName, + lastName: data.lastName, + imageUrl: data.imageUrl, + ); + + _users[userId] = user; + + return user; + } +} 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 eef4c34..9fe9d61 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 @@ -1,352 +1,54 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -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_firebase/src/models/firebase_user_document.dart'; +import 'package:flutter_timeline_firebase/flutter_timeline_firebase.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; -import 'package:uuid/uuid.dart'; -class FirebaseTimelineService extends TimelinePostService - with TimelineUserService { +class FirebaseTimelineService implements TimelineService { FirebaseTimelineService({ - required TimelineUserService userService, - FirebaseApp? app, - options = const FirebaseTimelineOptions(), + this.options, + this.app, + this.firebasePostService, + this.firebaseUserService, }) { - var appInstance = app ?? Firebase.app(); - _db = FirebaseFirestore.instanceFor(app: appInstance); - _storage = FirebaseStorage.instanceFor(app: appInstance); - _userService = userService; - _options = options; + firebaseUserService ??= FirebaseTimelinePostService( + userService: userService, + options: options, + app: app, + ); + + firebasePostService ??= FirebaseTimelinePostService( + userService: userService, + options: options, + app: app, + ); } - late FirebaseFirestore _db; - late FirebaseStorage _storage; - late TimelineUserService _userService; - late FirebaseTimelineOptions _options; - - final Map _users = {}; + final FirebaseTimelineOptions? options; + final FirebaseApp? app; + TimelinePostService? firebasePostService; + TimelineUserService? firebaseUserService; @override - Future createPost(TimelinePost post) async { - var postId = const Uuid().v4(); - var user = await _userService.getUser(post.creatorId); - var updatedPost = post.copyWith(id: postId, creator: user); - if (post.image != null) { - var imageRef = - _storage.ref().child('${_options.timelineCollectionName}/$postId'); - var result = await imageRef.putData(post.image!); - var imageUrl = await result.ref.getDownloadURL(); - updatedPost = updatedPost.copyWith(imageUrl: imageUrl); - } - var postRef = - _db.collection(_options.timelineCollectionName).doc(updatedPost.id); - 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); - await postRef.delete(); - notifyListeners(); - } - - @override - Future deletePostReaction( - TimelinePost post, - String reactionId, - ) async { - if (post.reactions != null && post.reactions!.isNotEmpty) { - var reaction = - post.reactions!.firstWhere((element) => element.id == reactionId); - var updatedPost = post.copyWith( - reaction: post.reaction - 1, - reactions: (post.reactions ?? [])..remove(reaction), + TimelinePostService get postService { + if (firebasePostService != null) { + return firebasePostService!; + } else { + return FirebaseTimelinePostService( + userService: userService, + options: options, + app: app, ); - posts = posts - .map( - (p) => p.id == post.id ? updatedPost : p, - ) - .toList(); - var postRef = - _db.collection(_options.timelineCollectionName).doc(post.id); - await postRef.update({ - 'reaction': FieldValue.increment(-1), - 'reactions': FieldValue.arrayRemove( - [reaction.toJsonWithMicroseconds()], - ), - }); - notifyListeners(); - return updatedPost; } - return post; } @override - Future fetchPostDetails(TimelinePost post) async { - 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(); - notifyListeners(); - return updatedPost; - } - - @override - Future> fetchPosts(String? category) async { - debugPrint('fetching posts from firebase with category: $category'); - var snapshot = (category != null) - ? await _db - .collection(_options.timelineCollectionName) - .where('category', isEqualTo: category) - .get() - : await _db.collection(_options.timelineCollectionName).get(); - - var posts = []; - for (var doc in snapshot.docs) { - var data = doc.data(); - var user = await _userService.getUser(data['creator_id']); - var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user); - posts.add(post); - } - - notifyListeners(); - return posts; - } - - @override - Future> fetchPostsPaginated( - String? category, - int limit, - ) async { - // only take posts that are in our category - var oldestPost = posts - .where( - (element) => category == null || element.category == category, - ) - .fold( - posts.first, - (previousValue, element) => - (previousValue.createdAt.isBefore(element.createdAt)) - ? previousValue - : element, - ); - var snapshot = (category != null) - ? await _db - .collection(_options.timelineCollectionName) - .where('category', isEqualTo: category) - .orderBy('created_at', descending: true) - .startAfter([oldestPost]) - .limit(limit) - .get() - : await _db - .collection(_options.timelineCollectionName) - .orderBy('created_at', descending: true) - .startAfter([oldestPost.createdAt]) - .limit(limit) - .get(); - // add the new posts to the list - var newPosts = []; - for (var doc in snapshot.docs) { - var data = doc.data(); - var user = await _userService.getUser(data['creator_id']); - var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user); - newPosts.add(post); - } - posts = [...posts, ...newPosts]; - notifyListeners(); - return newPosts; - } - - @override - Future fetchPost(TimelinePost post) async { - var doc = await _db - .collection(_options.timelineCollectionName) - .doc(post.id) - .get(); - var data = doc.data(); - if (data == null) return post; - var user = await _userService.getUser(data['creator_id']); - var updatedPost = TimelinePost.fromJson(doc.id, data).copyWith( - creator: user, - ); - posts = posts.map((p) => (p.id == post.id) ? updatedPost : p).toList(); - notifyListeners(); - return updatedPost; - } - - @override - Future> refreshPosts(String? category) async { - // fetch all posts between now and the newest posts we have - var newestPostWeHave = posts - .where( - (element) => category == null || element.category == category, - ) - .fold( - posts.first, - (previousValue, element) => - (previousValue.createdAt.isAfter(element.createdAt)) - ? previousValue - : element, - ); - var snapshot = (category != null) - ? await _db - .collection(_options.timelineCollectionName) - .where('category', isEqualTo: category) - .orderBy('created_at', descending: true) - .endBefore([newestPostWeHave.createdAt]).get() - : await _db - .collection(_options.timelineCollectionName) - .orderBy('created_at', descending: true) - .endBefore([newestPostWeHave.createdAt]).get(); - // add the new posts to the list - var newPosts = []; - for (var doc in snapshot.docs) { - var data = doc.data(); - var user = await _userService.getUser(data['creator_id']); - var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user); - newPosts.add(post); - } - posts = [...posts, ...newPosts]; - notifyListeners(); - return newPosts; - } - - @override - TimelinePost? getPost(String postId) => - (posts.any((element) => element.id == postId)) - ? posts.firstWhere((element) => element.id == postId) - : null; - - @override - List getPosts(String? category) => posts - .where((element) => category == null || element.category == category) - .toList(); - - @override - Future likePost(String userId, TimelinePost post) async { - // update the post with the new like - var updatedPost = post.copyWith( - likes: post.likes + 1, - likedBy: post.likedBy?..add(userId), - ); - posts = posts - .map( - (p) => p.id == post.id ? updatedPost : p, - ) - .toList(); - var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); - await postRef.update({ - 'likes': FieldValue.increment(1), - 'liked_by': FieldValue.arrayUnion([userId]), - }); - notifyListeners(); - return updatedPost; - } - - @override - Future unlikePost(String userId, TimelinePost post) async { - // update the post with the new like - var updatedPost = post.copyWith( - likes: post.likes - 1, - likedBy: post.likedBy?..remove(userId), - ); - posts = posts - .map( - (p) => p.id == post.id ? updatedPost : p, - ) - .toList(); - var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); - await postRef.update({ - 'likes': FieldValue.increment(-1), - 'liked_by': FieldValue.arrayRemove([userId]), - }); - notifyListeners(); - return updatedPost; - } - - @override - Future reactToPost( - TimelinePost post, - TimelinePostReaction reaction, { - Uint8List? image, - }) 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); - await postRef.update({ - 'reaction': FieldValue.increment(1), - 'reactions': FieldValue.arrayUnion([updatedReaction.toJson()]), - }); - posts = posts - .map( - (p) => p.id == post.id ? updatedPost : p, - ) - .toList(); - notifyListeners(); - return updatedPost; - } - - CollectionReference get _userCollection => _db - .collection(_options.usersCollectionName) - .withConverter( - fromFirestore: (snapshot, _) => FirebaseUserDocument.fromJson( - snapshot.data()!, - snapshot.id, - ), - toFirestore: (user, _) => user.toJson(), + TimelineUserService get userService { + if (firebaseUserService != null) { + return firebaseUserService!; + } else { + return FirebaseUserService( + options: options, + app: app, ); - @override - Future getUser(String userId) async { - if (_users.containsKey(userId)) { - return _users[userId]!; } - var data = (await _userCollection.doc(userId).get()).data(); - - var user = data == null - ? TimelinePosterUserModel(userId: userId) - : TimelinePosterUserModel( - userId: userId, - firstName: data.firstName, - lastName: data.lastName, - imageUrl: data.imageUrl, - ); - - _users[userId] = user; - - return user; } }