From 4113e9fea27e46672962062da976673195a4a9f9 Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Mon, 20 Nov 2023 08:20:37 +0100 Subject: [PATCH] feat: add firebase implementation of timeline service --- .../lib/config/firebase_timeline_options.dart | 18 +++ .../lib/models/firebase_user_document.dart | 36 +++++ .../service/firebase_timeline_service.dart | 138 ++++++++++++++++++ .../lib/service/firebase_user_service.dart | 55 +++++++ .../lib/flutter_timeline_interface.dart | 3 + .../lib/src/model/timeline_post.dart | 81 ++++++++++ .../lib/src/model/timeline_poster.dart | 4 +- .../lib/src/model/timeline_reaction.dart | 23 +++ .../lib/src/services/timeline_service.dart | 7 +- .../lib/src/services/user_service.dart | 4 + 10 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 packages/flutter_timeline_firebase/lib/config/firebase_timeline_options.dart create mode 100644 packages/flutter_timeline_firebase/lib/models/firebase_user_document.dart create mode 100644 packages/flutter_timeline_firebase/lib/service/firebase_timeline_service.dart create mode 100644 packages/flutter_timeline_firebase/lib/service/firebase_user_service.dart diff --git a/packages/flutter_timeline_firebase/lib/config/firebase_timeline_options.dart b/packages/flutter_timeline_firebase/lib/config/firebase_timeline_options.dart new file mode 100644 index 0000000..6acd32a --- /dev/null +++ b/packages/flutter_timeline_firebase/lib/config/firebase_timeline_options.dart @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; + +@immutable +class FirebaseTimelineOptions { + const FirebaseTimelineOptions({ + this.usersCollectionName = 'users', + this.timelineCollectionName = 'timeline', + this.allTimelineCategories = const [], + }); + + final String usersCollectionName; + final String timelineCollectionName; + final List allTimelineCategories; +} diff --git a/packages/flutter_timeline_firebase/lib/models/firebase_user_document.dart b/packages/flutter_timeline_firebase/lib/models/firebase_user_document.dart new file mode 100644 index 0000000..19fe9c9 --- /dev/null +++ b/packages/flutter_timeline_firebase/lib/models/firebase_user_document.dart @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; + +@immutable +class FirebaseUserDocument { + const FirebaseUserDocument({ + this.firstName, + this.lastName, + this.imageUrl, + this.userId, + }); + + FirebaseUserDocument.fromJson( + Map json, + String userId, + ) : this( + userId: userId, + firstName: json['first_name'] as String?, + lastName: json['last_name'] as String?, + imageUrl: json['image_url'] as String?, + ); + + final String? firstName; + final String? lastName; + final String? imageUrl; + final String? userId; + + Map toJson() => { + 'first_name': firstName, + 'last_name': lastName, + 'image_url': imageUrl, + }; +} diff --git a/packages/flutter_timeline_firebase/lib/service/firebase_timeline_service.dart b/packages/flutter_timeline_firebase/lib/service/firebase_timeline_service.dart new file mode 100644 index 0000000..c59727d --- /dev/null +++ b/packages/flutter_timeline_firebase/lib/service/firebase_timeline_service.dart @@ -0,0 +1,138 @@ +// 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/config/firebase_timeline_options.dart'; +import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; + +class FirebaseTimelineService implements TimelineService { + FirebaseTimelineService({ + 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; + + List _posts = []; + + @override + Future createPost(TimelinePost post) async { + var imageRef = _storage.ref().child('timeline/${post.id}'); + var result = await imageRef.putData(post.image!); + var imageUrl = await result.ref.getDownloadURL(); + var updatedPost = post.copyWith(imageUrl: imageUrl); + var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); + _posts.add(updatedPost); + return postRef.set(updatedPost.toJson()); + } + + @override + Future deletePost(TimelinePost post) async { + var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); + return postRef.delete(); + } + + @override + Future fetchPostDetails(TimelinePost post) async { + debugPrint('fetchPostDetails'); + return post; + } + + @override + Future> fetchPosts(String? category) async { + var snapshot = await _db + .collection(_options.timelineCollectionName) + .where('category', isEqualTo: category) + .get(); + + var posts = []; + for (var doc in snapshot.docs) { + var data = doc.data(); + var user = await _userService.getUser(data['user_id']); + var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user); + posts.add(post); + } + _posts = posts; + return posts; + } + + @override + Future likePost(String userId, TimelinePost post) { + // update the post with the new like + _posts = _posts + .map( + (p) => (p.id == post.id) + ? p.copyWith( + likes: p.likes + 1, + likedBy: p.likedBy?..add(userId), + ) + : p, + ) + .toList(); + var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); + return postRef.update({ + 'likes': FieldValue.increment(1), + 'liked_by': FieldValue.arrayUnion([userId]), + }); + } + + @override + Future unlikePost(String userId, TimelinePost post) { + // update the post with the new like + _posts = _posts + .map( + (p) => (p.id == post.id) + ? p.copyWith( + likes: p.likes - 1, + likedBy: p.likedBy?..remove(userId), + ) + : p, + ) + .toList(); + var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); + return postRef.update({ + 'likes': FieldValue.increment(-1), + 'liked_by': FieldValue.arrayRemove([userId]), + }); + } + + @override + 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(); + var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); + return postRef.update({ + 'reaction': FieldValue.increment(1), + 'reactions': FieldValue.arrayUnion([reaction.toJson()]), + }); + } +} diff --git a/packages/flutter_timeline_firebase/lib/service/firebase_user_service.dart b/packages/flutter_timeline_firebase/lib/service/firebase_user_service.dart new file mode 100644 index 0000000..cb09c62 --- /dev/null +++ b/packages/flutter_timeline_firebase/lib/service/firebase_user_service.dart @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_timeline_firebase/config/firebase_timeline_options.dart'; +import 'package:flutter_timeline_firebase/models/firebase_user_document.dart'; +import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; + +class FirebaseUserService implements TimelineUserService { + FirebaseUserService({ + FirebaseApp? app, + options = const FirebaseTimelineOptions(), + }) { + var appInstance = app ?? Firebase.app(); + _db = FirebaseFirestore.instanceFor(app: appInstance); + _options = options; + } + + late FirebaseFirestore _db; + late FirebaseTimelineOptions _options; + + final Map _users = {}; + + 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_interface/lib/flutter_timeline_interface.dart b/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart index c52ef3a..6004105 100644 --- a/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart +++ b/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart @@ -7,3 +7,6 @@ library flutter_timeline_interface; export 'src/model/timeline_post.dart'; export 'src/model/timeline_poster.dart'; export 'src/model/timeline_reaction.dart'; + +export 'src/services/timeline_service.dart'; +export 'src/services/user_service.dart'; 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 d8cfe13..8fe1eb9 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: BSD-3-Clause +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:flutter_timeline_interface/src/model/timeline_poster.dart'; import 'package:flutter_timeline_interface/src/model/timeline_reaction.dart'; @@ -23,8 +25,37 @@ class TimelinePost { this.likedBy, this.reactions, this.imageUrl, + this.image, }); + factory TimelinePost.fromJson(String id, Map json) => + TimelinePost( + id: id, + creatorId: json['creator_id'] as String, + title: json['title'] as String, + category: json['category'] as String, + imageUrl: json['image_url'] as String?, + content: json['content'] as String, + likes: json['likes'] as int, + likedBy: (json['liked_by'] as List?)?.cast(), + reaction: json['reaction'] as int, + reactions: (json['reactions'] as Map?) + ?.map( + (key, value) => MapEntry( + key, + TimelinePostReaction.fromJson( + key, + id, + value as Map, + ), + ), + ) + .values + .toList(), + createdAt: DateTime.parse(json['created_at'] as String), + reactionEnabled: json['reaction_enabled'] as bool, + ); + /// The unique identifier of the post. final String id; @@ -43,6 +74,9 @@ class TimelinePost { /// The url of the image of the post. final String? imageUrl; + /// The image of the post used for uploading. + final Uint8List? image; + /// The content of the post. final String content; @@ -63,4 +97,51 @@ class TimelinePost { /// If reacting is enabled on the post. final bool reactionEnabled; + + TimelinePost copyWith({ + String? id, + String? creatorId, + TimelinePosterUserModel? creator, + String? title, + String? category, + String? imageUrl, + Uint8List? image, + String? content, + int? likes, + List? likedBy, + int? reaction, + List? reactions, + DateTime? createdAt, + bool? reactionEnabled, + }) => + TimelinePost( + id: id ?? this.id, + creatorId: creatorId ?? this.creatorId, + creator: creator ?? this.creator, + title: title ?? this.title, + category: category ?? this.category, + imageUrl: imageUrl ?? this.imageUrl, + image: image ?? this.image, + content: content ?? this.content, + likes: likes ?? this.likes, + likedBy: likedBy ?? this.likedBy, + reaction: reaction ?? this.reaction, + reactions: reactions ?? this.reactions, + createdAt: createdAt ?? this.createdAt, + reactionEnabled: reactionEnabled ?? this.reactionEnabled, + ); + + Map toJson() => { + 'creator_id': creatorId, + 'title': title, + 'category': category, + 'image_url': imageUrl, + 'content': content, + 'likes': likes, + 'liked_by': likedBy, + 'reaction': reaction, + '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_poster.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart index 534810a..c652508 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart @@ -7,13 +7,13 @@ import 'package:flutter/material.dart'; @immutable class TimelinePosterUserModel { const TimelinePosterUserModel({ - required this.id, + required this.userId, this.firstName, this.lastName, this.imageUrl, }); - final String id; + final String userId; final String? firstName; final String? lastName; final String? imageUrl; 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 a97951e..76b22ca 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart @@ -17,6 +17,20 @@ class TimelinePostReaction { this.creator, }); + factory TimelinePostReaction.fromJson( + String id, + String postId, + Map json, + ) => + TimelinePostReaction( + id: id, + postId: postId, + creatorId: json['creator_id'] as String, + reaction: json['reaction'] as String?, + imageUrl: json['image_url'] as String?, + createdAt: DateTime.parse(json['created_at'] as String), + ); + /// The unique identifier of the reaction. final String id; @@ -37,4 +51,13 @@ class TimelinePostReaction { /// Reaction creation date. final DateTime createdAt; + + Map toJson() => { + id: { + 'creator_id': creatorId, + 'reaction': reaction, + 'image_url': imageUrl, + 'created_at': createdAt.toIso8601String(), + }, + }; } 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 f8cfdca..87c459e 100644 --- a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart +++ b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + import 'dart:typed_data'; import 'package:flutter_timeline_interface/src/model/timeline_post.dart'; @@ -13,5 +17,6 @@ abstract class TimelineService { TimelinePostReaction reaction, { Uint8List image, }); - Future likePost(TimelinePost post); + Future likePost(String userId, TimelinePost post); + Future unlikePost(String userId, TimelinePost post); } diff --git a/packages/flutter_timeline_interface/lib/src/services/user_service.dart b/packages/flutter_timeline_interface/lib/src/services/user_service.dart index d2cd20b..0fbf1d4 100644 --- a/packages/flutter_timeline_interface/lib/src/services/user_service.dart +++ b/packages/flutter_timeline_interface/lib/src/services/user_service.dart @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + import 'package:flutter_timeline_interface/src/model/timeline_poster.dart'; mixin TimelineUserService {