From f119710bd24916fa416797c19b1843f1a2272e4c Mon Sep 17 00:00:00 2001 From: mike doornenbal Date: Wed, 9 Oct 2024 16:58:58 +0200 Subject: [PATCH] feat: new structure --- .../firebase_timeline_repository/.gitignore | 29 + .../analysis_options.yaml | 7 + .../lib/firebase_timeline_repository.dart | 7 + .../lib/src/firebase_category_repository.dart | 74 ++ .../lib/src/firebase_post_repository.dart | 176 +++++ .../lib/src/firebase_user_repository.dart | 48 ++ .../firebase_timeline_repository/pubspec.yaml | 28 + packages/flutter_timeline/.gitignore | 36 + .../flutter_timeline/analysis_options.yaml | 8 +- .../assets/Comment.svg | 0 .../assets/send.svg | 0 .../example/fonts/Avenir-Regular.ttf | Bin 0 -> 52492 bytes .../example/fonts/Merriweather-Regular.ttf | Bin 0 -> 149120 bytes .../example/lib/apps/navigator/app.dart | 45 -- .../example/lib/apps/widgets/app.dart | 80 -- .../lib/apps/widgets/screens/post_screen.dart | 56 -- .../example/lib/config/config.dart | 101 --- .../flutter_timeline/example/lib/main.dart | 127 +++- .../flutter_timeline/example/lib/theme.dart | 147 ++++ .../flutter_timeline/example/pubspec.yaml | 92 +-- .../example/test/widget_test.dart | 29 - .../lib/flutter_timeline.dart | 37 +- .../flutter_timeline_navigator_userstory.dart | 454 +++--------- .../src/models/timeline_configuration.dart | 165 ----- .../lib/src/models/timeline_options.dart | 179 +++++ .../lib/src/models/timeline_translations.dart | 58 ++ packages/flutter_timeline/lib/src/routes.dart | 16 - .../timeline_add_post_information_screen.dart | 209 ++++++ .../timeline_choose_category_screen.dart | 199 +++++ .../screens/timeline_post_detail_screen.dart | 111 +++ .../src/screens/timeline_post_overview.dart | 78 ++ .../lib/src/screens/timeline_screen.dart | 112 +++ .../lib/src/widgets/category_list.dart | 57 ++ .../lib/src/widgets/category_widget.dart | 134 ++++ .../lib/src/widgets/comment_section.dart | 118 +++ .../lib/src/widgets/image_picker.dart | 103 +++ .../lib/src/widgets/post_info_textfield.dart} | 7 +- .../lib/src/widgets/post_list.dart | 53 ++ .../src/widgets/post_more_options_widget.dart | 34 + .../lib/src/widgets/reaction_textfield.dart | 83 +++ .../lib/src/widgets/tappable_image.dart | 35 +- .../lib/src/widgets/timeline_post.dart | 254 +++++++ packages/flutter_timeline/pubspec.yaml | 35 +- .../flutter_timeline_firebase/CHANGELOG.md | 1 - packages/flutter_timeline_firebase/LICENSE | 1 - packages/flutter_timeline_firebase/README.md | 1 - .../analysis_options.yaml | 13 - .../lib/flutter_timeline_firebase.dart | 11 - .../src/config/firebase_timeline_options.dart | 18 - .../src/models/firebase_user_document.dart | 36 - .../src/service/firebase_post_service.dart | 495 ------------- .../service/firebase_timeline_service.dart | 53 -- .../src/service/firebase_user_service.dart | 55 -- .../flutter_timeline_firebase/pubspec.yaml | 32 - .../flutter_timeline_interface/CHANGELOG.md | 1 - packages/flutter_timeline_interface/LICENSE | 1 - packages/flutter_timeline_interface/README.md | 1 - .../analysis_options.yaml | 13 - .../lib/flutter_timeline_interface.dart | 14 - .../lib/src/model/timeline_poster.dart | 51 -- .../lib/src/services/filter_service.dart | 22 - .../src/services/timeline_post_service.dart | 45 -- .../lib/src/services/timeline_service.dart | 12 - .../lib/src/services/user_service.dart | 9 - .../flutter_timeline_interface/pubspec.yaml | 25 - packages/flutter_timeline_view/CHANGELOG.md | 1 - packages/flutter_timeline_view/LICENSE | 1 - packages/flutter_timeline_view/README.md | 1 - .../analysis_options.yaml | 13 - .../lib/flutter_timeline_view.dart | 20 - .../lib/src/config/timeline_options.dart | 244 ------ .../lib/src/config/timeline_paddings.dart | 25 - .../lib/src/config/timeline_styles.dart | 76 -- .../lib/src/config/timeline_theme.dart | 65 -- .../lib/src/config/timeline_translations.dart | 263 ------- .../timeline_post_creation_screen.dart | 395 ---------- .../timeline_post_overview_screen.dart | 78 -- .../lib/src/screens/timeline_post_screen.dart | 695 ------------------ .../lib/src/screens/timeline_screen.dart | 343 --------- .../screens/timeline_selection_screen.dart | 215 ------ .../lib/src/services/local_post_service.dart | 332 --------- .../lib/src/widgets/category_selector.dart | 67 -- .../src/widgets/category_selector_button.dart | 143 ---- .../src/widgets/default_filled_button.dart | 35 - .../lib/src/widgets/reaction_bottom.dart | 58 -- .../lib/src/widgets/timeline_post_widget.dart | 444 ----------- packages/flutter_timeline_view/pubspec.yaml | 37 - .../timeline_repository_interface/.gitignore | 29 + .../analysis_options.yaml | 7 + .../category_repository_interface.dart | 10 + .../interfaces/post_repository_interface.dart | 30 + .../timeline_user_repository_interface.dart | 7 + .../src/local/local_category_repository.dart | 37 + .../lib/src/local/local_post_repository.dart | 187 +++++ .../local/local_timeline_user_repository.dart | 37 + .../lib/src/models}/timeline_category.dart | 27 +- .../lib/src/models}/timeline_post.dart | 64 +- .../src/models/timeline_post_reaction.dart} | 44 +- .../lib/src/models/timeline_user.dart | 44 ++ .../lib/src/services/timeline_service.dart | 74 ++ .../lib/timeline_repository_interface.dart | 21 + .../pubspec.yaml | 19 + 102 files changed, 3197 insertions(+), 5522 deletions(-) create mode 100644 packages/firebase_timeline_repository/.gitignore create mode 100644 packages/firebase_timeline_repository/analysis_options.yaml create mode 100644 packages/firebase_timeline_repository/lib/firebase_timeline_repository.dart create mode 100644 packages/firebase_timeline_repository/lib/src/firebase_category_repository.dart create mode 100644 packages/firebase_timeline_repository/lib/src/firebase_post_repository.dart create mode 100644 packages/firebase_timeline_repository/lib/src/firebase_user_repository.dart create mode 100644 packages/firebase_timeline_repository/pubspec.yaml create mode 100644 packages/flutter_timeline/.gitignore rename packages/{flutter_timeline_view => flutter_timeline}/assets/Comment.svg (100%) rename packages/{flutter_timeline_view => flutter_timeline}/assets/send.svg (100%) create mode 100644 packages/flutter_timeline/example/fonts/Avenir-Regular.ttf create mode 100644 packages/flutter_timeline/example/fonts/Merriweather-Regular.ttf delete mode 100644 packages/flutter_timeline/example/lib/apps/navigator/app.dart delete mode 100644 packages/flutter_timeline/example/lib/apps/widgets/app.dart delete mode 100644 packages/flutter_timeline/example/lib/apps/widgets/screens/post_screen.dart delete mode 100644 packages/flutter_timeline/example/lib/config/config.dart create mode 100644 packages/flutter_timeline/example/lib/theme.dart delete mode 100644 packages/flutter_timeline/example/test/widget_test.dart delete mode 100644 packages/flutter_timeline/lib/src/models/timeline_configuration.dart create mode 100644 packages/flutter_timeline/lib/src/models/timeline_options.dart create mode 100644 packages/flutter_timeline/lib/src/models/timeline_translations.dart delete mode 100644 packages/flutter_timeline/lib/src/routes.dart create mode 100644 packages/flutter_timeline/lib/src/screens/timeline_add_post_information_screen.dart create mode 100644 packages/flutter_timeline/lib/src/screens/timeline_choose_category_screen.dart create mode 100644 packages/flutter_timeline/lib/src/screens/timeline_post_detail_screen.dart create mode 100644 packages/flutter_timeline/lib/src/screens/timeline_post_overview.dart create mode 100644 packages/flutter_timeline/lib/src/screens/timeline_screen.dart create mode 100644 packages/flutter_timeline/lib/src/widgets/category_list.dart create mode 100644 packages/flutter_timeline/lib/src/widgets/category_widget.dart create mode 100644 packages/flutter_timeline/lib/src/widgets/comment_section.dart create mode 100644 packages/flutter_timeline/lib/src/widgets/image_picker.dart rename packages/{flutter_timeline_view/lib/src/widgets/post_creation_textfield.dart => flutter_timeline/lib/src/widgets/post_info_textfield.dart} (92%) create mode 100644 packages/flutter_timeline/lib/src/widgets/post_list.dart create mode 100644 packages/flutter_timeline/lib/src/widgets/post_more_options_widget.dart create mode 100644 packages/flutter_timeline/lib/src/widgets/reaction_textfield.dart rename packages/{flutter_timeline_view => flutter_timeline}/lib/src/widgets/tappable_image.dart (84%) create mode 100644 packages/flutter_timeline/lib/src/widgets/timeline_post.dart delete mode 120000 packages/flutter_timeline_firebase/CHANGELOG.md delete mode 120000 packages/flutter_timeline_firebase/LICENSE delete mode 120000 packages/flutter_timeline_firebase/README.md delete mode 100644 packages/flutter_timeline_firebase/analysis_options.yaml delete mode 100644 packages/flutter_timeline_firebase/lib/flutter_timeline_firebase.dart delete mode 100644 packages/flutter_timeline_firebase/lib/src/config/firebase_timeline_options.dart delete mode 100644 packages/flutter_timeline_firebase/lib/src/models/firebase_user_document.dart delete mode 100644 packages/flutter_timeline_firebase/lib/src/service/firebase_post_service.dart delete mode 100644 packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart delete mode 100644 packages/flutter_timeline_firebase/lib/src/service/firebase_user_service.dart delete mode 100644 packages/flutter_timeline_firebase/pubspec.yaml delete mode 120000 packages/flutter_timeline_interface/CHANGELOG.md delete mode 120000 packages/flutter_timeline_interface/LICENSE delete mode 120000 packages/flutter_timeline_interface/README.md delete mode 100644 packages/flutter_timeline_interface/analysis_options.yaml delete mode 100644 packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart delete mode 100644 packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart delete mode 100644 packages/flutter_timeline_interface/lib/src/services/filter_service.dart delete mode 100644 packages/flutter_timeline_interface/lib/src/services/timeline_post_service.dart delete mode 100644 packages/flutter_timeline_interface/lib/src/services/timeline_service.dart delete mode 100644 packages/flutter_timeline_interface/lib/src/services/user_service.dart delete mode 100644 packages/flutter_timeline_interface/pubspec.yaml delete mode 120000 packages/flutter_timeline_view/CHANGELOG.md delete mode 120000 packages/flutter_timeline_view/LICENSE delete mode 120000 packages/flutter_timeline_view/README.md delete mode 100644 packages/flutter_timeline_view/analysis_options.yaml delete mode 100644 packages/flutter_timeline_view/lib/flutter_timeline_view.dart delete mode 100644 packages/flutter_timeline_view/lib/src/config/timeline_options.dart delete mode 100644 packages/flutter_timeline_view/lib/src/config/timeline_paddings.dart delete mode 100644 packages/flutter_timeline_view/lib/src/config/timeline_styles.dart delete mode 100644 packages/flutter_timeline_view/lib/src/config/timeline_theme.dart delete mode 100644 packages/flutter_timeline_view/lib/src/config/timeline_translations.dart delete mode 100644 packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart delete mode 100644 packages/flutter_timeline_view/lib/src/screens/timeline_post_overview_screen.dart delete mode 100644 packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart delete mode 100644 packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart delete mode 100644 packages/flutter_timeline_view/lib/src/screens/timeline_selection_screen.dart delete mode 100644 packages/flutter_timeline_view/lib/src/services/local_post_service.dart delete mode 100644 packages/flutter_timeline_view/lib/src/widgets/category_selector.dart delete mode 100644 packages/flutter_timeline_view/lib/src/widgets/category_selector_button.dart delete mode 100644 packages/flutter_timeline_view/lib/src/widgets/default_filled_button.dart delete mode 100644 packages/flutter_timeline_view/lib/src/widgets/reaction_bottom.dart delete mode 100644 packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart delete mode 100644 packages/flutter_timeline_view/pubspec.yaml create mode 100644 packages/timeline_repository_interface/.gitignore create mode 100644 packages/timeline_repository_interface/analysis_options.yaml create mode 100644 packages/timeline_repository_interface/lib/src/interfaces/category_repository_interface.dart create mode 100644 packages/timeline_repository_interface/lib/src/interfaces/post_repository_interface.dart create mode 100644 packages/timeline_repository_interface/lib/src/interfaces/timeline_user_repository_interface.dart create mode 100644 packages/timeline_repository_interface/lib/src/local/local_category_repository.dart create mode 100644 packages/timeline_repository_interface/lib/src/local/local_post_repository.dart create mode 100644 packages/timeline_repository_interface/lib/src/local/local_timeline_user_repository.dart rename packages/{flutter_timeline_interface/lib/src/model => timeline_repository_interface/lib/src/models}/timeline_category.dart (61%) rename packages/{flutter_timeline_interface/lib/src/model => timeline_repository_interface/lib/src/models}/timeline_post.dart (69%) rename packages/{flutter_timeline_interface/lib/src/model/timeline_reaction.dart => timeline_repository_interface/lib/src/models/timeline_post_reaction.dart} (65%) create mode 100644 packages/timeline_repository_interface/lib/src/models/timeline_user.dart create mode 100644 packages/timeline_repository_interface/lib/src/services/timeline_service.dart create mode 100644 packages/timeline_repository_interface/lib/timeline_repository_interface.dart create mode 100644 packages/timeline_repository_interface/pubspec.yaml diff --git a/packages/firebase_timeline_repository/.gitignore b/packages/firebase_timeline_repository/.gitignore new file mode 100644 index 0000000..ac5aa98 --- /dev/null +++ b/packages/firebase_timeline_repository/.gitignore @@ -0,0 +1,29 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +build/ diff --git a/packages/firebase_timeline_repository/analysis_options.yaml b/packages/firebase_timeline_repository/analysis_options.yaml new file mode 100644 index 0000000..2a97d5c --- /dev/null +++ b/packages/firebase_timeline_repository/analysis_options.yaml @@ -0,0 +1,7 @@ +include: package:flutter_iconica_analysis/analysis_options.yaml + +analyzer: + exclude: + +linter: + rules: diff --git a/packages/firebase_timeline_repository/lib/firebase_timeline_repository.dart b/packages/firebase_timeline_repository/lib/firebase_timeline_repository.dart new file mode 100644 index 0000000..378206d --- /dev/null +++ b/packages/firebase_timeline_repository/lib/firebase_timeline_repository.dart @@ -0,0 +1,7 @@ +/// firebase_timeline_repository is a library for Firebase repositories. +library firebase_timeline_repository; + +/// Firebase repositories +export "src/firebase_category_repository.dart"; +export "src/firebase_post_repository.dart"; +export "src/firebase_user_repository.dart"; diff --git a/packages/firebase_timeline_repository/lib/src/firebase_category_repository.dart b/packages/firebase_timeline_repository/lib/src/firebase_category_repository.dart new file mode 100644 index 0000000..b2cdabf --- /dev/null +++ b/packages/firebase_timeline_repository/lib/src/firebase_category_repository.dart @@ -0,0 +1,74 @@ +import "package:cloud_firestore/cloud_firestore.dart"; +import "package:collection/collection.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.dart"; + +class FirebaseCategoryRepository implements CategoryRepositoryInterface { + final CollectionReference categoryCollection = + FirebaseFirestore.instance.collection("timeline_categories"); + + final List _categories = []; + TimelineCategory? _selectedCategory; + + @override + Future createCategory(TimelineCategory category) async { + await categoryCollection.add(category.toJson()); + } + + @override + Stream> getCategories() { + var currentlySelected = _selectedCategory; + + return categoryCollection + .snapshots() + .map( + (snapshot) => snapshot.docs + .map( + (doc) => TimelineCategory.fromJson( + doc.data()! as Map, + ), + ) + .toList(), + ) + .map((categories) { + // Ensure that selected category is preserved during re-fetching + + // Modify _categories without resetting selected category + if (_categories.isEmpty) { + _categories.add( + const TimelineCategory( + key: null, + title: "All", + ), + ); + _categories.addAll(categories); + } else { + // Replace or update categories in the list while keeping the "All" category intact + _categories + ..clear() + ..add(const TimelineCategory(key: null, title: "All")) + ..addAll(categories); + _selectedCategory = _categories.firstWhereOrNull( + (category) => category.key == currentlySelected?.key, + ); + } + return _categories; + }); + } + + @override + TimelineCategory? getCategory(String? categoryId) => + _categories.firstWhereOrNull( + (category) => category.key == categoryId, + ); + + @override + TimelineCategory? getSelectedCategory() => _selectedCategory; + + @override + TimelineCategory? selectCategory(String? categoryId) { + _selectedCategory = _categories.firstWhereOrNull( + (category) => category.key == categoryId, + ); + return _selectedCategory; + } +} diff --git a/packages/firebase_timeline_repository/lib/src/firebase_post_repository.dart b/packages/firebase_timeline_repository/lib/src/firebase_post_repository.dart new file mode 100644 index 0000000..7a86cd7 --- /dev/null +++ b/packages/firebase_timeline_repository/lib/src/firebase_post_repository.dart @@ -0,0 +1,176 @@ +import "dart:async"; +import "dart:typed_data"; + +import "package:cloud_firestore/cloud_firestore.dart"; +import "package:firebase_storage/firebase_storage.dart"; +import "package:firebase_timeline_repository/firebase_timeline_repository.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.dart"; + +class FirebasePostRepository implements PostRepositoryInterface { + FirebasePostRepository({ + this.userService, + }) { + userService ??= FirebaseUserRepository(); + } + + TimelineUserRepositoryInterface? userService; + + final CollectionReference postCollection = + FirebaseFirestore.instance.collection("timeline"); + + late TimelinePost? _currentPost; + + final List _posts = []; + + @override + Future createPost(TimelinePost post) async { + var updatedPost = post; + if (post.image != null) { + // add image upload logic here + var imageRef = FirebaseStorage.instance.ref().child("timeline/$post.id"); + var result = await imageRef.putData(post.image!); + var imageUrl = await result.ref.getDownloadURL(); + updatedPost = post.copyWith( + imageUrl: imageUrl, + ); + } + _posts.add(updatedPost); + + await postCollection.add(updatedPost.toJson()); + } + + @override + Future createReaction( + TimelinePost post, + TimelinePostReaction reaction, { + Uint8List? image, + }) async { + var user = await userService!.getCurrentUser(); + var currentReaction = reaction.copyWith( + creatorId: user.userId, + creator: user, + ); + var updatedPost = post.copyWith( + reaction: post.reaction + 1, + reactions: post.reactions + ?..add( + currentReaction, + ), + ); + await postCollection.doc(post.id).update(updatedPost.toJson()); + _posts[_posts.indexWhere((element) => element.id == post.id)] = updatedPost; + } + + @override + Future deletePost(String id) async { + await postCollection.doc(id).delete(); + } + + @override + TimelinePost getCurrentPost() => _currentPost!; + + @override + Stream> getPosts(String? categoryId) => postCollection + .where("category", isEqualTo: categoryId) + .snapshots() + .asyncMap((snapshot) async { + // Fetch posts and their associated users + var posts = await Future.wait( + snapshot.docs.map((doc) async { + // Get user who created the post + var postData = doc.data()! as Map; + var user = await userService!.getUser(postData["creator_id"]); + + // Create post from document data + var post = TimelinePost.fromJson(doc.id, postData); + + // Update reactions with user details + if (post.reactions != null) { + post = post.copyWith( + reactions: await Future.wait( + post.reactions!.map((reaction) async { + var reactionUser = + await userService!.getUser(reaction.creatorId); + return reaction.copyWith(creator: reactionUser); + }).toList(), + ), + ); + } + + // Return post with creator information + return post.copyWith(creator: user); + }).toList(), + ); + + // Update internal post list + _posts + ..clear() + ..addAll(posts); + + return _posts; + }); + + @override + Future likePost(String postId, String userId) async { + var post = await postCollection.doc(postId).get(); + var updatedPost = + TimelinePost.fromJson(post.id, post.data()! as Map); + updatedPost = updatedPost.copyWith( + likes: updatedPost.likes + 1, + likedBy: updatedPost.likedBy?..add(userId), + ); + await postCollection.doc(postId).update(updatedPost.toJson()); + } + + @override + Future likePostReaction( + TimelinePost post, + TimelinePostReaction reaction, + String userId, + ) async { + var updatedPost = post.copyWith( + reaction: post.reaction + 1, + reactions: post.reactions + ?..[post.reactions! + .indexWhere((element) => element.id == reaction.id)] = + reaction.copyWith( + likedBy: reaction.likedBy?..add(userId), + ), + ); + await postCollection.doc(post.id).update(updatedPost.toJson()); + } + + @override + void setCurrentPost(TimelinePost post) { + _currentPost = post; + } + + @override + Future unlikePost(String postId, String userId) async { + var updatedPost = _posts.firstWhere((element) => element.id == postId); + updatedPost = updatedPost.copyWith( + likes: updatedPost.likes - 1, + likedBy: updatedPost.likedBy?..remove(userId), + ); + await postCollection.doc(postId).update(updatedPost.toJson()); + } + + @override + Future unlikePostReaction( + TimelinePost post, + TimelinePostReaction reaction, + String userId, + ) async { + var updatedPost = post.copyWith( + reaction: post.reaction - 1, + reactions: post.reactions + ?..[post.reactions! + .indexWhere((element) => element.id == reaction.id)] = + reaction.copyWith( + likedBy: reaction.likedBy?..remove(userId), + ), + ); + + await postCollection.doc(post.id).update(updatedPost.toJson()); + } +} diff --git a/packages/firebase_timeline_repository/lib/src/firebase_user_repository.dart b/packages/firebase_timeline_repository/lib/src/firebase_user_repository.dart new file mode 100644 index 0000000..d503743 --- /dev/null +++ b/packages/firebase_timeline_repository/lib/src/firebase_user_repository.dart @@ -0,0 +1,48 @@ +import "package:firebase_auth/firebase_auth.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.dart"; +import "package:cloud_firestore/cloud_firestore.dart"; + +class FirebaseUserRepository implements TimelineUserRepositoryInterface { + final CollectionReference usersCollection = + FirebaseFirestore.instance.collection("users"); + + @override + Future> getAllUsers() async { + var users = await usersCollection + .withConverter( + fromFirestore: (snapshot, _) => + TimelineUser.fromJson(snapshot.data()!, snapshot.id), + toFirestore: (user, _) => user.toJson(), + ) + .get(); + return users.docs.map((e) => e.data()).toList(); + } + + @override + Future getCurrentUser() async { + var authUser = FirebaseAuth.instance.currentUser; + var user = await usersCollection + .doc(authUser!.uid) + .withConverter( + fromFirestore: (snapshot, _) => + TimelineUser.fromJson(snapshot.data()!, snapshot.id), + toFirestore: (user, _) => user.toJson(), + ) + .get(); + return user.data()!; + } + + @override + Future getUser(String userId) async { + var userDoc = await usersCollection + .doc(userId) + .withConverter( + fromFirestore: (snapshot, _) => + TimelineUser.fromJson(snapshot.data()!, snapshot.id), + toFirestore: (user, _) => user.toJson(), + ) + .get(); + // print(userDoc.data()?.firstName); + return userDoc.data(); + } +} diff --git a/packages/firebase_timeline_repository/pubspec.yaml b/packages/firebase_timeline_repository/pubspec.yaml new file mode 100644 index 0000000..9e9db24 --- /dev/null +++ b/packages/firebase_timeline_repository/pubspec.yaml @@ -0,0 +1,28 @@ +name: firebase_timeline_repository +description: "A new Flutter package project." +version: 6.0.0 +publish_to: none + +environment: + sdk: ^3.5.1 + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + timeline_repository_interface: + git: + url: https://github.com/Iconica-Development/flutter_timeline + path: packages/timeline_repository_interface + ref: 6.0.0 + + rxdart: any + cloud_firestore: ^5.4.4 + firebase_auth: ^5.3.1 + firebase_storage: ^12.3.2 + +dev_dependencies: + flutter_iconica_analysis: + git: + url: https://github.com/Iconica-Development/flutter_iconica_analysis + ref: 7.0.0 diff --git a/packages/flutter_timeline/.gitignore b/packages/flutter_timeline/.gitignore new file mode 100644 index 0000000..299982b --- /dev/null +++ b/packages/flutter_timeline/.gitignore @@ -0,0 +1,36 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +build/ + +ios +android +web +macos +windows +linux diff --git a/packages/flutter_timeline/analysis_options.yaml b/packages/flutter_timeline/analysis_options.yaml index 3e96d28..2a97d5c 100644 --- a/packages/flutter_timeline/analysis_options.yaml +++ b/packages/flutter_timeline/analysis_options.yaml @@ -1,13 +1,7 @@ -# SPDX-FileCopyrightText: 2023 Iconica -# -# SPDX-License-Identifier: GPL-3.0-or-later - include: package:flutter_iconica_analysis/analysis_options.yaml -# Possible to overwrite the rules from the package - analyzer: exclude: - + linter: rules: diff --git a/packages/flutter_timeline_view/assets/Comment.svg b/packages/flutter_timeline/assets/Comment.svg similarity index 100% rename from packages/flutter_timeline_view/assets/Comment.svg rename to packages/flutter_timeline/assets/Comment.svg diff --git a/packages/flutter_timeline_view/assets/send.svg b/packages/flutter_timeline/assets/send.svg similarity index 100% rename from packages/flutter_timeline_view/assets/send.svg rename to packages/flutter_timeline/assets/send.svg diff --git a/packages/flutter_timeline/example/fonts/Avenir-Regular.ttf b/packages/flutter_timeline/example/fonts/Avenir-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..7463844432caf7572fb580c5398250e5e3a3e029 GIT binary patch literal 52492 zcmb5W2S5}_&?wxqyE6;3uqx|t%DTIVf*Hv{K_z1X6;#AXk|-dcVmNa~%x5|SMlk1$ z28Z9x>o9j5SxUMdE=ynn0T&#RWGP} z2*D!>3e20B!Q50+$hS@!lassfQ}?HkFM#PUjfu(4lmzyJD#{D~FzGR45;g_vq7m8+ zFj$plW@P6yY(g#o-xa90&LGL>6tLx5Lxd^PnG#V zR*EEiDf~bmnNPhdLi9wazerCN`-zqOUx^N(en=7sNtwirT8d~WTPTTwd~Y$PMj#g| z6iOAeR3d?YvgydS&2(r0KL9O4eZ#}TQ4fUb8^pO}K2*6aLVkQ5bz5=)B3snx8%Wkr zDu!^09IcXcL|O>#rGCgkatrxDT_OZe2;Wh~NGDl`LLu!&^+g>a4yUq^6xusLh=Je& z!5%^@2%RCcgWv?gjZX{hp)Wti*FZf%YN;*iC@DpZWIT$3xU=LrthPU-JEABl3;A0p zijqNi0&_td>Jyab33X9Yf2fDHLL4XbagcOF3K(agB(4EV&-3Fpe31-jz)-HCq^JcC zGsOM?Lq8Y`q4y2Hz}h=Y;!rTm!N)x8ovE(K!j$fX-lOi&?h&--)BUB9C>YuVO9qJj z_;%DN=)VhDLu>(cyF?Um<*e@mau=fCC;6{|V=jrwN}9k}U#`2MYM%al{86HzxcDc;IpI zuK?@qEZ~M;uei70Vjdq5Tme51Ak^^qggp9&AHL6D!JbdU{Y6!W}5FjAu+{4cR=A8ISBgY``Vm_Z``J5PI{wsZ>A--Y_EAc_8! zm-d6Y*$@UmeQOB+$^%`rl>|fR4eNRg>-tw-))?k@hx!0v|Iz;|FNOZnZV*iI*M36T zI2fln%ySRmh=4X5|EE0A|GkC}l4VeL1n}h#?G-ThT*zO50C`D!2z>n&sQxbVt_h+A5Ae&Pe6lT z{+WmQ{$2kV*|mo(TJq41AM}ydl^^(4oSHXpj*t zA+&+e9Mu88Q~^J>K<6O*0%0%&2M7@moJ_$CVm|ml_;)*JDC+TdDV$3n)A^ItbVm6AjSl`ApdEjv`GCyqB7`mw^I-(w+!6JmTA%>xCY(vV z;Os(xUkI`ZfN%aJ{|?54vl0P~Ak?r6!U_mWARL0Q3Bp2CnlA?qgnk`S0E8BBro)*4 zGQA_>APk4#4S^pooZk=Ac{t1z4}kb<;LpkszFca7TtP!f5M&=O1204QFw~rS(_nzH zdQtC@PWl;HQP+_z$kdUN@o?5g!5(%*UQ%1sLI{sg56~;Tg)`_AoWtT--A;M~_NE2$ z0NMT4B+Gd@{t(g@5S!#W@`v;*QyVL&;~;(WpY8wA7S4fh?ZvjJVRAzy{4Oy4PH#wT zcn!b%8`ePB-!Q6SIsEQ#SO&j~8pcCd)UZ$Zd%7V-h$9>3!Y^MA8YTQl#UN?R>sRp8 z0!knSc}BwW1&|SI_{_h5V1xfZ{78`ubTJ0WLB}#jEK&egERhm5LXA-qWQA0yDc}Nh zB-9M}$p)zb3JuZ%ui3(RZ--g|huI?s)CRRh?NEEv0d+*3kR$4hoRBkUmaf3l?tpzy zILW->)ar_SkT2>6I!6!S4}TPZ0#OhOMjqA;MHesJ~ue=n(Z5?M3^jcW4pXNFAmQQLm}rsh_FesP|MURYo18Do_GS zM3c~DR6rf4PEf_v8LEUjOTC~BXeXLYO`{%C=cw~&H`*hSQb(v$)M=2caVQPNqA@5P zWuQzn7I<+S8V@VXMbpr9G!w0mC?r;rMiMiLrKE|3l_({RCFW2sV_pCmSOFo(VZRXU z-c-~;IaB^r1QkQ&Qd6nrlFO2xBtJ`6N_R?+NQj_JY-VxpL2W-K$2 znaa#%<}=HfHOyva8?%$y&zxdPnDfkarkbhKZnTx!vbK$Eo7gtBwXxOO+SxkTI@&tf zcCih#-D7*IrK+V}ONW+@b`m?ru92OU9cO1_r?u-~7iu@kF2Vk){q;7zh=lYfLr5|i zL%t^qNFK>2%g7I86WK}jlKtcuIZ1vc4PUx{`S|5?{mlB=^>gdz*DtDHT)(`2W&N7^ z_4Nhy+vaW+|sJ~r*ul`|uZT++Q*Y$7f-`5-K z8yXq_BRo!QVK1C1KWYFq3UKm0;N%kEmz{w~k<$uLV8Q|m=^B!;_u{F1~ z0Gz07o7uLowFR8C2b_2dI63(*oHPNPH2;Q^coR-2z{y~eNK(mU6HbaW&U)ZePVQ-8ny zQT>zp7XnT`3OJz}{%Cj#k~~(x4r%}k6gUqx_<~xf{vtOL;~&P4#$U)uqd7oP#J3{9 z5qpF_5B(hcdC=$h&oQ5eeg6LQ_|LOG&-gs|Gn^EkS3}tJxR!5=&|{&GkjjJHB?w&~ zJ0Vp2w)SD|PqlYyey@2|^8}%q3J8@o=WDjs%mbs=@nMJi`$2b069$1lbUd9xkLGjq z7UAznFk@5r6o|+9(iswOiL2z6#2KVw8we$04gXit9e({Jet(txHzi4uz*v&|U!^Do zc%ZIOSK*vNe-Hod3H;03R|rm)|K&#lyeMfZw6T^nlYnL|(MoJ3twDN=dK+K^QjY)Z z&TG|t5P!aoE@=j(%_J@O{-zo`i361JnzS&6#6#jGoaA4-B8dys@cNX8q>H4R#2<89 z(+{7P^pb>021o`;q9r3Fv69h}6iK=y3(i&ekxb?PQWYTCc**#HdMrT{0f}ftdqKjz zrjAixB#l6Bz5+g)Dv^U^{7fB@u+$Wgie=OcYC1KOIt(%}7GzqYAfb~$MyG?6P6PRt zWs+|xAhUA?88-<{pq`_NAmeyRHv`Q=Q_xnB)a z>Kau^l~YyJE$Rk!Q#dL3Q{t3xUYwS|B?z2Jx2e0-9jcnT$DbHf4fP1lpl8$*IC0=q z;!hBC5nV=?P&K-X?oet<1A5-is0KZvI#Hb|N05206x^#&y{Mj47}bvor&1+4pre)& zJD{bj=n552jiwT)pMdH|QL$7EDnsYdIaH1+Q3bjQ+Vd@>M-R|_^cX!wPtbGp3wnVt zXx0YMZoeQSoHZ1MC@Do#GD=RFQ)ZNcvZO4iMpP53F{Pq7swwpy)tqWZ*;8#O2dW*_ zfoe~=Q=XIu^x=_AUcd8o|Km}64R1h_c8bJ-G##0lhEGnDIp~g{})L5WLxHv*E zRU^Og3F*mModzT%j!TQl;)|T?CZW2?RNWK{RQJ84ZmQJKOh%emNJ$T4ObdGI=Nohj z?S@7zbWfTb)gUzQniUlfLxIB62vizdP+c5BS@8s=BnXs`2v9O&;ONhQ!}ohM8%S<7 zkk)oUG*2*P=n5d_0U+Wn`U4qZ*(@xx8Kr}Tbpo>J3PjNp&^#CxkwB%v0ww`N%%_%6 ztEdgsHflF8)k$EY%fMdMpawhzCi*}bs0IlG$9hwV8n&gK#0f~Ln<8I;*;d&u*+JO} z*;&~&*)5q~RxA5O_Ez?X%t%XVmTp2fqjj_c-HCRiyVCx2Pr4sHn2rOXK9>HTo=q>M zSJNBm?ere{Fnx+Hqp#4F^nLmj{ed>n4GhCrGEG5Ev18gXPK+nhjR|77i z8O(U*duBG1$1G>oGdq|ApsrkCDwuoB6XrGZryR*yc~iMo-d64+_mKz7`^lr_qvcuh z8S1OlImYV%ww$5y`*)Fq#X2oXb&90i=G`nZ^*zBd*JF_}- zskx`XSFUBhl?53r}%b8LlTxZ(%JR>jV7 z>B&y;BSwKC30Y%e(&J;(viSm6Z#Nor{>`BPS0}zI(90yEux6*xe(@addKejM$v-6&&QvLj{r8*;{Px$v212!rcDu zJYmf#_x%ly*&R;AvH3<-Nd?A1>j_x7O z>|x6B-JzeUr2t%zp9%0_q0UQ~$lp^K+TSyfAJgBH?*RRTT#%o*L_Z#P!9jjJCZWzW zmC*5b6YE2l?@XK~9{=U)XOC0j0tIUOW&^ z9^R&Y;%Z&R?esTw5FzsyA#*k5M9BO_$oxgfT*Zy|7cuBB;>F)Irm2ItumBNie-TTr z0$PIt#Q6fm83Ii6i8BO<-2+TH(|p1{1OzaCiYsvwF&-!a9Vo6OP+Wm*P2dkF4?k0((B0o#z`wu9xgKt&b%_`V z5-|{H!hpC_K_W;&V#^>A18yRKL8eHAGf0F}+**Hc(Qi@OS=O4ZjUe9)a$ETZFhz!2$+?g2m~BMMMXS3k)``#k2@!ecakYWLd^aZ#fxsc+CxA%Ai-$X0uzHB|2$B#A zd59q5A#(CCVK-PDMVJo?#Zd$#LrxsU1g)?xC=^E#)&+w(Ii`&khjspza`~2W{g!h3 zmU91=^7xkW{Fd_imh%3Z3jQ`n@V7aFzs(W+ZI0k?a|C~zBlz1K!QbWx{x(POUvs!Q zj?0Q4Ct}9)Z)vgo8z^FxDeENAyo-~wh(kd(Ie9pVyW}iVp|i++qHGFu6<8+Fl`nL0 za^kTK5x;{F3484%oJo-5cL^fC14O0{JS{_xpTWgRI3FPr3x$&na{ROq2@?ec1HETu z2ql6vaB=b%#&vNvMegYtISFIO#iS)>CB)<;WC`iCgzRh~5tEsjm60nXlhfhUPtKW` zoSu`Am=%*2J|-bCCNLo_CkC3tkBiI69yex8Oipq}dR9z)a$HPWW=vL2a!gu$@@Squ zT-*Z!(lRC`q$eiuIZuJ9U7Vd=6S8xXpW0qzh$n$yssZ#*9u&$Q82j$pCnEa<-Vs$cc%IOGwWVasaBSchb1@#F(scW71;A ziB)4_;=YcTm7Ja^G|x#+ix(#oz;PDX)n7bY{e?pnM1vrK{QXUJJR+gcV?s=JT1-q< zV$A65n5@*8?4+33jPVIDBfl{1xI6y^HYC6H z;FBh>_@oIeKI!jh8U`|7q2bF#XxzO9C1hm-dDuET0;vneoo#ZqZH#RW5KO`tz?p5v zXxqLSK#&tN6Kq4q#D>~tB_t+i^Hdpco1AW&laydP&@sZ%wja<-dX6pVzqXOzI){%Q zog9}S6po3RXd9dGuikM9SvfH)nJlqcA1$AFB?iCM{sNjbK|+QtoQ zXA3Ot^-oY7ZT-^HY=t)2FmQH4*7$^YN7FJ)HmaCImwBU;1!J=(+;c1g8@meZ;2df% z+#P>{o2oR)X2~(hL&@)wdNAM|r6JOIX|=Rg`bmalYMH03t1L#AF1ssxB>SDVrzg_W zz;HT57t{CX4@_64H?x}Az+7bRGPTSz=9OG3Zvlo+5BU)J2zjP_f_#U3ul&5+Xy#_- z38qY_S-4pym@$QByTNq%+3cy=E3+@=TJyH%9nFKy`=XKrK>D+41%!?tDp*f=(o&1L7XdF(Rw2X-yHfh}fB*~@GdTf;tOv7(8h zxx!81qX<$AQ)DS7DrPG36e|?l6-A0-MY%$+c%pcxXt1!dXl~KL!quXyMVLj5MUKT3 zi+qdK76lgDEp}QQwK#22W^v2nsl_{sFP5~WrDYRK&QfjJ(z3m!x23OTpydF|A(pY0 zDVCX*<1J@dF17r@a*O4D%VNvRmKB!OmiH}RSbntptduBOrBbOfHXA3ka6FqysxD8n}3sW{HkM6mD_`s~W8?|ez=@qGo%j4C=CNRKFM;vkf z3>TY0?yc+me~H)W6kiKUTW{&SD(z%8I&6t?BC`dQEIW4jN-^Crgrt^QxDWn zN}{?f(J4ur)~!h^()yD}`bR#bCD%A_YRT9K?H>fO1$uw@$k^Z0$Si8lQm zhY=q#DC8WL?Oa;4>e$~<(dvyeo`ut}^`l>ROy8TaS-XZ+)Zzk;`29j?9DoCU!7}Wp zHT3l~`pZd+zTU)=SoVDg=7{ z^~3YWf79L~Q<()5CoP<;whKLUTc=#UY4OGlHW!l*4@^#*n31i0A4j?=s&N2E=qrBM ztm?{vvs<&;sd^N#kP$(eADLTA<-lpjV zI>m0?h#hCg{;amuC^(+x{zigY<%S@7_2FI1_N%e_iYGD4nx8#4S4Y+{1cmf!qbR(& z?)C*6%nbaUv?R?vorvY2ABS&G(JJtRYJm>NvmMhCx@c$nEoJVOZ9cVIcM%AZb7YcY zm#!GCyLGbk>K&VxUCW4>V?a_`w072nIf?T#O8S)yITmh48jlzl)WwEK9}dP%4izpv z#%9b*Rd*Y*_4>&}W!UVXF5_zLlq&TTJJuMECvc0F(i;wxZMdqgIT}AQIx5;fSr=I~ z@aoV*X37T(*icQK1)hXa2<*B zNOGEZ=_YHAX@0+s8*kQ*-+gRekvq$!XN}EB-w*R8nTqQbE=mak$nx=38Xocf4l_uloRufp}*ipHrNi!K4M#}!*m;BPnv|d5VHwd z(i1PaNQV3@$Ev+IF^#Qla4_(u73og=NE@Oe!K7(5VQ`~lt+!m+UxNeg_-nXb1>=(D zEQDVj(K2T??ccUrtxW1bN9B&47_S~banbV0x)~w#%z`cRx2vm;6}~>G!xIe;Xb*riVH_h4{)zdsh#l)Tweo6!ylA zOOLPKcv!1Ck+`{Z@=I5i3^>iHa;IdCo-thQ9<~4Oqbo;#)_q`A%;U-N7kRvoQg!S3OpFgvF!F6_4_m39Mb2F;dc-J%Cypur~P3Aj==`Z zf3sB|{zmmaPqvitVNa4Z!!@dxi7Z#BnWoYHQp{ar3pas?oluaW3no%df%B?KDl=wh zV%bkNcS=wFwr|&**@fD5AQi_XFCVEUoqW8BG{NLG3@%hSbt%~t{GC7)PUJ*`rG_1d!4S9CLP($o9*o7+!4 zYs0)v8*NT*-Fj;C)_JoxX*b-YS7f9t9iw&~KB`Oh((DyEx?xL)uN=L`47 zUdeRUM9UR&TUPOTdbNW_v4$fVbxmkDhK%{r=us99HXO$x#^bc|ZrP5at-6uh&Q3e8 zt~#~(P?0WS>*<_pY6Z5gJdd?HoSeiexL4SA^;X zs+TyZ?i?4#s_w=2-Z5G|DPwlV7~PIkdc~>@D}Pk0Uhe&AYzZ8b^I2dh1#b1@wP(1k z4F*B}o1CEGacw7S6|*?p72+(dUr<`(%Au+!j5EU&Jv zByxCZwH?^kRj1^-4vOtDU3YaZePqS%?Z?!IcaMo3nLTcF3V)uoqx<7CWh6OY4yQ~L z`pEvR2TN@34Lt3xRfcGkhC`d#E(ZNWwxvd~mYvKfw(Kg(+Lo@sA7>2KtS`|$$3vE5 zt<6FIWVr*0UO?<^I^P?G=V=u)*uxJLh9vGXv**OoQ%4U)l{QOEPD{y7HTx-L)X7kF z_x_Q@LT8+$H%yja*|zK8$z3Buw`!HP8g~T_y39JnW%nK$y|z+|gMAg;UwC>b;0e%J z{5LczNo)ERqae+mI^a+RF~^=9FO7Cg*qoj*eq1J;kBvFiy{vTy0ag#iQWApAhxKVz3FpWy2Diek>BSl)m!Z-1 z`7x}t%I12>k!nyIqPWQ2#(rON&!y6ejDqon1Av$oYg(mh6h@@S6M?^rD}KAPdvJi3 z9MI#Fa!mKDCW_#`iG#AV3z%IC))(0*xD58HMn{&GanUT#b6O>7jvoUNz_}z6!DLpM zz9MVGK^q(a)F`)S6?uBRRFAXt(0MxDj#Z4>Kk?wwrFn~%D2hNJDCVb(UAqelcPoh3-P(J8)dq`u8beb(R!n8Fm0nfj zq#%uZg%OT43N6L5Q>zbcysQo0N~ecR4Ig4dT7Kw^Jr!v=S?M`|z6o0o%sis5I=%I9 zp-!=Ddq#R%*4Wh4tnIrLcd^-JjRK?R7%3>A4Fm3ZMvf;2V9OXCR>a<& z-qFUdN3>6$0fi-LTE*l|&;&gS!;Iqa*|>A+;ys71uhJ=wl*W}p`alJw^Ln$2B+aP3 zRXW3MU!xw*n4kJ%dqCfsyVGy3xN*8S%ggl6l@o7_uRL*ARZ^;e8r)~LM$y4%kVnwr zgqIU^AIB9<8f7zbWJbSX$v>Wo*DBOWnvnJ-I0map2UI|SSRGoDR5F`;BOO~S43 zGGbOqY7MV1QxkEK6tAjl%aNjRT*N53ISC~!t$4uR)Z^M3siKOi=66b}ASG{CzkOP* z*sS4U8un)Fn=x&K4^4=Qo|$sf#Tv$-b}VS9j^z{?Qt&d5T-!l`5v870|KFrLB^y%bBVK~M9AlG?h3xHZMkGW77B!+d-eq*%!WD?Cep zs8|t>d;NIea^7p-O9RR_Up%)wtP*KjA>PYx_HCqd29>n3Fn^RaBOz z4>+c~T}s~=?0l3UwUTpULI8*N809U-#7-boTPyNrz){$^vIHx2h6e?#V%HWBH;V6X z(X%rZ#GiZz%5(tyeh0K0pdhCVNq9DO8M{bvI&h!j?p@8f2ZzsMJHRnRY&{0p63gGC zahgu4x_0PLzv^8I;7VR$QN)9?_DDl1`kkbQj#gYRD=OWm7`fwY)}c%S+i!z z8g>o#rrUV$yT>Dv=zGm$)&E5>e^xMTbJ4coU*+BjCB{_Qbv0{!Ap?KNGW zQo2`^BJ%jibLWp8KX+l&(Flbxp82K|D7azSxIE#qm4c$?!5cUO&&J5y z6TBCYzXt85!Epf{MBt);4#~lV0iBY88v{Cvz;yvU7f>mp79dn+0Z+{E)DGSYs8R~| z0;oy~z7FWF41urZLj>Lq;J^TH`r!0{9x-SP1wIkr3PCNr4o(o@@c^z4;Aa7D5b#V5 zZWEHhoxqg>yyL+6f%oUkL6SKLUbDfU0$hjDI0{@bz*mm{Y{Cp7aDT}~;A|?H$MNoT ziAVx~B_PR03cNSKbwg4>fkOp&RDhF(WQzn`=fL+HUc@Ck7;vwU>_y<_22au8Yyp0F zXcvNqa&XH4{|j)ukignADaj!TyrY9x2RL$o9|kz5fqw_MdVqHaID3GH2fVSveTd|! zlO&M8AjuY7`6j^-=fIgD4_r+a!NtgZsY)6wO@bHfNzy!cyM8S*lMRxU$v)CS^bhng z#)HWRceSg`Yq?BrC!cNB)hx@b0^VSInn#(BH7_@RXa1RWU?bTSb}L*^R4N!nhT@3g zn&O4xlSMO&;TFp*wpd)Xcx>^9rP9&~E*1J)=2#xFtWnx4-IamLeafGe^^LkU8r^7o zqpOV`G3 zHCHw7*t~c1l;*pdKeK6RlWnut=AGJ8UD86+BCo|ZjZ9;w>8c6V#A@<2MVj-P=UT1S zOS?#0q&=te(52{VZ5!LV+itPFV*8<`cguk-C$wDM^0u9YU0b^~c2#!Oc6aUct(vy# z+iFy+v{q|cU2gS9>sGDZTL-jGYrVg9aqFL2-)a4{^%wh2_5t=o?C07awm)xQY5%kR zYX@rwcZV2+eqH-x?Q1$nJGga-?Xa-Ju?{sITXY=I zab3s99Y1xFbh7N^*J)U%tWNVfUFh_}QQ~Oh*wrz?aiQaG$J>t29RKLtsI#uKTj$`; zsh#I_-qX3Hv%d3hP92{w{31EZui~(bhmQXy0>@l>fXma%)Ouc z0QWfeE$;6;T6yGnT=0zY)O$Yg{Mi$G)_JMCIIj$^&E7I^fA2xw3Em654|^Z=KHf#y zWqOx=T`qTd(B)N^*InLrF?Q8-?bbD>>!Pm5yI$#fwd?(^uYDvw);{h&y?qjVa(w3d z-1gyS~zVF7qoBM9-TiEwf-xpyh%swn6Y+Triu;XDb!hQ)43P0H| zpkGmcrvH%s>-#_J|DylL0g?e815yX<8=#M1B3vU@MeK`g5t$J=HZnVMS>*Z1SCOv= z4jI@mST@*w@YKP(qXtDi8j?EX=#cl(jiOVd^P|rW4IMguXztLtL-!B8Fq8~yHmu#S zpkV`sMGl)iY}2qy!>WcEhc_PHdbsQG;Nb&?j~Jdae97?h!|x7%HvIhvYJ}B@&Lcuc z#E-}sF=NEy5z9v081Z0ahmqSx9vpdXREJUiqlS%28MR~7(NSkd)x}6-Y-4)HOpLi4 z+a`8F?5^1Raa5dR-0<+cGZhsE#hHsGRNk5DGsUN`l-xffHLR?Q<%}!8#2QvIL(XtU zC&L#w7s$x|oX^mqF0*uJX3YQ(9~Qw1$8lTxv70^rY)MiXpZ(IlYeX8 z^5PR_r;cqXJY#cz@Yw+EXrnI|dcH!gRFYHTzd6{#>OQuxuTd4?o^XhJ8kg5Fs?rUl zHMcvnFeN2(jz+5-Bpmd(ebt>RDLw;6MHQYMz`$j|Y%!&rjFZc8$8u_?VYU=E#mU@L z4Yp$74=QiR6yT2ZpXKfJ8e#e~xrKp~d;~vnO)mcIhgv(5FWP?e3~B9}2e`@@ZaO zTG=>U?TOWiTC!Z;FTek;%ws`e&H5d`o%~vT8dq_(S^MG#*n|xo5-?LsoD9Rb%ZE=@ zuF;a|@{aqa9y)5H#A~Nj;n=F({2%Hd|Mvp_M_T8cqnTDWn`_(Mr$dLkKJQ-NyZ_tk z?)tVmP>8q-B_~TRoQfV8Ido{`pdlwOXbsCroY1tr-uLx~+dsd2)%}6k^x~NlrRPo$ zjT|^EIwErD>2qKjwVzgHcwA+5xn^1oj;vAL!gGwYq2?K|6)hnp-OtME|VLWKtCg`4z(e$oOYv#&V!h1%i`#lSXGKUVJ{opc_4Q0 zjGIEora1Dl6h{IGq@A4DozCr#TMl(2iZcmMaHHp>QSm_+tiUZ#=UyasLuuSjuKX2m zDW`C0xwHyz;cza=EyuYyx17MAo&>VOGPIzup2ePzwZ6gRj>}@vgdfjyQV}EkEodqQ%bG`4QIO zi8$8_u9Ka0#K|b(@Zbw?-+U}Q6e~ z___wjKTmI{sG`oi!rP$DL4Fo<_uhog)xDTS-&k|z_$~GA^U;Cv^W!_a=*Tv*f$m@! zrYB$E<{%x%&@~Ur&S7MOS?6kE1v3F97XhPISsBzdJ)f)In zyZorjm#g02-g>(Hwhg9TZow5gEXqS~VBS#ss&Hol1opXH#I}I7)Q|fs5oS46?LQrUxM+pFkU-`^do2K zAwH7^_Ou~3?_6PvbZ?&HX1H1B=R}z_dgino?Sg#Thm7Q&mjCqlX?d87M_8}Uox`rz z!kX$>J$2d;C^h8pum$?!R}4Awg{5CHcz+!$&;*7BPpz^#TN7S`r^6rBYmqkOZruqA zJl?Zvf^LCa_0es@AU{_d(&&{Z{tnyx@)%n_i>&t8s0Bp6t|~ZQal_`5=Y7Iz2}}BO zmBq!CH_i+V4jwWzBxFc&rB?MZn>^xb$}ik`_)~ZnpYXok-r-js2$WQXzpWVW;mJko z;6ZFjljogqquu+Ltt-^!F90GtO}}AqZ+x6C|9#on5*w_xe?ppTNoSHJtozyZK5j0( z!dzW@U3tc{EF1}VAQBozVHA-ZEod&?vz(#l#G|?GyxJs69q^mM2k7#~9 zYS<{4IaRop>n}HKBva^exv?)EY=8hs;L^zQ=jA_Z@O!S@xUr|)S=Q6AkuQgyP`ZP= zC^v4zQ|O*@Ltir32mz8xXMo9Ypvnq&=Z~tY=O86#*QLPAidP?ka&^yV&=spr9=)!< zc64N~A?Zn}sk&~y6Tc6#38}d#-@CA&;)?cm#g@}oY%ub{TwUn-569JxE(@1Bgr@-tb~Gij2Y@#0Mu~ zYpg8C)KzUCrbk5E;qBFML9WJaaX0w2f!H5}ngT8e?H%CCCvftt7eZ@J?OR`bOqb7$SX4NEpZX&Zj0$_`j2%ce7oBkA z+;6ur^W^x!5z(G-3-7K|0x<&tCX1JRC3O za2x!d&S$VKc~2`ZPSIFBt-(8=4}7lrgwI)H4=lZk)xY$--DxGy--f$ePM?1W{N4Eq zX{9A;U!s^AJqI_e_HxyykJm?fcz25hG3S6Ead6+tL5JVYCI?_t&cWMm%;oScPSdOg! z5{2QJE(~|YRv^l=i97y|4AM>b!g9pTP>kIe9Ehp*B!q+#)PV%)lm$~X)YBU2Q)@$9 zv<5^1zIB#`Tn)a3AL+>>JQZWTu{Wc{u@m)p6@M87fEtoPUi9E$%_Q!`89t54#4e;G z&iv(h!HGvY{GR0bVox$go*f-JDO^o@5KG(`cGwpyaTDx$`&RnkV%^np`bwDRZaX!c z=P(yQVNmjb)Zj-3C3#d+7v)JxjY?eV3CGZNfD!koiz;TFC+aDD)g-Sxjq{oI@ln3v zN&C)E*LqH(9X8&KdZ)&k>(8)Mr@V-bN5$3EVB=~AKPN^SH)4!Z1BXB2yEL(6$Q^Qt zo`x^MBmsFm5W+N^=a(o$M?Ks=De+Uo3(WA)T^67-ya0+=ZhS$PKxB9U=gfgOOqx%#`1szlduKfH<%yS+EAM>-^~JLDDzkzjTd0c-Rn|Xvjsfb@d4Pz z9@c<)2RZ)kbp__Is&fVDpd+a~G1ajgc3>W#9qI2sXru>F5=^l!uPOizK)n?VcH%ia z{&W@zs56kuNZ>hV;)-K*2Inf0knQrks-tJeHxo>ymI564Q?EClMK_6 zfjqDVTMkI45nLFTj_w+g7)?6ql#_wT%bw${Fv@8FbEzhvA3cUu=JTfpUQi=l#jkMc z7Zz5Er|ONxu)b!n*|jwWNsY9VMtNu^cx}%FGRIqiLmx7DC-AEaL&h2J^LS4;+^3ZW zHIMWhjHqRwdrNV$^)Ep2hSxk72N++|FBlvTom?5>Wvt~p`59{IP7LuEbMBCHWRw@@ z*EI&=fD>^A9!)A}EM>@AgE^jwpVD>=d=9pgMBtsEjx%^Bc}i9p&1vOkyrM<~ZoRP$ zoej$pxwFWyMrt^~ABXwAhBl0`6A(!c!y3KOMQ-S1I7cf-E}-;2&>$r0RcT#c*_59{clhCzJ`t5m-+ff`Et{J zs?9r0fs7I^R73lxM~%`gzy?O?oXpQ5#mC?TB(2->-U9Y-9B}K(Yg^8q(Dgq@$EC+l zjM~D6sFe>`0Hzjz**lH9cWeKp-MV})ecgOtVpzbGjOtg^Q%xM&v?6vy_rVr7(}5x{ zx)}TeF6A;1sx_FO@-R%K%sIl+kx7b-{i1O{aKz=?Wbbp|lbZFn6 zwA0~5*q?h>8R*cVr=LS-|LR}g-K_oGu^XmbPFz(x5H zCTv35&}mcimrT{p454SO->_hl`o*bB_jJmDYQ41DS`T-r@JPH+FpToSA7eaRoPy^C zeE%6s8)o`~Gf3@2*aO$P`5eFn_wIO)p<@+w6EK*uWJH>c>5Yg!Q~qn^mf=oXvQ>|_ z%d1PS+`MyWL{}}@ug6Eh5RJG-EJH_RgpJl#qz&HOTOAk%&qq4b^O1;~Z$nPdC^rja z!aqNN-JHX<*KE?YC z%-)vhNUL|Pu|vhhq7u*@NU^@IkFVDP=2B|l{%&d|$uv0V;q3#=8UR6f@A?X`AMG`_V3+AoUF1=4LnLcyTH0bOAD>$mh;jn@+(0#3@?@&4uo=Gx6g?|MG7X#xm z1_zF;lM}N;iO-AnE?r%yoqvm-K4I+K9QBB-^>=i5G1iuY(%9UWG`9zJ4%C0sTa1%N z>74hT`oRz@{Q-6YM;Mdn#|-XkNTNNzxdu3ZYk(3o1dKofLDbs1;Zoei`T>JkY~+Pu zb2tg%X{CeEsU>7U7B+$^ZwB0A_?#_d+(B+EHq4a1v&Ig11a3=4@FIU8jOdQjz=VcJ zIUmv(`_T3b_91B`k}Rc_&On-f0XN8)8lVBwG_M>#yc7M+QEj{gn~b%r&#*!MI_NSuC1TJ5(9J)R#h<58`f@354Z^f% z{giTGFqj70@$($2bK}aG-RrZXbfmX@$nM@{)3poa{ibCOh_Hcz!4q@RwGrVrnG2^& zZ|Go}3wzI=&DUO*56(|6E3?79=&G`G$45{J_)r zss7W;)CT~N8i)Nu<0TF-|51&gE*r9*vclB;pR%g=0Pkxu&rr?XVk1TkAEbSIfm^8A zwSD_8lX7j?_(H?O`2&8UI^fNc9}VlcEz8#yY*VYQtk4W)vS&@5kf~N3NFIK;KNLkJ z#Py!8QyR?RRhZFV=HG=W!;%{53v1j6N7DEti8R>g$t9q>B0VVr?fI167{VAwffir@ z?SWfOjAS0KEs!xs@9#g#zh!}Tg%6jT6zX`mcHtH32n?pk-c$x zU+J;3#MdsdNW_(wS& z2zoh($KfpE20#qbkqzV$Q$y}?cokk+PL_fy?k&eXFlq-c*`Y+*f%poV_ZfJyd|GoJ z4q?M->uAlFC^@#RTbQK*r5vij+(-ptFqm|6)u*#(zS09U_z8&NX-x)@!sl#LKOPY)vGcusi&>2x-I-^x|ag zb8GAkvXlc!=mq`>99X$s;4Y#mIBI$mIas+~THMIc_UnG-vBv=?FuiJ#2CF~;zST$5 zmUR5v>}r535#X}o(Go1cWwH?JQtHNYK5lezAK&dRY9M35;&RyQb~#kn#r1H)!>4rA z)dv|b)!=304Dcj=8vQ)%y6*mC`ip|R1*H; zs14odV}rMbbhiQc6#&0`VEERq0e>_|G`8CpZE$hQ0RWFN3?soogPkU7AO9mxnCpwJaSGsi*N zYGZLAL;8Xjj0JWA&cFjjYlt@n2jW%?Zt zU%*ycl|3Jaujhi1e((V|hFuCCXRGU$aNrTs8r1&;R{3F~K7M>%)5m_*(IL~Qp1FFkB17>;P+c^TPi@W4 zMVg&LhwIo(Wc0tW`5yKl&=4ckEC0^hn+4Ve86@y_0VsMGxop-Jo-FVTn0Rv?&#&M} z^_5}2H88aMDOV%fF}yeeQ^x3mudCi0j#}&Gs+UF=MlfNj1k(m?I7Q<|j+5&aaxdU& zG=c5HfMM|t`ktz}j8jtds?Yzcd65lQohqZrQwjWtX|ZR_QRth2D@K3^;{V!L^^x?3 z?spm08~q1b^$||YbQauw5%kSoq>0t5>P}%5$J;U&G}^DGjSkOxGlW(BxzXUwrESY8 z;+?Go2PV>bAopK?tFH^0j8l$*hs!r$RG9&Zq1e-pcqdBsD9(_1bCJ!c}P^M~;lt99Y;*LG|B z?75k6=O%c3_f-3b{q%H!4u2_NaJRPiNuvP+#|?IXLx-*06eJ75u zI;E~UIjXB}roEiB^BLM!SF@NaIlJSI7As~j?gIvP?K6DaDJ@)RwC1XpUb%Qf{p9T6 zu8Vb`Bk~7z$qoEoFulS-IXeo9GO=%O2foYA9=5?Y({+*Ed9RjL>%MxmjQu__=OSBK zytnM==pCab&)^U2gJoCq&uu+&s<~i2CQKZ>g$-8!-GWH1+r$OO?aACVc56=amV=JQ z{#kZD;$p7@!Oa&gp!cK>|8bC-*tg<6-dq1}4_dD4i{G&y_UZxRqA>>NQR(^NhinIC z?no(2+t%FEcvP@{-&KJ18``6-=*YfI0|XActH&w%a4prfE@`}Gc^fb{g$jXwXW)t% zHL5=Z`%?6*=bcxy=l@6ddeH-4TXU5AyMH~9_yv67A0)m^VM4R7$WoPl2X`k9xRhlXGr4hNW(o~&JvIeeRxZA0}s2Z-~f?*tpo5! z!@(b=S1o;Ltv4k3GBH@jsYdZ^R`69q!7$H2grs1!Ouvjj@(XxC!pW4cqgcT`13@rD zEf0mNV2n5cZ(RN)^Oiq+NRumi!*_h*p}b2M8XxgBO2Fm;Ym=w@YJDtz0!)0Q+JLwT z8j}T9i*q>>nP2%EnHK?>g9$0_$!nZa0DBU~uWLaYuqDMBVN+J(ij}v#kN zn;?N=-pn;@GR);dgX!I)25syE7aKe|_9}=t5UUFb+M8N=lTJBOmU~r=+kE_pTU{T2 zF7cSI;s$N(My3eVcx3;yoI)*`Mdr~H3a9TsZ1WS*ad;M%tFQPP}(hGa@Hc?Hf^E!^@5hU=upl5H2)qOz&uIMO5D% z-fFliUdVmCdh55hm;Ktb>J!wqLwNN&ZC&T19NEd-UG~%YN_9<{AR+j>CNTQJ=9eGT zgbomFer)iqPh<3kSYg@!uigj9eQQ*oHs9eg*|nM)!|=b3q5`nLpO)ik zRaW5DxLTiGqcY%=bt^ey>*GRnI^zMop-BGwiOauKof#GqJR-_VJF(|R25$T1<-rbZ zdiH8NY4W1wxw=64>`j{%Y=H;9cQ3&>p!+(g*WnqGAq+BM?a9pUNg%2yua8y3NVR;|xm zq{)Qg{^!q3Y^oX6vu*Rv6xkS%FPS3$$44jle7eRoA&dk1zy%Sk5&U%bj)LfbTczm$ zGu$nZQr=v|qvmq~8Yf0|1D-o%Sz?Pt00y)$Hs#Gw;R-^q^6{*6jmgjlc4%z{#y)v3 zs0%LE-z@ygg5@t4Eq^&~W4QO%_9Ph`%Hg`1f&BqH5c>$%Uq^(Sl5h>&ffyu42TqVd zs(TZ0Z?2H#Z^g9Aq{l1{GhM0ttsz_&f~7yr;8QmA^uyDa%B=R3O*^TGj!9E z~oPQ`MQ%S0B>hD|as$XZDjT z+vHW^$yL;+D(U%3F2o?IB!lIt`zG!`Vsp3b*u$MgbKsV0T?M@|Gj>_3nt+^b9k*uW zjwEnkcwI2QZvF)7j$V2P{AmTe9XqFiEZ%3mN@J?N4NksR7mnoURiEy_jUZeI*3Ac> zfO6`RT1n!Fl)} zV_VkG_}AdyJdN+@{f?*%clb}O1h46@WBbY{)ki;MRi7eZY<^Y4H1=Q4abFjeS6$a_ znC26d{B2nkhp5em*6I%&l>W7@>!sXIHXdyDKXO@dP5*55-$jw0fQg07PFDWd`p|~G zg?rW8H%%ThXW{Gxv*22!F0VRYQ|G0Z#8=Ondsxl&)4HC0e^^UYAG$&(GzU<>f^Rn@|$Vud=@ zjUxD9QLCy2&tAyz&b{C-Q@2F}a||;c>7`NqVfqLPz}TXJ$sUo!zn78);Nra<+_t;% zpH4mV(17AGQfMN4J*87UP`t4wwLdVq*bHT-~EH9Yb#z6@U?y*c`S zI{Om1D30a-9oOB3S=l76OCp=uC5bO;G^jB#-Vx7u;f43XeJPjR2r38&DEFnHASx(= zii(P&fZ~A{-UJbi#+byHM3a}~r3Z&R|KFb76*Muw_x{X>*_rO@>Zd)z zlJc+fwwiZXvNKqd%0A#oHy9V{$tr~?*}sD1Lk03a@Fr%lUH3hXXbyd>e)JW;1vq|c zo2^k_r8YVptmD*=^3mvjf)zsBwrJ8Y7E+SNbEJ99pQ<0v@w?i3i&f2Izpd6xVM{~{ zr!P1^9r>UwUZZ~aa$UdBy5><68j~&5MHqY-t9nSQTEm(+T+5PmGq?wq@z9htEVU^e z2g;FpK;uHP6#X~_=?cp-Az?4aG*sj^A3Y!?!>?+@ZQ^+^*dhJKJ8EEq#>)w{UyY}9>#xnyVZI)Tx#Wc26Eev!vH!!rc0javSjhD5#&9=8KzSo2pd>#Bg6&fcr4LdgZ7EBQ;@>k)fe^kp+d9 zxx5gNw}GqZugTy<@^tOwQD}1GJ&g!vDx-%vMJ|zPP=^60tw1-~QzKSrs$H*IUd%Oi zwtbn0BEduR8M@L9p|d8Kqa8}87Ovna`WdSlR?La~ydmU5zeMH#cNAAGnr^sre5WQU z3AUWb{?;*pO4VSnA=(YV0zs6jYI!9G6FkU=KOD`OWN62!hMCfbv8w8Fmb?w5b$n?L zZe&}j2DlALqBKObx^}dBOhIKmIMwnI979x(iT2Ov$td0t5X0IbkOJgz*H)@Qjdtjr zyt!y_)bFx<8Hp@j2W9&%?_;llv7n7(c@yL|W#D@0R*&KU`UkpqAR zfuKhn;eMILs{chk6vrC_W&Z+Yt@0w4oTK*VJ$}1H-YC>3a_Sm`IU$iXTqF#}&_1le zB2A}iol(SQvLc|a9q?++_QSjMPCIJ?TIhh%2TKY~bg=H)Ocpm2$7X3Z`w~b9+sDt{ z_8V5`f`?sFN7ygfJjHCxpN&;N2h63UuI2tj>hxF9%G>czHL&-3hgE->=EJqlSj%;! z+*s}cV>RmWBrprzI$Z6~Zj3w&d zqWVG};PEf;yu{|q7%-d=Ys=5r@C!yXttppT3>V#+`PSRgr~CLp!?@PE=SiPp7SXSg z`x&`oQKewGye)0z_(8Cm?(ZmCP5Wd8Y&~N21>4VW>1ds;;>*L^%C_hi?79$F@d-=u z$BO21_aBqVsA3-N;$I79hI-nwK4oD=x^^JP_rrMbGt9yOK9&{y#4&;h?TNRg{23#T z|BeJwlFgBCL$UM-gUZ{mUu<=i7u08+JvM-SPeG;*fxg^%5dy(4Ve!J_CCA z$RL7J1JF;+25jx5m;fSQ1o#V@4AspsJ`$4eO350_$y~Pao}6fr^0ym5Tn|l|-}2z`WnkW7+4`yc6HfR>{w~K`6321u1O9aYB~u{hmRpY-2XnW`&jYy( z5hdy22r)~|1;_@s@lZAPCL=e z`5N_qqTVxf$UjD*L-NU{^3E;kr`R-1=JkaJg& zB#2o>94@m>(2Zn{xVB*Pl$7s|aeg#Qa_Ep@OdHV-hbE_XoUSd&m{P|ztDi}D2fh?+ z2_ApgQ2`o?P7*CpU0YHC71NC55iFTQ?rH%49xJ?KDNgfMhSp363@B?C)*&j^>ktJ% zXF>`fe*_1yZUP`0@hQ}4KGl}4RIZleSS%D$igR`1ioo%Nt`@;Tyah2eX9br)l~? zJ#G2^j}!-4QnrKRuLR{ zHBSR~Cz(Rri8->Ck3h!@b?)G(O1IYqog@I z>C`$B>GF6O-afAXwNyteN?9SLjuZE(d_jGOh!VC{lyKH~4G_*4vetJ-^Qamtxr(N9 zEhM8wdY&T#mOt<@oo;>EqXqu|$4jJo-ZO8h_^58uI2hUnPsH?gO zD&C&c7}7{W;k%;g3Ef?)jy%uZkgJbx3MmLLSZQ_^%QMYq^Yx4P|`J4>uDvyJ5Im$LY` zg|Fw~<&DlW89E2OPX^LCC>bG6F{jhl^)df8nvg)YI|By=2o43o-A_N8V@fk*KD_K zUOJV5p(dCcAFm~NWP*7Od8_41*uY4~l1}*0WIa}3u-ClP)qdn%J^88gw+E*}k=#cN zx8L_&^MQ-s>9OT{iH5jVr*QAHa@8_Cem68WaeG^uYJa>d{k}$Xrb-r2va0Ba)eko+ zzS3P5?BoNE&-_(aCAa1V7+tWy66d2~9|+RXB}+Y-I7ALgBPt} zzVxoF-R!{$#Os{6MsX1d$FGkgQBmIQi!HqvwNm%5`0)pJ!`&t>C5 zk%xAhAU>JKx0Ux~KQgH4EAqw(qi_C9AA?Mt_z)zo;r(je=Dc-zTH?|06H(}i9k%RE z{SqE|EJybc;MJ&k6zO(!XGZRB-E9`82jet@<{td^tD0k1u2(MUrGG~e?dKimrd9b< z%+ah7?QzQ`tYCg^F!@Y}4fgJuFHatCXsnon4Un%-Q3$Usta_+dnP{ozNUo(=gZ?&~ zI#%|7lcN*5VoWj7pfk}x3Da*N58L%eBnr+js<}F{2B28h9#9}US?Kn-=+5NF#HvO8 zM^A2)UC9LC1`aPQfa_6TO0IOSmI& z|E1&K9ntohqG#yR52-gXT}N+XZ+AW-EHZpk`HxTAFwMUN54k$D=_Nm6v9hGt^>J}l z>dK~oHLF8|SLGCCCS|4M==?KeG2sajkyiiw22MtH!^w!1E0+FG3ZGRaSB6zPR-8B* zcz9oJQT646u2rekXREzJw_)8{U*T4Ds%&j&d1%+&#~rJEs&`a*Ry!WwRS;g|Q?!B{Caxx<#;xYf)*Ur*wPnXbPL@?URl`F`>A0z-2S~QY zp)}yg*3GGzn{=x-ZeCle&6e-llD;WdADNXOTdalEn7!P8O>}@x7$irj9dPE7l)64O z)vCsO_kuZI9t(6)(F(`Fpv8LI>WhlP%#6)Ry3I+lS}rX*BPKJ(?6ACr+qErgQ=Xno zk(X>q&)cY9mAyG;hc;iX-Xyw7*||9*H&5TfMQ+#}w@q8PIXy2|Z}6b9VC9BQv4z?r zMO!QMPv#C$gv7>#>C|Nb2c};r4v)#)q(3)rrXnn6O*p;LotrmH5xQngm@ZNt9GzVV zmuY06VrOA`-Uj{3?9H(|v}8l|D0y6F+{TSog}J%Kx>_zf8*VuDS(P&N#ef5&r_M=@ zj7*8p&hqhgSeX`?z8Z65d2m$D7Cq@VPw1uai;fP2k#FN%TY-}YMs2{f`{IF;x(`bm z=Z&M8%i&BaASQE*zGb3f;?iaF^!#h&GtHuM-%PVu@bD}eJ>WljiOuxQsYkt}$ zkd?J5cDr_O9?S#vD>iIND9{o|d1g^+QGwMt?{fS33%rIp>sBVmWp1*%FmHw;Fghj} z2zGyjHV9cwIUF-j7^bM@d}E>mgI6YHc^Y!}l&*4+9b2_AZlzU_Z`dr?(2ZNuk}{Ih zbqlj(aVry}qO969M4|!l#qZ26b=Vbf)Y$$THm@zy(l#Xm7qccP1VYx4Zu5jU6aleof`VhyH|y?E&b{a9pU(V4uGx^2l$x#g4_LKW z7Z;lpt4&@c%g9)lp}n)eM%Qjn=vOXon zswj8UHXS7ul4{N0s@amWa=G3|kq{G|7^MwbmAiHO#=JuPe|lm^_txBvCAw|Xu^EPDXXC!9DXX!)ZWXbVKaq(8MiSe;$n*#wTm5`8}l&D7+T9>I?kqFHb zajj9rCM3i`CF2rO(^HaClGF6zsWg*fOo&| zCrP)HT}g7oyaw#+7>|$rlNJUp3ewjF$jUbD%HFFzQXJ~);1w`aA3l2nc0}IV>$kw! z6BbdPTMtC)X9vmbwjB3v)_!yT*v(V!hh}DqhT>Hl%PK0ZF3+kQs#CS~7KZZiQu0+0 zrek^QDG|gSwDoS_H^S6b8N~UtuBu{rJE7c>0|4nU`y_E_CO+6h(XWAI*As1ndKDk= z4Q%V>>Y4!!3iYbs38MS?aN zv#BOwD0~u>Q=bHL8xL~AAFgCLZY`wP|Ez(NfQ7{4U@@lk@EJ_*=0G#>z(!#jO@HZqHi+wEhMm6 zT5rSsf01;XJKjvpuSt(j<(9=Y@>z|{;TBQ_QfAw+%lO+jncuJ=INb4np+jgCWm8Q> z(>0CV&AA#x1D|SCHl))~_6kxU9GYT~4(Zg3oBCWlUl_zQa{^mt5KoIiJarD7qftEn zgn0gWlRXIW9HHU~pUxhHc*N^V_FTks@mvzcbIFGL|0V0lZI03j8Iev?xkCmafgmcR z6?W`cDx?+&={?8)g&Nw-P4N8EP zUvK}VOLzG1-o0aIS()!nZ$l8QX1Dha zYy^2EmT{I(UK?p=ulsC5=scJ=Np6oLW<>hs1tKZX4ak;_U%l96vUZa5k&6en?5(KK z*Vb0)^r2Q9;X{IR9w#~+8ae1Nay0CBuwv@gY6 zzb#=5H8ABziK$N`rB5S~`k%-#Pb1|3Hh4oYdcpe_9P3f%giUhQ96@)N#T$)oGY9uM zcUhi+sn}(>FZ@hC#NfX0Te;zuy4Rtgyys@_IA_TIR<1gZt!hSGV~Y7v%o*65kt}{t z42w6G@iDBbJypgph~W;Cr7?J)$`N770wZk41QARn1fY#~k;Bq6c>4QImAN0NTvMwh z{ThXS@)q& z5{KRz5{OBjudrap7C}X}kX+I`YM)T+KAjwt`N*dT2Zh>wG43Q4yESA&flRfJE84Sd z=Z(#Wa!X<_&$_U!uzb6^i9967G_w>@sWI89>8Yuyx*Hc|>FKFSsoJcR`1rWQxVW|Y znZspKF=?xFl9JXZX|LqS1bF8gCJf!L%}L72iq?<2HvZA7pUqO`m3ZK%tV`DBCTC@@ z(NDP{Q#bt(m$f!M#VRvxeVPuHB`2>>)+WOiWlubI8KDq|BtuI%6Q#eg>dCpO-4#a zN>+;535EI!xiM4b5FuZkfa&7e%(%?N4GCr|Divd9$xbL%r!Lu?ZI!kmb8Ds!DzUx1 zaNC7hmt#wE4{g4&bK9OGb<@3F<_Yoq1}ZCo6hdWZk*Yg=%h6{+Wi8_`in%lRB-wq3{S|SsAEGIqWix$bmY=I&3q&`pI)D#pfu9A@+#lk=2 zRaIg6!-pI5WyNGnu~d*f4b%)TK0(IJDZZ^xJ+&ksF(XJn>4>L=nzPfVPtOmnuRl9I zzhK7MGiSpKrcax8&QSb=OVTq>=W7-%DhPc}z|7_HrIy8$mIDpw6Iild8fYXiJoG3C zR4s>>5D@rXs7Q5q=(*{3(}7PzhA}I5NLN19Xm$`Y`O(D%1tXeAj3@{@a@0^;*(IsK zJayCD9wBmbs(7Zzi|l-V&86Z=a@EtIB1X`VBV~F` zI#tB4Hwb@&+1X5f#8jp$e@rv`goDG$*)mV(u-P-M>< z%+JMP^;~#SAMgb+57v_id48Qt$dV^)T358msvxN-6>rd$V&Vxod84G|D(S2}emFXG zufFJUgB4YRaxw%56j@QIs#raaVMc2%AbvCc2VDkM21DN?rpu5N@LH~29PB=80k z&F_##3tcpk+e_D!Y5#fb7%|_fzev87k*@IXE-xb8WS2JwR{D2$ETtlxKDR^a22 z<))oA8gBS}^+AFPu4E<>iF}%HTQ*>YU!e<{1D5T*~7 z{fO)P@CPN#94I5YA;L)PyCZkb{Z#*?o_WNNqke1N)_iluZ|nxW5HpR$$S19u;VH

t5LwC}tK80Ib8D8qM-Hd!%-!=}8Lf}vhCd(uZ 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 B%^d&$ literal 0 HcmV?d00001 diff --git a/packages/flutter_timeline/example/fonts/Merriweather-Regular.ttf b/packages/flutter_timeline/example/fonts/Merriweather-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..3fecc77777abf21b9544e0d5e6610d2b51f1779a GIT binary patch literal 149120 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 createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - var timelineService = - TimelineService(postService: LocalTimelinePostService()); - var timelineOptions = options; - - @override - Widget build(BuildContext context) { - return timeLineNavigatorUserStory( - context: context, - configuration: getConfig(timelineService), - ); - } -} diff --git a/packages/flutter_timeline/example/lib/apps/widgets/app.dart b/packages/flutter_timeline/example/lib/apps/widgets/app.dart deleted file mode 100644 index 9c75730..0000000 --- a/packages/flutter_timeline/example/lib/apps/widgets/app.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:example/config/config.dart'; - -import 'package:flutter/material.dart'; -import 'package:flutter_timeline/flutter_timeline.dart'; - -class WidgetApp extends StatelessWidget { - const WidgetApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Timeline', - theme: ThemeData( - colorScheme: - ColorScheme.fromSeed(seedColor: Colors.deepPurple).copyWith( - surface: const Color(0xFFB8E2E8), - ), - useMaterial3: true, - ), - home: const MyHomePage(), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({ - super.key, - }); - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - var timelineService = - TimelineService(postService: LocalTimelinePostService()); - var timelineOptions = options; - - @override - Widget build(BuildContext context) { - return Scaffold( - floatingActionButton: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - FloatingActionButton( - heroTag: 'btn1', - onPressed: () { - createPost( - context, - timelineService, - timelineOptions, - getConfig(timelineService), - ); - }, - child: const Icon( - Icons.edit, - color: Colors.white, - ), - ), - const SizedBox( - height: 8, - ), - FloatingActionButton( - heroTag: 'btn2', - onPressed: () { - generatePost(timelineService); - }, - child: const Icon( - Icons.add, - color: Colors.white, - ), - ), - ], - ), - body: const SafeArea( - child: TimelineScreen(), - ), - ); - } -} diff --git a/packages/flutter_timeline/example/lib/apps/widgets/screens/post_screen.dart b/packages/flutter_timeline/example/lib/apps/widgets/screens/post_screen.dart deleted file mode 100644 index 8b86dfa..0000000 --- a/packages/flutter_timeline/example/lib/apps/widgets/screens/post_screen.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_timeline/flutter_timeline.dart'; - -class PostScreen extends StatefulWidget { - const PostScreen({ - required this.service, - required this.post, - super.key, - }); - - final TimelineService service; - final TimelinePost post; - - @override - State createState() => _PostScreenState(); -} - -class _PostScreenState extends State { - @override - Widget build(BuildContext context) { - return Scaffold( - body: TimelinePostScreen( - userId: 'test_user', - service: widget.service, - options: const TimelineOptions(), - post: widget.post, - onPostDelete: () { - Navigator.of(context).pop(); - }, - ), - ); - } -} - -class TestUserService implements TimelineUserService { - final Map _users = { - 'test_user': const TimelinePosterUserModel( - userId: 'test_user', - imageUrl: - 'https://cdn.britannica.com/68/143568-050-5246474F/Donkey.jpg?w=400&h=300&c=crop', - firstName: 'Dirk', - lastName: 'lukassen', - ) - }; - - @override - Future getUser(String userId) async { - if (_users.containsKey(userId)) { - return _users[userId]!; - } - - _users[userId] = TimelinePosterUserModel(userId: userId); - - return TimelinePosterUserModel(userId: userId); - } -} diff --git a/packages/flutter_timeline/example/lib/config/config.dart b/packages/flutter_timeline/example/lib/config/config.dart deleted file mode 100644 index 3e82841..0000000 --- a/packages/flutter_timeline/example/lib/config/config.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_timeline/flutter_timeline.dart'; - -TimelineUserStoryConfiguration getConfig(TimelineService service) { - return TimelineUserStoryConfiguration( - service: service, - userId: 'test_user', - optionsBuilder: (context) => options, - enablePostOverviewScreen: false, - canDeleteAllPosts: (_) => true, - ); -} - -var options = TimelineOptions( - textInputBuilder: null, - paddings: TimelinePaddingOptions( - mainPadding: const EdgeInsets.all(20).copyWith(top: 28), - ), -); - -void navigateToOverview( - BuildContext context, - TimelineService service, - TimelineOptions options, - TimelinePost post, -) { - if (context.mounted) { - Navigator.of(context).pop(); - } - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => TimelinePostOverviewScreen( - timelinePost: post, - options: options, - service: service, - onPostSubmit: (post) { - service.postService.createPost(post); - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - ), - ), - ); -} - -void createPost( - BuildContext context, - TimelineService service, - TimelineOptions options, - TimelineUserStoryConfiguration configuration) async { - await Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => Scaffold( - body: TimelinePostCreationScreen( - postCategory: 'category1', - userId: 'test_user', - service: service, - options: options, - onPostCreated: (post) { - Navigator.of(context).pop(); - }, - onPostOverview: (post) { - navigateToOverview(context, service, options, post); - }, - enablePostOverviewScreen: configuration.enablePostOverviewScreen, - ), - ), - ), - ); -} - -void generatePost(TimelineService service) { - var amountOfPosts = service.postService.getPosts(null).length; - - service.postService.createPost( - TimelinePost( - id: 'Post$amountOfPosts', - creatorId: 'test_user', - title: 'Post $amountOfPosts', - category: amountOfPosts % 2 == 0 ? 'category1' : 'category2', - content: "Post $amountOfPosts content", - likes: 0, - reaction: 0, - creator: const TimelinePosterUserModel( - userId: 'test_user', - imageUrl: - 'https://cdn.britannica.com/68/143568-050-5246474F/Donkey.jpg?w=400&h=300&c=crop', - firstName: 'Dirk', - lastName: 'lukassen', - ), - createdAt: DateTime.now(), - reactionEnabled: amountOfPosts % 2 == 0 ? false : true, - imageUrl: amountOfPosts % 3 != 0 - ? 'https://s3-eu-west-1.amazonaws.com/sortlist-core-api/6qpvvqjtmniirpkvp8eg83bicnc2' - : null, - ), - ); -} diff --git a/packages/flutter_timeline/example/lib/main.dart b/packages/flutter_timeline/example/lib/main.dart index 8599ddf..a5781d6 100644 --- a/packages/flutter_timeline/example/lib/main.dart +++ b/packages/flutter_timeline/example/lib/main.dart @@ -1,9 +1,130 @@ -import 'package:example/apps/navigator/app.dart'; +import 'package:example/theme.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_timeline/flutter_timeline.dart'; +import 'package:timeline_repository_interface/timeline_repository_interface.dart'; import 'package:intl/date_symbol_data_local.dart'; -void main() { +void main(List args) { initializeDateFormatting(); - runApp(const NavigatorApp()); + runApp(const MyApp()); } + +var timelineService = TimelineService(); + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: theme, + home: const FlutterTimelineNavigatorUserstory( + currentUserId: "1", + ), + ); + } +} + +// class Titje extends StatelessWidget { +// const Titje({super.key, required this.initialCategory}); +// final String? initialCategory; + +// @override +// Widget build(BuildContext context) { +// return TimelineScreen( +// onTapComments: (post) { +// Navigator.of(context).push(MaterialPageRoute( +// builder: (context) => Detail( +// post: post, +// timelineService: timelineService, +// ))); +// }, +// onTapCreatePost: () { +// Navigator.of(context).push(MaterialPageRoute( +// builder: (context) => ChooseCategory( +// timelineService: timelineService, +// ))); +// }, +// currentUserId: "1", +// options: TimelineOptions(), +// timelineService: timelineService, +// onTapPost: (post) { +// Navigator.of(context).push(MaterialPageRoute( +// builder: (context) => Detail( +// post: post, +// timelineService: timelineService, +// ))); +// }, +// ); +// } +// } + +// class Detail extends StatelessWidget { +// const Detail({super.key, required this.post, required this.timelineService}); + +// final TimelinePost post; +// final TimelineService timelineService; + +// @override +// Widget build(BuildContext context) { +// return TimelinePostDetailScreen( +// onTapComments: (post) {}, +// onTapPost: (post) {}, +// post: post, +// timelineService: timelineService, +// options: TimelineOptions(), +// currentUserId: "1"); +// } +// } + +// class ChooseCategory extends StatelessWidget { +// const ChooseCategory({super.key, required this.timelineService}); +// final TimelineService timelineService; + +// @override +// Widget build(BuildContext context) { +// return TimelineChooseCategoryScreen( +// options: TimelineOptions(), +// timelineService: timelineService, +// ontapCategory: (category) { +// Navigator.of(context).push(MaterialPageRoute( +// builder: (context) => +// AddInformation(timelineService: timelineService))); +// }, +// ); +// } +// } + +// class AddInformation extends StatelessWidget { +// const AddInformation({super.key, required this.timelineService}); +// final TimelineService timelineService; + +// @override +// Widget build(BuildContext context) { +// return TimelineAddPostInformationScreen( +// timelineService: timelineService, +// onTaponTapOverview: () { +// Navigator.of(context).push(MaterialPageRoute( +// builder: (context) => Overview(timelineService: timelineService))); +// }, +// ); +// } +// } + +// class Overview extends StatelessWidget { +// const Overview({super.key, required this.timelineService}); +// final TimelineService timelineService; + +// @override +// Widget build(BuildContext context) { +// return TimelinePostOverview( +// timelineService: timelineService, +// options: TimelineOptions(), +// onTapCreatePost: (post) { +// Navigator.of(context).pushReplacement(MaterialPageRoute( +// builder: (context) => Titje(initialCategory: post.category))); +// }, +// ); +// } +// } diff --git a/packages/flutter_timeline/example/lib/theme.dart b/packages/flutter_timeline/example/lib/theme.dart new file mode 100644 index 0000000..9931a67 --- /dev/null +++ b/packages/flutter_timeline/example/lib/theme.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; + +const Color primaryColor = Color(0xFF71C6D1); + +ThemeData theme = ThemeData( + actionIconTheme: ActionIconThemeData( + backButtonIconBuilder: (context) { + return const Icon(Icons.arrow_back_ios_new_rounded); + }, + ), + scaffoldBackgroundColor: const Color(0xFFFAF9F6), + primaryColor: primaryColor, + checkboxTheme: CheckboxThemeData( + side: const BorderSide( + color: Color(0xFF8D8D8D), + width: 1, + ), + fillColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.selected)) { + return primaryColor; + } + return const Color(0xFFEEEEEE); + }, + ), + ), + switchTheme: SwitchThemeData( + trackColor: + WidgetStateProperty.resolveWith((Set states) { + if (!states.contains(WidgetState.selected)) { + return const Color(0xFF8D8D8D); + } + return primaryColor; + }), + thumbColor: const WidgetStatePropertyAll( + Colors.white, + ), + ), + appBarTheme: const AppBarTheme( + centerTitle: true, + iconTheme: IconThemeData( + color: Colors.white, + size: 16, + ), + elevation: 0, + backgroundColor: Color(0xFF212121), + titleTextStyle: TextStyle( + fontWeight: FontWeight.w900, + fontSize: 24, + color: Color(0xFF71C6D1), + fontFamily: "Merriweather", + ), + actionsIconTheme: IconThemeData()), + fontFamily: "Merriweather", + useMaterial3: false, + textTheme: const TextTheme( + headlineSmall: TextStyle( + fontWeight: FontWeight.w400, + fontSize: 16, + color: Colors.white, + ), + headlineLarge: TextStyle( + fontWeight: FontWeight.w900, + fontSize: 24, + color: Color(0xFF71C6D1), + ), + + displayLarge: TextStyle( + fontFamily: "Avenir", + fontWeight: FontWeight.w800, + fontSize: 20, + color: Colors.white, + ), + displayMedium: TextStyle( + fontFamily: "Avenir", + fontWeight: FontWeight.w800, + fontSize: 18, + color: Color(0xFF71C6D1), + ), + displaySmall: TextStyle( + fontFamily: "Avenir", + fontWeight: FontWeight.w800, + fontSize: 14, + color: Colors.black, + ), + + // TITLE + titleSmall: TextStyle( + fontFamily: "Avenir", + fontWeight: FontWeight.w800, + fontSize: 14, + color: Colors.white, + ), + titleMedium: TextStyle( + fontFamily: "Avenir", + fontWeight: FontWeight.w800, + fontSize: 16, + color: Colors.black, + ), + titleLarge: TextStyle( + fontFamily: "Avenir", + fontWeight: FontWeight.w800, + fontSize: 20, + color: Colors.black, + ), + + // LABEL + labelSmall: TextStyle( + fontFamily: "Avenir", + fontWeight: FontWeight.w400, + fontSize: 12, + color: Color(0xFF8D8D8D), + ), + + // BODY + bodySmall: TextStyle( + fontFamily: "Avenir", + fontWeight: FontWeight.w400, + fontSize: 14, + color: Colors.black, + ), + bodyMedium: TextStyle( + fontFamily: "Avenir", + fontWeight: FontWeight.w400, + fontSize: 16, + color: Colors.black, + ), + ), + radioTheme: RadioThemeData( + visualDensity: const VisualDensity( + horizontal: 0, + vertical: -2, + ), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + fillColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.selected)) { + return primaryColor; + } + return Colors.black; + }, + ), + ), + colorScheme: const ColorScheme.light( + primary: primaryColor, + ), +); diff --git a/packages/flutter_timeline/example/pubspec.yaml b/packages/flutter_timeline/example/pubspec.yaml index 4f0c7fc..678f1e6 100644 --- a/packages/flutter_timeline/example/pubspec.yaml +++ b/packages/flutter_timeline/example/pubspec.yaml @@ -1,92 +1,30 @@ name: example description: "A new Flutter project." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. +publish_to: "none" + version: 1.0.0+1 environment: - sdk: '>=3.2.3 <4.0.0' + sdk: ^3.5.1 -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.2 flutter_timeline: - path: ../ - intl: ^0.19.0 - + path: ../../flutter_timeline + timeline_repository_interface: + path: ../../timeline_repository_interface + intl: 0.19.0 dev_dependencies: - flutter_test: - sdk: flutter + flutter_lints: ^4.0.0 - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^2.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - assets/ - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages + fonts: + - family: Merriweather + fonts: + - asset: fonts/Merriweather-Regular.ttf + - family: Avenir + fonts: + - asset: fonts/Avenir-Regular.ttf diff --git a/packages/flutter_timeline/example/test/widget_test.dart b/packages/flutter_timeline/example/test/widget_test.dart deleted file mode 100644 index f84cd57..0000000 --- a/packages/flutter_timeline/example/test/widget_test.dart +++ /dev/null @@ -1,29 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:example/apps/widgets/app.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const WidgetApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/packages/flutter_timeline/lib/flutter_timeline.dart b/packages/flutter_timeline/lib/flutter_timeline.dart index 071b8aa..01c4960 100644 --- a/packages/flutter_timeline/lib/flutter_timeline.dart +++ b/packages/flutter_timeline/lib/flutter_timeline.dart @@ -1,12 +1,31 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause +/// flutter_timeline library + +// ignore_for_file: directives_ordering -/// Flutter Timeline library library flutter_timeline; -export 'package:flutter_timeline/src/flutter_timeline_navigator_userstory.dart'; -export 'package:flutter_timeline/src/models/timeline_configuration.dart'; -export 'package:flutter_timeline/src/routes.dart'; -export 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; -export 'package:flutter_timeline_view/flutter_timeline_view.dart'; +/// userstory +export "src/flutter_timeline_navigator_userstory.dart"; + +/// models +export "src/models/timeline_options.dart"; +export "src/models/timeline_translations.dart"; + +/// screens +export "src/screens/timeline_screen.dart"; +export "src/screens/timeline_post_overview.dart"; +export "src/screens/timeline_post_detail_screen.dart"; +export "src/screens/timeline_add_post_information_screen.dart"; +export "src/screens/timeline_choose_category_screen.dart"; + +/// widgets +export "src/widgets/category_list.dart"; +export "src/widgets/category_widget.dart"; +export "src/widgets/comment_section.dart"; +export "src/widgets/image_picker.dart"; +export "src/widgets/post_info_textfield.dart"; +export "src/widgets/post_list.dart"; +export "src/widgets/post_more_options_widget.dart"; +export "src/widgets/reaction_textfield.dart"; +export "src/widgets/tappable_image.dart"; +export "src/widgets/timeline_post.dart"; 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 aaf36e3..ae717ca 100644 --- a/packages/flutter_timeline/lib/src/flutter_timeline_navigator_userstory.dart +++ b/packages/flutter_timeline/lib/src/flutter_timeline_navigator_userstory.dart @@ -1,374 +1,104 @@ -// SPDX-FileCopyrightText: 2024 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause +import "package:flutter/material.dart"; +import "package:flutter_timeline/flutter_timeline.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.dart"; -import 'package:flutter/material.dart'; -import 'package:flutter_timeline/flutter_timeline.dart'; +class FlutterTimelineNavigatorUserstory extends StatefulWidget { + const FlutterTimelineNavigatorUserstory({ + required this.currentUserId, + this.options = const TimelineOptions(), + this.timelineService, + super.key, + }); -/// A widget function that creates a timeline navigator for user stories. -/// -/// This function creates a navigator for displaying user stories on a timeline. -/// It takes a [BuildContext] and an optional [TimelineUserStoryConfiguration] -/// as parameters. If no configuration is provided, default values will be used. -late TimelineUserStoryConfiguration timelineUserStoryConfiguration; + final TimelineOptions options; + final TimelineService? timelineService; + final String currentUserId; -Widget timeLineNavigatorUserStory({ - required BuildContext context, - TimelineUserStoryConfiguration? configuration, -}) { - timelineUserStoryConfiguration = configuration ?? - TimelineUserStoryConfiguration( - userId: 'test_user', - service: TimelineService( - postService: LocalTimelinePostService(), - ), - optionsBuilder: (context) => const TimelineOptions(), - ); - - return _timelineScreenRoute( - config: timelineUserStoryConfiguration, - context: context, - ); + @override + State createState() => + _FlutterTimelineNavigatorUserstoryState(); } -/// A widget function that creates a timeline screen route. -/// -/// This function creates a route for displaying a timeline screen. It takes -/// a [BuildContext] and an optional [TimelineUserStoryConfiguration] as -/// parameters. If no configuration is provided, default values will be used. -Widget _timelineScreenRoute({ - required BuildContext context, - required TimelineUserStoryConfiguration config, - String? initalCategory, -}) { - var timelineScreen = TimelineScreen( - timelineCategory: initalCategory, - userId: config.getUserId?.call(context) ?? config.userId, - allowAllDeletion: config.canDeleteAllPosts?.call(context) ?? false, - onUserTap: (user) => config.onUserTap?.call(context, user), - service: config.service, - options: config.optionsBuilder(context), - onPostTap: (post) async => - config.onPostTap?.call(context, post) ?? - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => _postDetailScreenRoute( - config: config, - context: context, - post: post, - ), - ), - ), - onRefresh: config.onRefresh, - filterEnabled: config.filterEnabled, - postWidgetBuilder: config.postWidgetBuilder, - ); - var theme = Theme.of(context); - var button = FloatingActionButton( - backgroundColor: config - .optionsBuilder(context) - .theme - .postCreationFloatingActionButtonColor ?? - theme.colorScheme.primary, - onPressed: () async { - var selectedCategory = config.service.postService.selectedCategory; - if (selectedCategory != null && selectedCategory.key != null) { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => _postCreationScreenRoute( - config: config, - context: context, - category: selectedCategory, - ), - ), - ); - } else { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => _postCategorySelectionScreen( - config: config, - context: context, - ), - ), - ); - } - }, - shape: const CircleBorder(), - child: const Icon( - Icons.add, - color: Colors.white, - size: 24, - ), - ); +class _FlutterTimelineNavigatorUserstoryState + extends State { + late TimelineService timelineService; - return config.homeOpenPageBuilder?.call(context, timelineScreen, button) ?? - Scaffold( - appBar: AppBar( - title: Text( - config.optionsBuilder(context).translations.timeLineScreenTitle, - style: theme.textTheme.headlineLarge, - ), - ), - body: timelineScreen, - floatingActionButton: button, - ); -} + @override + void initState() { + timelineService = widget.timelineService ?? TimelineService(); + super.initState(); + } -/// A widget function that creates a post detail screen route. -/// -/// This function creates a route for displaying a post detail screen. It takes -/// a [BuildContext], a [TimelinePost], and an optional -/// [TimelineUserStoryConfiguration] as parameters. If no configuration is -/// provided, default values will be used. -Widget _postDetailScreenRoute({ - required BuildContext context, - required TimelinePost post, - required TimelineUserStoryConfiguration config, -}) { - var timelinePostScreen = TimelinePostScreen( - userId: config.getUserId?.call(context) ?? config.userId, - allowAllDeletion: config.canDeleteAllPosts?.call(context) ?? false, - options: config.optionsBuilder(context), - service: config.service, - post: post, - onPostDelete: () async => - config.onPostDelete?.call(context, post) ?? - () async { - await config.service.postService.deletePost(post); - if (context.mounted) { - Navigator.of(context).pop(); + @override + Widget build(BuildContext context) => _timelineScreenWidget(); + + Widget _timelineScreenWidget() => TimelineScreen( + currentUserId: widget.currentUserId, + timelineService: timelineService, + options: widget.options, + onTapComments: (post) async { + var currentUser = await timelineService.getCurrentUser(); + await _push(_timelinePostDetailScreenWidget(post, currentUser)); + }, + onTapCreatePost: () async { + var selectedCategory = timelineService.getSelectedCategory(); + if (selectedCategory?.key != null) { + await _push(_timelineAddpostInformationScreenWidget()); + } else { + await _push(_timelineChooseCategoryScreenWidget()); } - }.call(), - onUserTap: (user) => config.onUserTap?.call(context, user), - ); - - var category = config.service.postService.categories - .firstWhere((element) => element.key == post.category); - - var backButton = IconButton( - color: Colors.white, - icon: const Icon(Icons.arrow_back_ios), - onPressed: () => Navigator.of(context).pop(), - ); - - return config.postViewOpenPageBuilder - ?.call(context, timelinePostScreen, backButton, post, category) ?? - Scaffold( - appBar: AppBar( - iconTheme: Theme.of(context).appBarTheme.iconTheme, - title: Text( - category.title.toLowerCase(), - style: TextStyle( - color: Theme.of(context).primaryColor, - fontSize: 24, - fontWeight: FontWeight.w800, - ), - ), - ), - body: timelinePostScreen, + }, + onTapPost: (post) async { + var currentUser = await timelineService.getCurrentUser(); + if (context.mounted) + await _push(_timelinePostDetailScreenWidget(post, currentUser)); + }, ); -} -/// A widget function that creates a post creation screen route. -/// -/// This function creates a route for displaying a post creation screen. -/// It takes a [BuildContext] and an optional [TimelineUserStoryConfiguration] -/// as parameters. If no configuration is provided, default values will be used. -Widget _postCreationScreenRoute({ - required BuildContext context, - required TimelineCategory category, - required TimelineUserStoryConfiguration config, -}) { - var timelinePostCreationScreen = TimelinePostCreationScreen( - userId: config.getUserId?.call(context) ?? config.userId, - options: config.optionsBuilder(context), - service: config.service, - onPostCreated: (post) async { - var newPost = await config.service.postService.createPost(post); - - if (!context.mounted) return; - if (config.afterPostCreationGoHome) { - await Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => _timelineScreenRoute( - config: config, - context: context, - initalCategory: category.title, - ), - ), - ); - } else { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => _postOverviewScreenRoute( - config: config, - context: context, - post: newPost, - ), - ), - ); - } - }, - onPostOverview: (post) async => Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => _postOverviewScreenRoute( - config: config, - context: context, - post: post, - ), - ), - ), - enablePostOverviewScreen: config.enablePostOverviewScreen, - postCategory: category.key, - ); - - var backButton = IconButton( - icon: const Icon( - Icons.arrow_back_ios, - color: Colors.white, - ), - onPressed: () => Navigator.of(context).pop(), - ); - - return config.postCreationOpenPageBuilder - ?.call(context, timelinePostCreationScreen, backButton) ?? - Scaffold( - appBar: AppBar( - iconTheme: Theme.of(context).appBarTheme.iconTheme, - leading: backButton, - title: Text( - config.optionsBuilder(context).translations.postCreation, - style: TextStyle( - color: Theme.of(context).primaryColor, - fontSize: 24, - fontWeight: FontWeight.w800, - ), - ), - ), - body: timelinePostCreationScreen, - ); -} - -/// A widget function that creates a post overview screen route. -/// -/// This function creates a route for displaying a post overview screen. -/// It takes a [BuildContext], a [TimelinePost], and an optional -/// [TimelineUserStoryConfiguration] as parameters. If no configuration is -/// provided, default values will be used. -Widget _postOverviewScreenRoute({ - required BuildContext context, - required TimelinePost post, - required TimelineUserStoryConfiguration config, -}) { - var timelinePostOverviewWidget = TimelinePostOverviewScreen( - options: config.optionsBuilder(context), - service: config.service, - timelinePost: post, - onPostSubmit: (post) async { - var createdPost = await config.service.postService.createPost(post); - config.onPostCreate?.call(createdPost); - if (context.mounted) { - await Navigator.of(context).pushAndRemoveUntil( - MaterialPageRoute( - builder: (context) => _timelineScreenRoute( - config: config, - context: context, - initalCategory: post.category, - ), - ), - (route) => false, - ); - } - }, - ); - - var backButton = IconButton( - icon: const Icon( - Icons.arrow_back_ios, - color: Colors.white, - ), - onPressed: () async => Navigator.of(context).pop(), - ); - - return config.postOverviewOpenPageBuilder?.call( - context, - timelinePostOverviewWidget, - ) ?? - Scaffold( - appBar: AppBar( - iconTheme: Theme.of(context).appBarTheme.iconTheme, - leading: backButton, - title: Text( - config.optionsBuilder(context).translations.postCreation, - style: TextStyle( - color: Theme.of(context).primaryColor, - fontSize: 24, - fontWeight: FontWeight.w800, - ), - ), - ), - body: timelinePostOverviewWidget, - ); -} - -Widget _postCategorySelectionScreen({ - required BuildContext context, - required TimelineUserStoryConfiguration config, -}) { - var timelineSelectionScreen = TimelineSelectionScreen( - postService: config.service.postService, - options: config.optionsBuilder(context), - categories: config.service.postService.categories, - onCategorySelected: (category) async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => _postCreationScreenRoute( - config: config, - context: context, - category: category, - ), - ), - ); - }, - ); - - var backButton = IconButton( - color: Colors.white, - icon: const Icon(Icons.arrow_back_ios), - onPressed: () async { - Navigator.of(context).pop(); - }, - ); - - return config.categorySelectionOpenPageBuilder - ?.call(context, timelineSelectionScreen) ?? - Scaffold( - appBar: AppBar( - iconTheme: Theme.of(context).appBarTheme.iconTheme, - leading: backButton, - title: Text( - config.optionsBuilder(context).translations.postCreation, - style: TextStyle( - color: Theme.of(context).primaryColor, - fontSize: 24, - fontWeight: FontWeight.w800, - ), - ), - ), - body: timelineSelectionScreen, - ); -} - -Future routeToPostDetail(BuildContext context, TimelinePost post) async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => _postDetailScreenRoute( - config: timelineUserStoryConfiguration, - context: context, + Widget _timelinePostDetailScreenWidget( + TimelinePost post, + TimelineUser currentUser, + ) => + TimelinePostDetailScreen( + currentUserId: widget.currentUserId, + timelineService: timelineService, + currentUser: currentUser, + options: widget.options, post: post, - ), - ), - ); + ); + + Widget _timelineChooseCategoryScreenWidget() => TimelineChooseCategoryScreen( + timelineService: timelineService, + options: widget.options, + ontapCategory: (category) async { + await _push(_timelineAddpostInformationScreenWidget()); + }, + ); + + Widget _timelineAddpostInformationScreenWidget() => + TimelineAddPostInformationScreen( + timelineService: timelineService, + options: widget.options, + onTapOverview: () async { + await _push(_timelinePostOverviewWidget()); + }, + ); + + Widget _timelinePostOverviewWidget() => TimelinePostOverview( + timelineService: timelineService, + options: widget.options, + onTapCreatePost: (post) async { + await Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (context) => _timelineScreenWidget(), + ), + (route) => route.isFirst, + ); + }, + ); + + Future _push(Widget screen) async { + await Navigator.of(context) + .push(MaterialPageRoute(builder: (context) => screen)); + } } diff --git a/packages/flutter_timeline/lib/src/models/timeline_configuration.dart b/packages/flutter_timeline/lib/src/models/timeline_configuration.dart deleted file mode 100644 index 8dcc2a5..0000000 --- a/packages/flutter_timeline/lib/src/models/timeline_configuration.dart +++ /dev/null @@ -1,165 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -import 'package:flutter/material.dart'; -import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; -import 'package:flutter_timeline_view/flutter_timeline_view.dart'; - -/// Configuration class for defining user-specific settings and callbacks for a -/// timeline user story. -/// -/// This class holds various parameters to customize the behavior and appearance -/// of a user story timeline. -@immutable -class TimelineUserStoryConfiguration { - /// Constructs a [TimelineUserStoryConfiguration] with the specified - /// parameters. - /// - /// [service] is the TimelineService responsible for fetching user story data. - /// - /// [optionsBuilder] is a function that builds TimelineOptions based on the - /// given [BuildContext]. - /// - /// [userId] is the ID of the user associated with this user story - /// configuration. Default is 'test_user'. - /// - /// [openPageBuilder] is a function that defines the behavior when a page - /// needs to be opened. This function should accept a [BuildContext] and a - /// child widget. - /// - /// [onPostTap] is a callback function invoked when a timeline post is - /// tapped. It should accept a [BuildContext] and the tapped post. - /// - /// [onUserTap] is a callback function invoked when the user's profile is - /// tapped. It should accept a [BuildContext] and the user ID of the tapped - /// user. - /// - /// [onPostDelete] is a callback function invoked when a post deletion is - /// requested. It should accept a [BuildContext] and the post widget. This - /// function can return a widget to be displayed after the post is deleted. - /// - /// [filterEnabled] determines whether filtering functionality is enabled for - /// this user story timeline. Default is false. - /// - /// [postWidgetBuilder] is a function that builds a widget for a timeline - /// post. It should accept a [TimelinePost] and return a widget representing - /// that post. - const TimelineUserStoryConfiguration({ - required this.service, - required this.optionsBuilder, - this.getUserId, - this.serviceBuilder, - this.canDeleteAllPosts, - this.userId = 'test_user', - this.homeOpenPageBuilder, - this.postCreationOpenPageBuilder, - this.postViewOpenPageBuilder, - this.postOverviewOpenPageBuilder, - this.onPostTap, - this.onUserTap, - this.onRefresh, - this.onPostDelete, - this.filterEnabled = false, - this.postWidgetBuilder, - this.afterPostCreationGoHome = false, - this.enablePostOverviewScreen = true, - this.categorySelectionOpenPageBuilder, - this.onPostCreate, - }); - - /// The ID of the user associated with this user story configuration. - final String userId; - - /// A function to get the userId only when needed and with a context - final String Function(BuildContext context)? getUserId; - - /// A function to determine if a user can delete posts that is called - /// when needed - final bool Function(BuildContext context)? canDeleteAllPosts; - - /// The TimelineService responsible for fetching user story data. - final TimelineService service; - - /// A function to get the timeline service only when needed and with a context - final TimelineService Function(BuildContext context)? serviceBuilder; - - /// A function that builds TimelineOptions based on the given BuildContext. - final TimelineOptions Function(BuildContext context) optionsBuilder; - - /// Open page builder function for the home page. This function accepts - /// a [BuildContext], a child widget, and a FloatingActionButton which can - /// route to the post creation page. - - final Function( - BuildContext context, - Widget child, - FloatingActionButton? button, - )? homeOpenPageBuilder; - - /// Open page builder function for the post creation page. This function - /// accepts a [BuildContext], a child widget, and an IconButton which can - /// route to the home page. - - final Function( - BuildContext context, - Widget child, - IconButton? button, - )? postCreationOpenPageBuilder; - - /// Open page builder function for the post view page. This function accepts - /// a [BuildContext], a child widget, and an IconButton which can route to the - /// home page. - - final Function( - BuildContext context, - Widget child, - IconButton? button, - TimelinePost post, - TimelineCategory? category, - )? postViewOpenPageBuilder; - - /// Open page builder function for the post overview page. This function - /// accepts a [BuildContext], a child widget, and an IconButton which can - /// route to the home page. - - final Function( - BuildContext context, - Widget child, - )? postOverviewOpenPageBuilder; - - /// A callback function invoked when a timeline post is tapped. - final Function(BuildContext context, TimelinePost post)? onPostTap; - - /// A callback function invoked when the user's profile is tapped. - final Function(BuildContext context, String userId)? onUserTap; - - /// A callback function invoked when the timeline is refreshed by pulling down - final Function(BuildContext context, String? category)? onRefresh; - - /// A callback function invoked when a post deletion is requested. - final Widget Function(BuildContext context, TimelinePost post)? onPostDelete; - - /// Determines whether filtering functionality is enabled for this user story - /// timeline. - final bool filterEnabled; - - /// A function that builds a widget for a timeline post. - final Widget Function(TimelinePost post)? postWidgetBuilder; - - /// Boolean to enable timeline post overview screen before submitting - final bool enablePostOverviewScreen; - - /// Boolean to enable redirect to home after post creation. - /// If false, it will redirect to created post screen - final bool afterPostCreationGoHome; - - /// Open page builder function for the category selection page. This function - /// accepts a [BuildContext] and a child widget. - final Function( - BuildContext context, - Widget child, - )? categorySelectionOpenPageBuilder; - - final Function(TimelinePost post)? onPostCreate; -} diff --git a/packages/flutter_timeline/lib/src/models/timeline_options.dart b/packages/flutter_timeline/lib/src/models/timeline_options.dart new file mode 100644 index 0000000..c3c5ac2 --- /dev/null +++ b/packages/flutter_timeline/lib/src/models/timeline_options.dart @@ -0,0 +1,179 @@ +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/flutter_timeline.dart"; +import "package:intl/intl.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.dart"; + +class TimelineOptions { + const TimelineOptions({ + this.translations = const TimelineTranslations(), + this.everyoneCanDelete = false, + this.onTapLike, + this.onTapUnlike, + this.onPostDelete, + this.userAvatarBuilder = _defaultUserAvatarBuilder, + this.iconSize = 24, + this.iconColor = Colors.black, + this.doubleTapToLike = false, + this.userNameBuilder = _defaultNameBuilder, + this.floatingActionButtonBuilder = _defaultFloatingActionButton, + this.allowCreatingCategories = true, + this.initialCategoryId, + this.likeIcon = Icons.favorite_outline, + this.likedIcon = Icons.favorite, + this.commentIcon = Icons.chat_bubble_outline, + this.imagePickerTheme, + this.dateFormat = _defaultDateFormat, + this.buttonBuilder = _defaultButtonBuilder, + this.postBuilder, + this.timelineScreenDrawer, + this.timelineScreenAppBarBuilder, + this.onCreatePost, + }); + + // Builders + final UserAvatarBuilder userAvatarBuilder; + final UserNameBuilder userNameBuilder; + final DateFormat Function(BuildContext context) dateFormat; + final FloatingActionButtonBuilder floatingActionButtonBuilder; + final ButtonBuilder buttonBuilder; + final PostBuilder? postBuilder; + + //general + final TimelineTranslations translations; + + // TimelinePostWidget + final bool everyoneCanDelete; + final VoidCallback? onTapLike; + final VoidCallback? onTapUnlike; + final VoidCallback? onPostDelete; + final Function(TimelinePost post)? onCreatePost; + final double iconSize; + final Color iconColor; + final IconData likeIcon; + final IconData likedIcon; + final IconData commentIcon; + final bool doubleTapToLike; + final bool allowCreatingCategories; + final String? initialCategoryId; + final ImagePickerTheme? imagePickerTheme; + final Widget? timelineScreenDrawer; + final AppBarBuilder? timelineScreenAppBarBuilder; +} + +Widget _defaultFloatingActionButton( + Function() onPressed, + BuildContext context, +) { + var theme = Theme.of(context); + return FloatingActionButton.large( + backgroundColor: theme.primaryColor, + onPressed: onPressed, + child: Icon( + Icons.add, + size: 44, + color: theme.colorScheme.onPrimary, + ), + ); +} + +Widget _defaultUserAvatarBuilder(TimelineUser? user, double size) { + if (user == null || user.imageUrl == null) { + return CircleAvatar( + radius: size / 2, + child: const Icon( + Icons.person, + ), + ); + } + return Container( + height: size, + width: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + image: DecorationImage( + image: CachedNetworkImageProvider( + user.imageUrl!, + ), + fit: BoxFit.cover, + ), + ), + ); +} + +Widget _defaultNameBuilder( + TimelineUser? user, + String anonymousUserText, + BuildContext context, +) { + if (user == null || user.fullName == null) { + return Text(anonymousUserText); + } + return Text( + user.fullName!, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.black, + ), + ); +} + +Widget _defaultButtonBuilder({ + required String title, + required Function() onPressed, + required BuildContext context, +}) { + var theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: FilledButton( + style: ElevatedButton.styleFrom( + maximumSize: const Size(254, 50), + minimumSize: const Size(254, 50), + ), + onPressed: onPressed, + child: Text( + title, + style: theme.textTheme.displayLarge, + ), + ), + ); +} + +DateFormat _defaultDateFormat(BuildContext context) => DateFormat( + "dd/MM/yyyy 'at' HH:mm", + Localizations.localeOf(context).languageCode, + ); + +typedef UserAvatarBuilder = Widget Function( + TimelineUser? user, + double size, +); + +typedef UserNameBuilder = Widget Function( + TimelineUser? user, + String anonymousUserText, + BuildContext context, +); + +typedef FloatingActionButtonBuilder = Widget Function( + Function() onPressed, + BuildContext context, +); + +typedef ButtonBuilder = Widget Function({ + required String title, + required Function() onPressed, + required BuildContext context, +}); + +typedef PostBuilder = Widget Function({ + required TimelinePost post, + required Function(TimelinePost) onTap, + required BuildContext context, +}); + +typedef AppBarBuilder = PreferredSizeWidget Function( + BuildContext context, + String title, +); diff --git a/packages/flutter_timeline/lib/src/models/timeline_translations.dart b/packages/flutter_timeline/lib/src/models/timeline_translations.dart new file mode 100644 index 0000000..5156a7a --- /dev/null +++ b/packages/flutter_timeline/lib/src/models/timeline_translations.dart @@ -0,0 +1,58 @@ +class TimelineTranslations { + const TimelineTranslations({ + this.timelineTitle = "iconinstagram", + this.viewPostTitle = "View post", + this.deletePostTitle = "Delete", + this.oneLikeTitle = "like", + this.multipleLikesTitle = "likes", + this.anonymousUser = "Anonymous User", + this.commentFieldHint = "Write your comment here...", + this.commentsTitle = "Comments", + this.allowCommentsYes = "Yes", + this.allowCommentsNo = "No", + this.allowCommentsTitle = "Are people allowed to comment?", + this.allowCommentsDescription = + "Indicate whether people are allowed to respond", + this.uploadimageTitle = "Upload image", + this.uploadImageDescription = "Upload an image to your message", + this.postTitle = "Title", + this.postTitleHint = "Title...", + this.contentTitle = "Content", + this.contentDescription = "What do you want to share?", + this.contentTitleHint = "Content...", + this.titleErrorText = "Please enter a title", + this.contentErrorText = "Please enter content", + this.addPost = "Add post", + this.overviewButton = "Overview", + this.chooseCategory = "Choose a category", + this.addCategory = "Add category", + this.postButtonTitle = "Post", + }); + + final String timelineTitle; + final String viewPostTitle; + final String deletePostTitle; + final String oneLikeTitle; + final String multipleLikesTitle; + final String anonymousUser; + final String commentFieldHint; + final String commentsTitle; + final String allowCommentsYes; + final String allowCommentsNo; + final String allowCommentsTitle; + final String allowCommentsDescription; + final String uploadimageTitle; + final String uploadImageDescription; + final String postTitle; + final String postTitleHint; + final String contentTitle; + final String contentDescription; + final String contentTitleHint; + final String titleErrorText; + final String contentErrorText; + final String addPost; + final String overviewButton; + final String chooseCategory; + final String addCategory; + final String postButtonTitle; +} diff --git a/packages/flutter_timeline/lib/src/routes.dart b/packages/flutter_timeline/lib/src/routes.dart deleted file mode 100644 index 12e8712..0000000 --- a/packages/flutter_timeline/lib/src/routes.dart +++ /dev/null @@ -1,16 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -mixin TimelineUserStoryRoutes { - static const String timelineHome = '/timeline'; - static const String timelineView = '/timeline-view/:post'; - static String timelineViewPath(String postId) => '/timeline-view/$postId'; - static String timelinepostCreation(String category) => - '/timeline-post-creation/$category'; - - static const String timelinePostCreation = - '/timeline-post-creation/:category'; - static String timelinePostOverview = '/timeline-post-overview'; - static String timelineCategorySelection = '/timeline-category-selection'; -} diff --git a/packages/flutter_timeline/lib/src/screens/timeline_add_post_information_screen.dart b/packages/flutter_timeline/lib/src/screens/timeline_add_post_information_screen.dart new file mode 100644 index 0000000..95f0546 --- /dev/null +++ b/packages/flutter_timeline/lib/src/screens/timeline_add_post_information_screen.dart @@ -0,0 +1,209 @@ +import "dart:typed_data"; + +import "package:flutter/material.dart"; +import "package:flutter_timeline/flutter_timeline.dart"; +import "package:flutter_timeline/src/widgets/image_picker.dart"; +import "package:flutter_timeline/src/widgets/post_info_textfield.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.dart"; + +class TimelineAddPostInformationScreen extends StatefulWidget { + const TimelineAddPostInformationScreen({ + required this.timelineService, + required this.onTapOverview, + required this.options, + super.key, + }); + final TimelineService timelineService; + final void Function() onTapOverview; + final TimelineOptions options; + + @override + State createState() => + _TimelineAddPostInformationScreenState(); +} + +class _TimelineAddPostInformationScreenState + extends State { + final titleController = TextEditingController(); + final contentController = TextEditingController(); + bool allowedToComment = true; + Uint8List? image; + final _formKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + var category = + widget.timelineService.categoryRepository.getSelectedCategory(); + + return Scaffold( + appBar: AppBar( + title: Text( + category?.title.toLowerCase() ?? widget.options.translations.addPost, + ), + ), + body: CustomScrollView( + shrinkWrap: true, + slivers: [ + SliverList( + delegate: SliverChildListDelegate([ + Form( + key: _formKey, + child: Padding( + padding: + const EdgeInsets.symmetric(vertical: 20, horizontal: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.options.translations.postTitle, + style: theme.textTheme.titleMedium, + ), + PostInfoTextfield( + expands: false, + maxLines: 1, + controller: titleController, + hintText: widget.options.translations.postTitleHint, + validator: (value) { + if (value == null || value.isEmpty) { + return widget.options.translations.titleErrorText; + } + return null; + }, + ), + const SizedBox( + height: 16, + ), + Text( + widget.options.translations.contentTitle, + style: theme.textTheme.titleMedium, + ), + Text( + widget.options.translations.contentDescription, + style: theme.textTheme.bodySmall, + ), + PostInfoTextfield( + expands: false, + maxLines: 1, + controller: contentController, + hintText: widget.options.translations.contentTitleHint, + validator: (value) { + if (value == null || value.isEmpty) { + return widget.options.translations.contentErrorText; + } + return null; + }, + ), + const SizedBox( + height: 16, + ), + Text( + widget.options.translations.uploadimageTitle, + style: theme.textTheme.titleMedium, + ), + Text( + widget.options.translations.uploadImageDescription, + style: theme.textTheme.bodySmall, + ), + const SizedBox( + height: 12, + ), + ImagePickerWidget( + onImageChanged: (pickedImage) { + image = pickedImage; + setState(() {}); + }, + ), + const SizedBox( + height: 24, + ), + Text( + widget.options.translations.allowCommentsTitle, + style: theme.textTheme.titleMedium, + ), + Text( + widget.options.translations.allowCommentsDescription, + style: theme.textTheme.bodySmall, + ), + Row( + children: [ + Radio( + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + visualDensity: const VisualDensity(horizontal: -4), + value: true, + groupValue: allowedToComment, + onChanged: (value) { + setState(() { + allowedToComment = true; + }); + }, + ), + const SizedBox( + width: 8, + ), + Text(widget.options.translations.allowCommentsYes), + Radio( + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.standard, + value: false, + groupValue: allowedToComment, + onChanged: (value) { + setState(() { + allowedToComment = false; + }); + }, + ), + Text(widget.options.translations.allowCommentsNo), + ], + ), + ], + ), + ), + ), + ]), + ), + SliverFillRemaining( + hasScrollBody: false, + fillOverscroll: false, + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + widget.options.buttonBuilder( + title: widget.options.translations.overviewButton, + onPressed: () async { + if (!_formKey.currentState!.validate()) { + return; + } + var user = await widget.timelineService.getCurrentUser(); + widget.timelineService.setCurrentPost( + TimelinePost( + id: "", + creatorId: user.userId, + title: titleController.text, + content: contentController.text, + image: image, + likes: 0, + reaction: 0, + createdAt: DateTime.now(), + reactionEnabled: allowedToComment, + category: category?.key, + reactions: [], + likedBy: [], + creator: user, + ), + ); + widget.onTapOverview(); + }, + context: context, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/packages/flutter_timeline/lib/src/screens/timeline_choose_category_screen.dart b/packages/flutter_timeline/lib/src/screens/timeline_choose_category_screen.dart new file mode 100644 index 0000000..7504831 --- /dev/null +++ b/packages/flutter_timeline/lib/src/screens/timeline_choose_category_screen.dart @@ -0,0 +1,199 @@ +import "package:flutter/material.dart"; +import "package:flutter_timeline/flutter_timeline.dart"; +import "package:flutter_timeline/src/widgets/post_info_textfield.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.dart"; + +class TimelineChooseCategoryScreen extends StatelessWidget { + const TimelineChooseCategoryScreen({ + required this.timelineService, + required this.ontapCategory, + required this.options, + super.key, + }); + final TimelineService timelineService; + final Function(TimelineCategory category) ontapCategory; + final TimelineOptions options; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var newCategoryController = TextEditingController(); + return Scaffold( + appBar: AppBar( + title: Text( + options.translations.addPost, + style: theme.textTheme.headlineLarge, + ), + ), + body: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + options.translations.chooseCategory, + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 16), + StreamBuilder( + stream: timelineService.getCategories(), + builder: (context, snapshot) { + if (snapshot.hasData) { + var categories = snapshot.data; + return Column( + children: [ + ...categories! + .where((category) => category.key != null) + .map( + (category) => CategoryOption( + category: category.title, + onTap: () { + timelineService.selectCategory(category.key); + + ontapCategory.call(category); + }, + ), + ), + if (options.allowCreatingCategories) + CategoryOption( + addCategory: true, + category: options.translations.addCategory, + onTap: () async { + /// shop dialog to add category + + await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: theme.scaffoldBackgroundColor, + content: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + options.translations.addCategory, + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 16), + PostInfoTextfield( + controller: newCategoryController, + hintText: "Category...", + validator: (p0) { + if (p0 == null || p0.isEmpty) { + return "Category cannot be empty"; + } + return null; + }, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: options.buttonBuilder( + title: options.translations.addCategory, + context: context, + onPressed: () async { + await timelineService.createCategory( + TimelineCategory( + key: newCategoryController.text + .toLowerCase(), + title: newCategoryController.text, + ), + ); + Navigator.of(context).pop(); + }, + ), + ), + TextButton( + child: Text( + "Cancel", + style: theme.textTheme.titleMedium, + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), + ), + ); + }, + ), + ], + ); + } + return const CircularProgressIndicator(); + }, + ), + ], + ), + ), + ); + } +} + +class CategoryOption extends StatelessWidget { + const CategoryOption({ + required this.category, + required this.onTap, + this.addCategory = false, + super.key, + }); + final String category; + final bool addCategory; + final Function() onTap; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: InkWell( + onTap: onTap, + child: Row( + children: [ + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + width: 2, + color: addCategory + ? Colors.black.withOpacity(0.3) + : theme.primaryColor, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + if (addCategory) ...[ + const SizedBox(width: 8), + Icon( + Icons.add, + color: addCategory + ? Colors.black.withOpacity(0.3) + : theme.primaryColor, + ), + ], + Padding( + padding: EdgeInsets.symmetric( + vertical: 16, + horizontal: addCategory ? 8 : 16, + ), + child: Text( + category, + style: theme.textTheme.titleMedium?.copyWith( + color: addCategory + ? Colors.black.withOpacity(0.3) + : Colors.black, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/flutter_timeline/lib/src/screens/timeline_post_detail_screen.dart b/packages/flutter_timeline/lib/src/screens/timeline_post_detail_screen.dart new file mode 100644 index 0000000..d0275c6 --- /dev/null +++ b/packages/flutter_timeline/lib/src/screens/timeline_post_detail_screen.dart @@ -0,0 +1,111 @@ +import "package:flutter/material.dart"; +import "package:flutter_svg/svg.dart"; +import "package:flutter_timeline/flutter_timeline.dart"; +import "package:flutter_timeline/src/widgets/reaction_textfield.dart"; +import "package:flutter_timeline/src/widgets/timeline_post.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.dart"; + +class TimelinePostDetailScreen extends StatefulWidget { + const TimelinePostDetailScreen({ + required this.post, + required this.timelineService, + required this.options, + required this.currentUserId, + required this.currentUser, + super.key, + }); + + final TimelinePost post; + final TimelineService timelineService; + final TimelineOptions options; + final String currentUserId; + final TimelineUser? currentUser; + + @override + State createState() => + _TimelinePostDetailScreenState(); +} + +class _TimelinePostDetailScreenState extends State { + final TextEditingController _commentController = TextEditingController(); + TimelineCategory? selectedCategory; + + @override + void initState() { + selectedCategory = widget.timelineService.categoryRepository + .selectCategory(widget.post.category); + super.initState(); + } + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: Text( + selectedCategory?.key == null + ? widget.timelineService + .getCategory(widget.post.category) + ?.title ?? + "" + : selectedCategory?.title ?? "", + style: theme.textTheme.headlineLarge, + ), + ), + body: Stack( + children: [ + SingleChildScrollView( + child: TimelinePostWidget( + post: widget.post, + timelineService: widget.timelineService, + options: widget.options, + isInDetialView: true, + currentUserId: widget.currentUserId, + onTapPost: (post) {}, + onTapComments: (post) {}, + ), + ), + if (widget.post.reactionEnabled) + Align( + alignment: Alignment.bottomCenter, + child: ReactionTextfield( + controller: _commentController, + options: widget.options, + user: widget.currentUser, + suffixIcon: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: IconButton( + onPressed: () async { + var comment = _commentController.text; + if (comment.isNotEmpty) { + var reaction = TimelinePostReaction( + id: DateTime.now().millisecondsSinceEpoch.toString(), + postId: widget.post.id, + creatorId: widget.currentUserId, + createdAt: DateTime.now(), + reaction: comment, + likedBy: [], + ); + await widget.timelineService.postRepository + .createReaction( + widget.post, + reaction, + ); + _commentController.clear(); + setState(() {}); + } + }, + icon: SvgPicture.asset( + "assets/send.svg", + package: "flutter_timeline", + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/packages/flutter_timeline/lib/src/screens/timeline_post_overview.dart b/packages/flutter_timeline/lib/src/screens/timeline_post_overview.dart new file mode 100644 index 0000000..444c444 --- /dev/null +++ b/packages/flutter_timeline/lib/src/screens/timeline_post_overview.dart @@ -0,0 +1,78 @@ +import "package:flutter/material.dart"; +import "package:flutter_timeline/flutter_timeline.dart"; +import "package:flutter_timeline/src/widgets/timeline_post.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.dart"; + +class TimelinePostOverview extends StatefulWidget { + const TimelinePostOverview({ + required this.timelineService, + required this.options, + required this.onTapCreatePost, + super.key, + }); + + final TimelineService timelineService; + final TimelineOptions options; + final Function(TimelinePost post) onTapCreatePost; + + @override + State createState() => _TimelinePostOverviewState(); +} + +class _TimelinePostOverviewState extends State { + bool isLoading = false; + @override + Widget build(BuildContext context) { + var currentPost = widget.timelineService.getCurrentPost(); + return Scaffold( + appBar: AppBar( + title: Text( + widget.options.translations.addPost, + ), + ), + body: CustomScrollView( + shrinkWrap: true, + slivers: [ + SliverList( + delegate: SliverChildListDelegate([ + Column( + children: [ + TimelinePostWidget( + timelineService: widget.timelineService, + post: currentPost, + options: widget.options, + currentUserId: currentPost.creatorId, + onTapPost: (post) {}, + onTapComments: (post) {}, + isInDetialView: true, + isInPostOverview: true, + ), + ], + ), + ]), + ), + SliverFillRemaining( + hasScrollBody: false, + fillOverscroll: false, + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + widget.options.buttonBuilder( + title: widget.options.translations.postButtonTitle, + onPressed: () async { + if (isLoading) return; + isLoading = true; + await widget.timelineService.createPost(currentPost); + widget.options.onCreatePost?.call(currentPost); + widget.onTapCreatePost(currentPost); + }, + context: context, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/packages/flutter_timeline/lib/src/screens/timeline_screen.dart b/packages/flutter_timeline/lib/src/screens/timeline_screen.dart new file mode 100644 index 0000000..b5498f6 --- /dev/null +++ b/packages/flutter_timeline/lib/src/screens/timeline_screen.dart @@ -0,0 +1,112 @@ +import "package:flutter/material.dart"; +import "package:flutter_timeline/src/models/timeline_options.dart"; +import "package:flutter_timeline/src/widgets/category_list.dart"; +import "package:flutter_timeline/src/widgets/post_list.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.dart"; + +class TimelineScreen extends StatefulWidget { + const TimelineScreen({ + required this.options, + required this.timelineService, + required this.onTapPost, + required this.currentUserId, + required this.onTapComments, + required this.onTapCreatePost, + super.key, + }); + final TimelineService timelineService; + final TimelineOptions options; + final Function(TimelinePost post) onTapPost; + final String currentUserId; + final Function(TimelinePost post) onTapComments; + final Function() onTapCreatePost; + + @override + State createState() => _TimelineScreenState(); +} + +class _TimelineScreenState extends State { + final ScrollController _scrollController = ScrollController(); + bool _isOnTop = true; + List categories = []; + + @override + void initState() { + _scrollController.addListener(_updateIsOnTop); + if (widget.timelineService.getSelectedCategory() == null) { + widget.timelineService.selectCategory(widget.options.initialCategoryId); + } + super.initState(); + } + + void _updateIsOnTop() { + setState(() { + _isOnTop = _scrollController.position.pixels < 0.1; + }); + } + + @override + Widget build(BuildContext context) { + var translations = widget.options.translations; + var theme = Theme.of(context); + return Scaffold( + drawer: widget.options.timelineScreenDrawer, + floatingActionButton: widget.options + .floatingActionButtonBuilder(widget.onTapCreatePost, context), + appBar: widget.options.timelineScreenAppBarBuilder + ?.call(context, translations.timelineTitle) ?? + AppBar( + title: Text( + translations.timelineTitle, + style: theme.textTheme.headlineLarge, + ), + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StreamBuilder( + stream: widget.timelineService.getCategories(), + builder: (context, snapshot) { + if (snapshot.hasData) { + categories = snapshot.data!; + + return CategoryList( + selectedCategory: + widget.timelineService.getSelectedCategory(), + categories: categories, + isOnTop: _isOnTop, + onTap: (category) { + widget.timelineService.selectCategory(category.key); + setState(() {}); + }, + ); + } else { + return const CircularProgressIndicator(); + } + }, + ), + StreamBuilder( + stream: widget.timelineService.postRepository + .getPosts(widget.timelineService.getSelectedCategory()?.key), + builder: (context, snapshot) { + if (snapshot.hasData) { + var posts = snapshot.data!; + return PostList( + timelineService: widget.timelineService, + currentUserId: widget.currentUserId, + controller: _scrollController, + onTapPost: widget.onTapPost, + onTapComments: widget.onTapComments, + options: widget.options, + posts: posts, + ); + } else { + return const CircularProgressIndicator(); + } + }, + ), + ], + ), + ); + } +} diff --git a/packages/flutter_timeline/lib/src/widgets/category_list.dart b/packages/flutter_timeline/lib/src/widgets/category_list.dart new file mode 100644 index 0000000..1abbacd --- /dev/null +++ b/packages/flutter_timeline/lib/src/widgets/category_list.dart @@ -0,0 +1,57 @@ +import "package:flutter/material.dart"; +import "package:flutter_timeline/src/widgets/category_widget.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.dart"; +import "package:collection/collection.dart"; + +class CategoryList extends StatefulWidget { + const CategoryList({ + required this.categories, + required this.onTap, + required this.isOnTop, + required this.selectedCategory, + super.key, + }); + + final List categories; + final Function(TimelineCategory) onTap; + final bool isOnTop; + final TimelineCategory? selectedCategory; + + @override + State createState() => _CategoryListState(); +} + +class _CategoryListState extends State { + TimelineCategory? selectedCategory; + + @override + void initState() { + selectedCategory = widget.selectedCategory ?? widget.categories.firstOrNull; + super.initState(); + } + + @override + Widget build(BuildContext context) => SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + for (var i = 0; i < widget.categories.length; i++) + CategoryWidget( + category: widget.categories[i], + onTap: (category) { + widget.onTap(category); + setState(() { + selectedCategory = category; + }); + }, + isOnTop: widget.isOnTop, + selected: selectedCategory?.key == widget.categories[i].key, + ), + ], + ), + ), + ); +} diff --git a/packages/flutter_timeline/lib/src/widgets/category_widget.dart b/packages/flutter_timeline/lib/src/widgets/category_widget.dart new file mode 100644 index 0000000..e14733d --- /dev/null +++ b/packages/flutter_timeline/lib/src/widgets/category_widget.dart @@ -0,0 +1,134 @@ +import "package:flutter/material.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.dart"; + +class CategoryWidget extends StatelessWidget { + const CategoryWidget({ + required this.category, + required this.onTap, + required this.isOnTop, + required this.selected, + super.key, + }); + + final TimelineCategory category; + final Function(TimelineCategory) onTap; + final bool isOnTop; + final bool selected; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + return InkWell( + onTap: () => onTap(category), + child: AnimatedCrossFade( + crossFadeState: + isOnTop ? CrossFadeState.showFirst : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 100), + firstChild: ExpandedCategoryWidget( + selected: selected, + theme: theme, + category: category, + ), + secondChild: CollapsedCategoryWidget( + selected: selected, + theme: theme, + category: category, + ), + ), + ); + } +} + +class CollapsedCategoryWidget extends StatelessWidget { + const CollapsedCategoryWidget({ + required this.selected, + required this.theme, + required this.category, + super.key, + }); + + final bool selected; + final ThemeData theme; + final TimelineCategory category; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(right: 8), + child: Container( + decoration: BoxDecoration( + color: selected ? theme.primaryColor : theme.colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.primaryColor, + width: 2, + ), + ), + width: 140, + height: 40, + child: Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + maxLines: 1, + category.title, + style: selected + ? theme.textTheme.titleMedium + : theme.textTheme.bodyMedium?.copyWith( + color: selected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ); +} + +class ExpandedCategoryWidget extends StatelessWidget { + const ExpandedCategoryWidget({ + required this.selected, + required this.theme, + required this.category, + super.key, + }); + + final bool selected; + final ThemeData theme; + final TimelineCategory category; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(right: 8), + child: Container( + decoration: BoxDecoration( + color: selected ? theme.primaryColor : theme.colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.primaryColor, + width: 2, + ), + ), + width: 140, + height: 140, + child: Align( + alignment: Alignment.bottomLeft, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + category.title, + style: selected + ? theme.textTheme.titleMedium + : theme.textTheme.bodyMedium?.copyWith( + color: selected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + ), + ), + ), + ), + ), + ); +} diff --git a/packages/flutter_timeline/lib/src/widgets/comment_section.dart b/packages/flutter_timeline/lib/src/widgets/comment_section.dart new file mode 100644 index 0000000..1867ac7 --- /dev/null +++ b/packages/flutter_timeline/lib/src/widgets/comment_section.dart @@ -0,0 +1,118 @@ +import "package:cached_network_image/cached_network_image.dart"; +import "package:flutter/material.dart"; +import "package:flutter_timeline/flutter_timeline.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.dart"; + +class CommentSection extends StatefulWidget { + const CommentSection({ + required this.options, + required this.post, + required this.currentUserId, + required this.timelineService, + super.key, + }); + final TimelineOptions options; + final TimelinePost post; + final String currentUserId; + final TimelineService timelineService; + + @override + State createState() => _CommentSectionState(); +} + +class _CommentSectionState extends State { + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 20, + ), + Text( + widget.options.translations.commentsTitle, + style: theme.textTheme.titleSmall!.copyWith(color: Colors.black), + ), + const SizedBox( + height: 4, + ), + for (TimelinePostReaction reaction in widget.post.reactions ?? []) ...[ + Builder( + builder: (context) => const SizedBox(), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + widget.options.userAvatarBuilder.call(reaction.creator, 24), + const SizedBox(width: 8), + widget.options.userNameBuilder.call( + reaction.creator, + widget.options.translations.anonymousUser, + context, + ), + const SizedBox(width: 8), + if (reaction.imageUrl != null) ...[ + CachedNetworkImage( + imageUrl: reaction.imageUrl!, + ), + ] else ...[ + Flexible( + child: Text( + reaction.reaction ?? "", + style: theme.textTheme.bodySmall!.copyWith( + color: Colors.black, + ), + overflow: TextOverflow.clip, + ), + ), + ], + ], + ), + ), + Builder( + builder: (context) { + var reactionIsLikedByCurrentUser = + reaction.likedBy?.contains(widget.currentUserId) ?? false; + return IconButton( + iconSize: 14, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + icon: Icon( + reactionIsLikedByCurrentUser + ? widget.options.likedIcon + : widget.options.likeIcon, + ), + onPressed: () async { + if (reactionIsLikedByCurrentUser) { + await widget.timelineService.unlikePostReaction( + widget.post, + reaction, + widget.currentUserId, + ); + } else { + await widget.timelineService.likePostReaction( + widget.post, + reaction, + widget.currentUserId, + ); + } + setState(() {}); + }, + ); + }, + ), + ], + ), + const SizedBox( + height: 8, + ), + ], + ], + ); + } +} diff --git a/packages/flutter_timeline/lib/src/widgets/image_picker.dart b/packages/flutter_timeline/lib/src/widgets/image_picker.dart new file mode 100644 index 0000000..3a6609b --- /dev/null +++ b/packages/flutter_timeline/lib/src/widgets/image_picker.dart @@ -0,0 +1,103 @@ +import "dart:typed_data"; + +import "package:dotted_border/dotted_border.dart"; +import "package:flutter/material.dart"; +import "package:flutter_image_picker/flutter_image_picker.dart"; + +class ImagePickerWidget extends StatefulWidget { + const ImagePickerWidget({required this.onImageChanged, super.key}); + + final Function(Uint8List?) onImageChanged; + + @override + State createState() => _ImagePickerWidgetState(); +} + +class _ImagePickerWidgetState extends State { + Uint8List? image; + @override + Widget build(BuildContext context) => Stack( + children: [ + GestureDetector( + onTap: () async { + image = await pickImage(context); + widget.onImageChanged(image); + setState(() {}); + }, + child: DottedBorder( + borderType: BorderType.RRect, + dashPattern: const [6, 6], + color: Colors.grey, + strokeWidth: 3, + child: image == null + ? const SizedBox( + height: 150, + width: double.infinity, + child: Icon(Icons.image, size: 64), + ) + : Image.memory(image!), + ), + ), + if (image != null) ...[ + Positioned( + top: 8, + right: 8, + child: GestureDetector( + onTap: () { + widget.onImageChanged(null); + setState(() { + image = null; + }); + }, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + borderRadius: BorderRadius.circular(8.0), + ), + child: const Icon( + Icons.delete, + color: Colors.white, + ), + ), + ), + ), + ], + ], + ); +} + +Future pickImage(BuildContext context) async { + var theme = Theme.of(context); + var result = await showModalBottomSheet( + context: context, + builder: (context) => Container( + padding: const EdgeInsets.all(20), + color: theme.colorScheme.surface, + child: ImagePicker( + config: const ImagePickerConfig(), + theme: ImagePickerTheme( + titleStyle: theme.textTheme.titleMedium, + iconSize: 40, + selectImageText: "UPLOAD FILE", + makePhotoText: "TAKE PICTURE", + selectImageIcon: const Icon( + size: 40, + Icons.insert_drive_file, + ), + closeButtonBuilder: (onTap) => TextButton( + onPressed: () { + onTap(); + }, + child: Text( + "Cancel", + style: theme.textTheme.bodyMedium!.copyWith( + decoration: TextDecoration.underline, + ), + ), + ), + ), + ), + ), + ); + return result; +} diff --git a/packages/flutter_timeline_view/lib/src/widgets/post_creation_textfield.dart b/packages/flutter_timeline/lib/src/widgets/post_info_textfield.dart similarity index 92% rename from packages/flutter_timeline_view/lib/src/widgets/post_creation_textfield.dart rename to packages/flutter_timeline/lib/src/widgets/post_info_textfield.dart index 9a7e1a0..d2b8c42 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/post_creation_textfield.dart +++ b/packages/flutter_timeline/lib/src/widgets/post_info_textfield.dart @@ -1,7 +1,7 @@ -import 'package:flutter/material.dart'; +import "package:flutter/material.dart"; -class PostCreationTextfield extends StatelessWidget { - const PostCreationTextfield({ +class PostInfoTextfield extends StatelessWidget { + const PostInfoTextfield({ required this.controller, required this.hintText, required this.validator, @@ -30,6 +30,7 @@ class PostCreationTextfield extends StatelessWidget { Widget build(BuildContext context) { var theme = Theme.of(context); return TextFormField( + keyboardType: TextInputType.text, key: fieldKey, validator: validator, style: theme.textTheme.bodySmall, diff --git a/packages/flutter_timeline/lib/src/widgets/post_list.dart b/packages/flutter_timeline/lib/src/widgets/post_list.dart new file mode 100644 index 0000000..f4645b7 --- /dev/null +++ b/packages/flutter_timeline/lib/src/widgets/post_list.dart @@ -0,0 +1,53 @@ +import "package:flutter/material.dart"; +import "package:flutter_timeline/flutter_timeline.dart"; +import "package:flutter_timeline/src/widgets/timeline_post.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.dart"; + +class PostList extends StatelessWidget { + const PostList({ + required this.controller, + required this.posts, + required this.timelineService, + required this.options, + required this.onTapPost, + required this.currentUserId, + required this.onTapComments, + super.key, + }); + + final ScrollController controller; + final List posts; + final TimelineService timelineService; + final TimelineOptions options; + final Function(TimelinePost post) onTapPost; + final String currentUserId; + final Function(TimelinePost post) onTapComments; + + @override + Widget build(BuildContext context) => Expanded( + child: ListView.builder( + controller: controller, + itemCount: posts.length, + itemBuilder: (context, index) { + posts.sort( + (b, a) => a.createdAt.compareTo(b.createdAt), + ); + var post = posts[index]; + // var post = posts[index]; + return options.postBuilder?.call( + context: context, + onTap: onTapPost, + post: post, + ) ?? + TimelinePostWidget( + timelineService: timelineService, + currentUserId: currentUserId, + onTapPost: onTapPost, + options: options, + post: post, + onTapComments: onTapComments, + ); + }, + ), + ); +} diff --git a/packages/flutter_timeline/lib/src/widgets/post_more_options_widget.dart b/packages/flutter_timeline/lib/src/widgets/post_more_options_widget.dart new file mode 100644 index 0000000..5262781 --- /dev/null +++ b/packages/flutter_timeline/lib/src/widgets/post_more_options_widget.dart @@ -0,0 +1,34 @@ +import "package:flutter/material.dart"; +import "package:flutter_timeline/flutter_timeline.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.dart"; + +class MoreOptionsButton extends StatelessWidget { + const MoreOptionsButton({ + required this.timelineService, + required this.post, + required this.options, + super.key, + }); + + final TimelineService timelineService; + final TimelinePost post; + final TimelineOptions options; + + @override + Widget build(BuildContext context) => PopupMenuButton( + onSelected: (value) async { + if (value == "delete") { + options.onPostDelete ?? await timelineService.deletePost(post.id); + } + }, + itemBuilder: (BuildContext context) => >[ + PopupMenuItem( + value: "delete", + child: Text(options.translations.deletePostTitle), + ), + ], + child: const Icon( + Icons.more_horiz_rounded, + ), + ); +} diff --git a/packages/flutter_timeline/lib/src/widgets/reaction_textfield.dart b/packages/flutter_timeline/lib/src/widgets/reaction_textfield.dart new file mode 100644 index 0000000..988f5b4 --- /dev/null +++ b/packages/flutter_timeline/lib/src/widgets/reaction_textfield.dart @@ -0,0 +1,83 @@ +import "package:flutter/material.dart"; +import "package:flutter_timeline/flutter_timeline.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.dart"; + +class ReactionTextfield extends StatelessWidget { + const ReactionTextfield({ + required this.options, + required this.controller, + required this.suffixIcon, + required this.user, + super.key, + }); + + final TimelineUser? user; + final TimelineOptions options; + final TextEditingController controller; + final Widget suffixIcon; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + return ColoredBox( + color: theme.scaffoldBackgroundColor, + child: Padding( + padding: const EdgeInsets.only( + left: 12, + bottom: 20, + right: 16, + top: 20, + ), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: options.userAvatarBuilder( + user, + 26, + ), + ), + Expanded( + child: TextField( + style: theme.textTheme.bodyMedium, + textCapitalization: TextCapitalization.sentences, + controller: controller, + decoration: InputDecoration( + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: const BorderSide( + color: Colors.black, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: const BorderSide( + color: Colors.black, + ), + ), + contentPadding: const EdgeInsets.symmetric( + vertical: 0, + horizontal: 16, + ), + hintText: options.translations.commentFieldHint, + hintStyle: theme.textTheme.bodyMedium!.copyWith( + color: theme.textTheme.bodyMedium!.color!.withOpacity(0.5), + ), + fillColor: Colors.white, + filled: true, + border: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(25), + ), + borderSide: BorderSide.none, + ), + suffixIcon: suffixIcon, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/flutter_timeline_view/lib/src/widgets/tappable_image.dart b/packages/flutter_timeline/lib/src/widgets/tappable_image.dart similarity index 84% rename from packages/flutter_timeline_view/lib/src/widgets/tappable_image.dart rename to packages/flutter_timeline/lib/src/widgets/tappable_image.dart index fcf2b48..7a0fb6c 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/tappable_image.dart +++ b/packages/flutter_timeline/lib/src/widgets/tappable_image.dart @@ -1,8 +1,8 @@ -import 'dart:async'; +import "dart:async"; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; +import "package:cached_network_image/cached_network_image.dart"; +import "package:flutter/material.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.dart"; class TappableImage extends StatefulWidget { const TappableImage({ @@ -15,7 +15,7 @@ class TappableImage extends StatefulWidget { final TimelinePost post; final String userId; - final Future Function({required bool liked}) onLike; + final Future Function() onLike; final (Icon?, Icon?) likeAndDislikeIcon; @override @@ -73,12 +73,7 @@ class _TappableImageState extends State loading = true; await animationController.forward(); - var liked = await widget.onLike( - liked: widget.post.likedBy?.contains( - widget.userId, - ) ?? - false, - ); + var liked = await widget.onLike(); if (context.mounted) { await showDialog( @@ -101,15 +96,19 @@ class _TappableImageState extends State scale: 1 + animation.value * 0.1, child: widget.post.imageUrl != null ? CachedNetworkImage( - imageUrl: widget.post.imageUrl ?? '', + height: 250, + imageUrl: widget.post.imageUrl ?? "", width: double.infinity, - fit: BoxFit.fitHeight, + fit: BoxFit.cover, ) - : Image.memory( - width: double.infinity, - widget.post.image!, - fit: BoxFit.fitHeight, - ), + : widget.post.image != null + ? Image.memory( + width: double.infinity, + widget.post.image!, + fit: BoxFit.cover, + height: 250, + ) + : null, ), ), ); diff --git a/packages/flutter_timeline/lib/src/widgets/timeline_post.dart b/packages/flutter_timeline/lib/src/widgets/timeline_post.dart new file mode 100644 index 0000000..838372a --- /dev/null +++ b/packages/flutter_timeline/lib/src/widgets/timeline_post.dart @@ -0,0 +1,254 @@ +import "package:cached_network_image/cached_network_image.dart"; +import "package:flutter/material.dart"; +import "package:flutter_timeline/flutter_timeline.dart"; +import "package:flutter_timeline/src/widgets/comment_section.dart"; +import "package:flutter_timeline/src/widgets/post_more_options_widget.dart"; +import "package:flutter_timeline/src/widgets/tappable_image.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.dart"; + +class TimelinePostWidget extends StatefulWidget { + const TimelinePostWidget({ + required this.post, + required this.timelineService, + required this.options, + required this.currentUserId, + required this.onTapPost, + required this.onTapComments, + this.isInDetialView = false, + this.isInPostOverview = false, + super.key, + }); + + final TimelinePost post; + final TimelineService timelineService; + final TimelineOptions options; + final String currentUserId; + final Function(TimelinePost post) onTapPost; + final bool isInDetialView; + final Function(TimelinePost post) onTapComments; + final bool isInPostOverview; + + @override + State createState() => _TimelinePostWidgetState(); +} + +class _TimelinePostWidgetState extends State { + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var translations = widget.options.translations; + var user = widget.post.creator; + var options = widget.options; + var post = widget.post; + var isLikedByCurrentUser = + widget.post.likedBy?.contains(widget.currentUserId) ?? false; + var likesTitle = widget.post.likes == 1 + ? translations.oneLikeTitle + : translations.multipleLikesTitle; + + return Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: widget.isInDetialView ? 100 : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + options.userAvatarBuilder.call(user, 24), + const SizedBox(width: 8), + options.userNameBuilder + .call(user, options.translations.anonymousUser, context), + ], + ), + if (post.creatorId == widget.currentUserId && + !widget.isInPostOverview && + !widget.isInDetialView) + MoreOptionsButton( + timelineService: widget.timelineService, + options: options, + post: post, + ), + ], + ), + const SizedBox( + height: 8, + ), + if (post.imageUrl != null || post.image != null) ...[ + if (options.doubleTapToLike) ...[ + TappableImage( + post: post, + onLike: () async { + if (isLikedByCurrentUser) { + widget.options.onTapUnlike ?? + widget.timelineService.unlikePost( + widget.post.id, + widget.currentUserId, + ); + setState(() {}); + return true; + } else { + widget.options.onTapLike ?? + widget.timelineService.likePost( + widget.post.id, + widget.currentUserId, + ); + setState(() {}); + return false; + } + }, + userId: widget.currentUserId, + likeAndDislikeIcon: ( + Icon(options.likeIcon), + Icon(options.likedIcon) + ), + ), + ] else ...[ + if (post.imageUrl != null) + Container( + height: 250, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: DecorationImage( + image: CachedNetworkImageProvider(widget.post.imageUrl!), + fit: BoxFit.cover, + ), + ), + ), + if (post.image != null) + Container( + height: 250, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: DecorationImage( + image: MemoryImage(widget.post.image!), + fit: BoxFit.cover, + ), + ), + ), + ], + ], + const SizedBox( + height: 12, + ), + Row( + children: [ + Builder( + builder: (context) { + var postIsLikedByCurrentUser = + post.likedBy?.contains(widget.currentUserId) ?? false; + return IconButton( + iconSize: options.iconSize, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + icon: Icon( + postIsLikedByCurrentUser + ? widget.options.likedIcon + : widget.options.likeIcon, + ), + onPressed: () async { + if (postIsLikedByCurrentUser) { + await widget.timelineService.unlikePost( + post.id, + widget.currentUserId, + ); + } else { + await widget.timelineService.likePost( + widget.post.id, + widget.currentUserId, + ); + } + setState(() {}); + }, + ); + }, + ), + const SizedBox(width: 8), + if (post.reactionEnabled) ...[ + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + icon: Icon( + options.commentIcon, + size: options.iconSize, + color: options.iconColor, + ), + onPressed: () { + widget.onTapComments(widget.post); + }, + ), + ], + ], + ), + const SizedBox( + height: 8, + ), + if (!widget.isInPostOverview) ...[ + Text( + "${widget.post.likes} $likesTitle", + style: theme.textTheme.titleSmall?.copyWith( + color: Colors.black, + ), + ), + ], + Row( + children: [ + options.userNameBuilder.call( + user, + options.translations.anonymousUser, + context, + ), + const SizedBox(width: 8), + Text( + widget.post.title, + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.black, + ), + ), + ], + ), + if (widget.isInDetialView) ...[ + const SizedBox( + height: 20, + ), + Text( + widget.post.content, + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.black, + ), + ), + Text( + widget.options.dateFormat(context).format(widget.post.createdAt), + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.black.withOpacity(0.5), + ), + ), + if (widget.post.reactionEnabled) + if (!widget.isInPostOverview) + CommentSection( + options: options, + post: post, + currentUserId: widget.currentUserId, + timelineService: widget.timelineService, + ), + ], + if (!widget.isInDetialView) + InkWell( + onTap: () => widget.onTapPost(widget.post), + child: Text( + translations.viewPostTitle, + style: theme.textTheme.titleSmall + ?.copyWith(color: Colors.black.withOpacity(0.5)), + ), + ), + ], + ), + ); + } +} diff --git a/packages/flutter_timeline/pubspec.yaml b/packages/flutter_timeline/pubspec.yaml index a152f39..da082c7 100644 --- a/packages/flutter_timeline/pubspec.yaml +++ b/packages/flutter_timeline/pubspec.yaml @@ -1,31 +1,36 @@ -# SPDX-FileCopyrightText: 2023 Iconica -# -# SPDX-License-Identifier: GPL-3.0-or-later name: flutter_timeline -description: Visual elements and interface combined into one package -version: 5.1.0 -publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub +description: "A new Flutter package project." +version: 6.0.0 +publish_to: none environment: - sdk: ">=3.1.3 <4.0.0" + sdk: ^3.5.1 + flutter: ">=1.17.0" dependencies: flutter: sdk: flutter - flutter_timeline_view: - hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub - version: ^5.1.0 - flutter_timeline_interface: + flutter_image_picker: hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub - version: ^5.1.0 - collection: any + version: ^4.0.0 + timeline_repository_interface: + git: + url: https://github.com/Iconica-Development/flutter_timeline + path: packages/timeline_repository_interface + ref: 6.0.0 + + cached_network_image: ^3.4.1 + intl: 0.19.0 + flutter_svg: ^2.0.10+1 + dotted_border: ^2.1.0 dev_dependencies: - flutter_lints: ^2.0.0 flutter_iconica_analysis: git: url: https://github.com/Iconica-Development/flutter_iconica_analysis - ref: 6.0.0 + ref: 7.0.0 flutter: + assets: + - assets/ diff --git a/packages/flutter_timeline_firebase/CHANGELOG.md b/packages/flutter_timeline_firebase/CHANGELOG.md deleted file mode 120000 index 699cc9e..0000000 --- a/packages/flutter_timeline_firebase/CHANGELOG.md +++ /dev/null @@ -1 +0,0 @@ -../../CHANGELOG.md \ No newline at end of file diff --git a/packages/flutter_timeline_firebase/LICENSE b/packages/flutter_timeline_firebase/LICENSE deleted file mode 120000 index 30cff74..0000000 --- a/packages/flutter_timeline_firebase/LICENSE +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE \ No newline at end of file diff --git a/packages/flutter_timeline_firebase/README.md b/packages/flutter_timeline_firebase/README.md deleted file mode 120000 index fe84005..0000000 --- a/packages/flutter_timeline_firebase/README.md +++ /dev/null @@ -1 +0,0 @@ -../../README.md \ No newline at end of file diff --git a/packages/flutter_timeline_firebase/analysis_options.yaml b/packages/flutter_timeline_firebase/analysis_options.yaml deleted file mode 100644 index 3e96d28..0000000 --- a/packages/flutter_timeline_firebase/analysis_options.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# SPDX-FileCopyrightText: 2023 Iconica -# -# SPDX-License-Identifier: GPL-3.0-or-later - -include: package:flutter_iconica_analysis/analysis_options.yaml - -# Possible to overwrite the rules from the package - -analyzer: - exclude: - -linter: - rules: diff --git a/packages/flutter_timeline_firebase/lib/flutter_timeline_firebase.dart b/packages/flutter_timeline_firebase/lib/flutter_timeline_firebase.dart deleted file mode 100644 index b138166..0000000 --- a/packages/flutter_timeline_firebase/lib/flutter_timeline_firebase.dart +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -/// -library flutter_timeline_firebase; - -export 'src/config/firebase_timeline_options.dart'; -export 'src/service/firebase_post_service.dart'; -export 'src/service/firebase_timeline_service.dart'; -export 'src/service/firebase_user_service.dart'; diff --git a/packages/flutter_timeline_firebase/lib/src/config/firebase_timeline_options.dart b/packages/flutter_timeline_firebase/lib/src/config/firebase_timeline_options.dart deleted file mode 100644 index 03f32e3..0000000 --- a/packages/flutter_timeline_firebase/lib/src/config/firebase_timeline_options.dart +++ /dev/null @@ -1,18 +0,0 @@ -// 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.timelineCategoryCollectionName = 'timeline_categories', - }); - - final String usersCollectionName; - final String timelineCollectionName; - final String timelineCategoryCollectionName; -} diff --git a/packages/flutter_timeline_firebase/lib/src/models/firebase_user_document.dart b/packages/flutter_timeline_firebase/lib/src/models/firebase_user_document.dart deleted file mode 100644 index 19fe9c9..0000000 --- a/packages/flutter_timeline_firebase/lib/src/models/firebase_user_document.dart +++ /dev/null @@ -1,36 +0,0 @@ -// 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/src/service/firebase_post_service.dart b/packages/flutter_timeline_firebase/lib/src/service/firebase_post_service.dart deleted file mode 100644 index aa27af4..0000000 --- a/packages/flutter_timeline_firebase/lib/src/service/firebase_post_service.dart +++ /dev/null @@ -1,495 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -import 'dart:typed_data'; - -import 'package:cloud_firestore/cloud_firestore.dart'; -import 'package:collection/collection.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 - with TimelineUserService, ChangeNotifier - implements TimelinePostService { - FirebaseTimelinePostService({ - required TimelineUserService userService, - FirebaseApp? app, - FirebaseTimelineOptions? options, - }) { - var appInstance = app ?? Firebase.app(); - _db = FirebaseFirestore.instanceFor(app: appInstance); - _storage = FirebaseStorage.instanceFor(app: appInstance); - _userService = userService; - _options = options ?? const FirebaseTimelineOptions(); - } - - late FirebaseFirestore _db; - late FirebaseStorage _storage; - late TimelineUserService _userService; - late FirebaseTimelineOptions _options; - - final Map _users = {}; - - @override - List posts = []; - - @override - List categories = []; - - @override - TimelineCategory? selectedCategory; - - @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, - creator: await _userService.getUser(post.creatorId), - ); - posts = posts.map((p) => (p.id == post.id) ? updatedPost : p).toList(); - notifyListeners(); - return updatedPost; - } - - @override - Future> fetchPosts(String? category) async { - var snapshot = (category != null) - ? await _db - .collection(_options.timelineCollectionName) - .where('category', isEqualTo: category) - .get() - : await _db.collection(_options.timelineCollectionName).get(); - - var fetchedPosts = []; - 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); - fetchedPosts.add(post); - } - - posts = fetchedPosts; - - 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 - Future getPost(String postId) async { - var post = await _db - .collection(_options.timelineCollectionName) - .doc(postId) - .withConverter( - fromFirestore: (snapshot, _) => TimelinePost.fromJson( - snapshot.id, - snapshot.data()!, - ), - toFirestore: (user, _) => user.toJson(), - ) - .get(); - return post.data(); - } - - @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 ?? [], 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; - } - - @override - Future addCategory(TimelineCategory category) async { - var exists = categories.firstWhereOrNull( - (element) => element.title.toLowerCase() == category.title.toLowerCase(), - ); - if (exists != null) return false; - try { - await _db - .collection(_options.timelineCategoryCollectionName) - .add(category.toJson()); - categories.add(category); - notifyListeners(); - return true; - } on Exception catch (_) { - return false; - } - } - - @override - Future> fetchCategories() async { - categories.clear(); - categories.add( - const TimelineCategory( - key: null, - title: 'All', - ), - ); - var categoriesSnapshot = await _db - .collection(_options.timelineCategoryCollectionName) - .withConverter( - fromFirestore: (snapshot, _) => - TimelineCategory.fromJson(snapshot.data()!), - toFirestore: (model, _) => model.toJson(), - ) - .get(); - categories.addAll(categoriesSnapshot.docs.map((e) => e.data())); - - notifyListeners(); - return categories; - } - - @override - Future likeReaction( - String userId, - TimelinePost post, - String reactionId, - ) async { - // update the post with the new like - var updatedPost = post.copyWith( - reactions: post.reactions?.map( - (r) { - if (r.id == reactionId) { - return r.copyWith( - likedBy: (r.likedBy ?? [])..add(userId), - ); - } - return r; - }, - ).toList(), - ); - posts = posts - .map( - (p) => p.id == post.id ? updatedPost : p, - ) - .toList(); - var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); - await postRef.update({ - 'reactions': post.reactions - ?.map( - (r) => - r.id == reactionId ? r.copyWith(likedBy: r.likedBy ?? []) : r, - ) - .map((e) => e.toJson()) - .toList(), - }); - notifyListeners(); - return updatedPost; - } - - @override - Future unlikeReaction( - String userId, - TimelinePost post, - String reactionId, - ) async { - // update the post with the new like - var updatedPost = post.copyWith( - reactions: post.reactions?.map( - (r) { - if (r.id == reactionId) { - return r.copyWith( - likedBy: r.likedBy?..remove(userId), - ); - } - return r; - }, - ).toList(), - ); - posts = posts - .map( - (p) => p.id == post.id ? updatedPost : p, - ) - .toList(); - var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); - await postRef.update({ - 'reactions': post.reactions - ?.map( - (r) => r.id == reactionId - ? r.copyWith(likedBy: r.likedBy?..remove(userId)) - : r, - ) - .map((e) => e.toJson()) - .toList(), - }); - notifyListeners(); - return updatedPost; - } -} 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 deleted file mode 100644 index 2e56f8d..0000000 --- a/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:firebase_core/firebase_core.dart'; -import 'package:flutter_timeline_firebase/flutter_timeline_firebase.dart'; -import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; - -class FirebaseTimelineService implements TimelineService { - FirebaseTimelineService({ - this.options, - this.app, - this.firebasePostService, - this.firebaseUserService, - }) { - firebaseUserService ??= FirebaseTimelineUserService( - options: options, - app: app, - ); - - firebasePostService ??= FirebaseTimelinePostService( - userService: userService, - options: options, - app: app, - ); - } - - final FirebaseTimelineOptions? options; - final FirebaseApp? app; - TimelinePostService? firebasePostService; - TimelineUserService? firebaseUserService; - - @override - TimelinePostService get postService { - if (firebasePostService != null) { - return firebasePostService!; - } else { - return FirebaseTimelinePostService( - userService: userService, - options: options, - app: app, - ); - } - } - - @override - TimelineUserService get userService { - if (firebaseUserService != null) { - return firebaseUserService!; - } else { - return FirebaseTimelineUserService( - options: options, - app: app, - ); - } - } -} diff --git a/packages/flutter_timeline_firebase/lib/src/service/firebase_user_service.dart b/packages/flutter_timeline_firebase/lib/src/service/firebase_user_service.dart deleted file mode 100644 index bfde3d5..0000000 --- a/packages/flutter_timeline_firebase/lib/src/service/firebase_user_service.dart +++ /dev/null @@ -1,55 +0,0 @@ -// 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/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'; - -class FirebaseTimelineUserService implements TimelineUserService { - FirebaseTimelineUserService({ - FirebaseApp? app, - FirebaseTimelineOptions? options, - }) { - var appInstance = app ?? Firebase.app(); - _db = FirebaseFirestore.instanceFor(app: appInstance); - _options = options ?? const FirebaseTimelineOptions(); - } - - 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_firebase/pubspec.yaml b/packages/flutter_timeline_firebase/pubspec.yaml deleted file mode 100644 index d094194..0000000 --- a/packages/flutter_timeline_firebase/pubspec.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# SPDX-FileCopyrightText: 2023 Iconica -# -# SPDX-License-Identifier: GPL-3.0-or-later - -name: flutter_timeline_firebase -description: Implementation of the Flutter Timeline interface for Firebase. -version: 5.1.0 -publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub - -environment: - sdk: ">=3.1.3 <4.0.0" - -dependencies: - flutter: - sdk: flutter - cloud_firestore: ^4.13.1 - firebase_core: ^2.22.0 - firebase_storage: ^11.5.1 - uuid: ^4.2.1 - collection: ^1.18.0 - flutter_timeline_interface: - hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub - version: ^5.1.0 - -dev_dependencies: - flutter_lints: ^2.0.0 - flutter_iconica_analysis: - git: - url: https://github.com/Iconica-Development/flutter_iconica_analysis - ref: 6.0.0 - -flutter: diff --git a/packages/flutter_timeline_interface/CHANGELOG.md b/packages/flutter_timeline_interface/CHANGELOG.md deleted file mode 120000 index 699cc9e..0000000 --- a/packages/flutter_timeline_interface/CHANGELOG.md +++ /dev/null @@ -1 +0,0 @@ -../../CHANGELOG.md \ No newline at end of file diff --git a/packages/flutter_timeline_interface/LICENSE b/packages/flutter_timeline_interface/LICENSE deleted file mode 120000 index 30cff74..0000000 --- a/packages/flutter_timeline_interface/LICENSE +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE \ No newline at end of file diff --git a/packages/flutter_timeline_interface/README.md b/packages/flutter_timeline_interface/README.md deleted file mode 120000 index fe84005..0000000 --- a/packages/flutter_timeline_interface/README.md +++ /dev/null @@ -1 +0,0 @@ -../../README.md \ No newline at end of file diff --git a/packages/flutter_timeline_interface/analysis_options.yaml b/packages/flutter_timeline_interface/analysis_options.yaml deleted file mode 100644 index 3e96d28..0000000 --- a/packages/flutter_timeline_interface/analysis_options.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# SPDX-FileCopyrightText: 2023 Iconica -# -# SPDX-License-Identifier: GPL-3.0-or-later - -include: package:flutter_iconica_analysis/analysis_options.yaml - -# Possible to overwrite the rules from the package - -analyzer: - exclude: - -linter: - rules: diff --git a/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart b/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart deleted file mode 100644 index 8fb0bf9..0000000 --- a/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause -/// -library flutter_timeline_interface; - -export 'src/model/timeline_category.dart'; -export 'src/model/timeline_post.dart'; -export 'src/model/timeline_poster.dart'; -export 'src/model/timeline_reaction.dart'; -export 'src/services/filter_service.dart'; -export 'src/services/timeline_post_service.dart'; -export 'src/services/timeline_service.dart'; -export 'src/services/user_service.dart'; diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart deleted file mode 100644 index a07f3fd..0000000 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -import 'package:flutter/material.dart'; - -@immutable -class TimelinePosterUserModel { - const TimelinePosterUserModel({ - required this.userId, - this.firstName, - this.lastName, - this.imageUrl, - }); - - factory TimelinePosterUserModel.fromJson( - Map json, - String userId, - ) => - TimelinePosterUserModel( - userId: userId, - firstName: json['first_name'] as String?, - lastName: json['last_name'] as String?, - imageUrl: json['image_url'] as String?, - ); - - final String userId; - final String? firstName; - final String? lastName; - final String? imageUrl; - - Map toJson() => { - 'first_name': firstName, - 'last_name': lastName, - 'image_url': imageUrl, - }; - - String? get fullName { - var fullName = ''; - - if (firstName != null && lastName != null) { - fullName += '$firstName $lastName'; - } else if (firstName != null) { - fullName += firstName!; - } else if (lastName != null) { - fullName += lastName!; - } - - return fullName == '' ? null : fullName; - } -} diff --git a/packages/flutter_timeline_interface/lib/src/services/filter_service.dart b/packages/flutter_timeline_interface/lib/src/services/filter_service.dart deleted file mode 100644 index 029dbec..0000000 --- a/packages/flutter_timeline_interface/lib/src/services/filter_service.dart +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; - -mixin TimelineFilterService on TimelinePostService { - List filterPosts( - String filterWord, - Map options, - ) { - var filteredPosts = posts - .where( - (post) => post.title.toLowerCase().contains( - filterWord.toLowerCase(), - ), - ) - .toList(); - - return filteredPosts; - } -} diff --git a/packages/flutter_timeline_interface/lib/src/services/timeline_post_service.dart b/packages/flutter_timeline_interface/lib/src/services/timeline_post_service.dart deleted file mode 100644 index 9a464f3..0000000 --- a/packages/flutter_timeline_interface/lib/src/services/timeline_post_service.dart +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -import 'dart:typed_data'; - -import 'package:flutter/material.dart'; -import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; - -abstract class TimelinePostService with ChangeNotifier { - List posts = []; - List categories = []; - TimelineCategory? selectedCategory; - - Future deletePost(TimelinePost post); - Future deletePostReaction(TimelinePost post, String reactionId); - Future createPost(TimelinePost post); - Future> fetchPosts(String? category); - Future fetchPost(TimelinePost post); - Future> fetchPostsPaginated(String? category, int limit); - Future getPost(String postId); - List getPosts(String? category); - Future> refreshPosts(String? category); - Future fetchPostDetails(TimelinePost post); - Future reactToPost( - TimelinePost post, - TimelinePostReaction reaction, { - Uint8List image, - }); - Future likePost(String userId, TimelinePost post); - Future unlikePost(String userId, TimelinePost post); - - Future> fetchCategories(); - Future addCategory(TimelineCategory category); - Future likeReaction( - String userId, - TimelinePost post, - String reactionId, - ); - Future unlikeReaction( - String userId, - TimelinePost post, - String reactionId, - ); -} diff --git a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart deleted file mode 100644 index 4e06b2c..0000000 --- a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter_timeline_interface/src/services/timeline_post_service.dart'; -import 'package:flutter_timeline_interface/src/services/user_service.dart'; - -class TimelineService { - TimelineService({ - required this.postService, - this.userService, - }); - - final TimelinePostService postService; - final TimelineUserService? userService; -} diff --git a/packages/flutter_timeline_interface/lib/src/services/user_service.dart b/packages/flutter_timeline_interface/lib/src/services/user_service.dart deleted file mode 100644 index 0fbf1d4..0000000 --- a/packages/flutter_timeline_interface/lib/src/services/user_service.dart +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -import 'package:flutter_timeline_interface/src/model/timeline_poster.dart'; - -mixin TimelineUserService { - Future getUser(String userId); -} diff --git a/packages/flutter_timeline_interface/pubspec.yaml b/packages/flutter_timeline_interface/pubspec.yaml deleted file mode 100644 index d1e82e5..0000000 --- a/packages/flutter_timeline_interface/pubspec.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# SPDX-FileCopyrightText: 2023 Iconica -# -# SPDX-License-Identifier: GPL-3.0-or-later - -name: flutter_timeline_interface -description: Interface for the service of the Flutter Timeline component -version: 5.1.0 -publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub - -environment: - sdk: '>=3.1.3 <4.0.0' - -dependencies: - flutter: - sdk: flutter - -dev_dependencies: - flutter_lints: ^2.0.0 - flutter_iconica_analysis: - git: - url: https://github.com/Iconica-Development/flutter_iconica_analysis - ref: 6.0.0 - -flutter: - diff --git a/packages/flutter_timeline_view/CHANGELOG.md b/packages/flutter_timeline_view/CHANGELOG.md deleted file mode 120000 index 699cc9e..0000000 --- a/packages/flutter_timeline_view/CHANGELOG.md +++ /dev/null @@ -1 +0,0 @@ -../../CHANGELOG.md \ No newline at end of file diff --git a/packages/flutter_timeline_view/LICENSE b/packages/flutter_timeline_view/LICENSE deleted file mode 120000 index 30cff74..0000000 --- a/packages/flutter_timeline_view/LICENSE +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE \ No newline at end of file diff --git a/packages/flutter_timeline_view/README.md b/packages/flutter_timeline_view/README.md deleted file mode 120000 index fe84005..0000000 --- a/packages/flutter_timeline_view/README.md +++ /dev/null @@ -1 +0,0 @@ -../../README.md \ No newline at end of file diff --git a/packages/flutter_timeline_view/analysis_options.yaml b/packages/flutter_timeline_view/analysis_options.yaml deleted file mode 100644 index 3e96d28..0000000 --- a/packages/flutter_timeline_view/analysis_options.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# SPDX-FileCopyrightText: 2023 Iconica -# -# SPDX-License-Identifier: GPL-3.0-or-later - -include: package:flutter_iconica_analysis/analysis_options.yaml - -# Possible to overwrite the rules from the package - -analyzer: - exclude: - -linter: - rules: diff --git a/packages/flutter_timeline_view/lib/flutter_timeline_view.dart b/packages/flutter_timeline_view/lib/flutter_timeline_view.dart deleted file mode 100644 index 89264d4..0000000 --- a/packages/flutter_timeline_view/lib/flutter_timeline_view.dart +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause -/// -library flutter_timeline_view; - -export 'src/config/timeline_options.dart'; -export 'src/config/timeline_paddings.dart'; -export 'src/config/timeline_styles.dart'; -export 'src/config/timeline_theme.dart'; -export 'src/config/timeline_translations.dart'; -export 'src/screens/timeline_post_creation_screen.dart'; -export 'src/screens/timeline_post_overview_screen.dart'; -export 'src/screens/timeline_post_screen.dart'; -export 'src/screens/timeline_screen.dart'; -export 'src/screens/timeline_selection_screen.dart'; -export 'src/services/local_post_service.dart'; -export 'src/widgets/category_selector.dart'; -export 'src/widgets/category_selector_button.dart'; -export 'src/widgets/timeline_post_widget.dart'; diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart deleted file mode 100644 index 49e35f2..0000000 --- a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart +++ /dev/null @@ -1,244 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause -import 'package:collection/collection.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_paddings.dart'; -import 'package:flutter_timeline_view/src/config/timeline_theme.dart'; -import 'package:flutter_timeline_view/src/config/timeline_translations.dart'; -import 'package:intl/intl.dart'; - -class TimelineOptions { - const TimelineOptions({ - this.theme = const TimelineTheme(), - this.translations = const TimelineTranslations.empty(), - this.paddings = const TimelinePaddingOptions(), - this.imagePickerConfig = const ImagePickerConfig(), - this.imagePickerTheme, - this.timelinePostHeight, - this.sortCommentsAscending = true, - this.sortPostsAscending = false, - this.doubleTapTolike = false, - this.iconsWithValues = false, - this.likeAndDislikeIconsForDoubleTap = const ( - Icon( - Icons.favorite_rounded, - color: Color(0xFFC3007A), - ), - null, - ), - this.itemInfoBuilder, - this.dateFormat, - this.timeFormat, - this.buttonBuilder, - this.textInputBuilder, - this.dividerBuilder, - this.userAvatarBuilder, - this.anonymousAvatarBuilder, - this.nameBuilder, - this.iconSize = 24, - this.postWidgetHeight, - this.filterOptions = const FilterOptions(), - this.categoriesOptions = const CategoriesOptions(), - this.requireImageForPost = false, - this.minTitleLength, - this.maxTitleLength, - this.minContentLength, - this.maxContentLength, - this.categorySelectorButtonBuilder, - this.postOverviewButtonBuilder, - this.deletionDialogBuilder, - this.listHeaderBuilder, - this.titleInputDecoration, - this.contentInputDecoration, - }); - - /// Theming options for the timeline - final TimelineTheme theme; - - /// The format to display the post date in - final DateFormat? dateFormat; - - /// The format to display the post time in - final DateFormat? timeFormat; - - /// Whether to sort comments ascending or descending - final bool sortCommentsAscending; - - /// Whether to sort posts ascending or descending - final bool? sortPostsAscending; - - /// The height of a post in the timeline - final double? timelinePostHeight; - - /// Class that contains all the translations used in the timeline - final TimelineTranslations translations; - - /// Class that contains all the paddings used in the timeline - final TimelinePaddingOptions paddings; - - final ButtonBuilder? buttonBuilder; - - final TextInputBuilder? textInputBuilder; - - final UserAvatarBuilder? userAvatarBuilder; - - /// When the imageUrl is null this anonymousAvatarBuilder will be used - /// You can use it to display a default avatarW - final UserAvatarBuilder? anonymousAvatarBuilder; - - final String Function(TimelinePosterUserModel?)? nameBuilder; - - /// ImagePickerTheme can be used to change the UI of the - /// Image Picker Widget to change the text/icons to your liking. - final ImagePickerTheme? imagePickerTheme; - - /// ImagePickerConfig can be used to define the - /// size and quality for the uploaded image. - final ImagePickerConfig imagePickerConfig; - - /// Whether to allow double tap to like - final bool doubleTapTolike; - - /// The icons to display when double tap to like is enabled - final (Icon?, Icon?) likeAndDislikeIconsForDoubleTap; - - /// Whether to display the icons with values - final bool iconsWithValues; - - /// The builder for the item info, all below the like and comment buttons - final Widget Function({required TimelinePost post})? itemInfoBuilder; - - /// The builder for the divider - final Widget Function()? dividerBuilder; - - /// Size of icons like the comment and like icons. Dafualts to 26 - final double iconSize; - - /// Sets a predefined height for the postWidget. - final double? postWidgetHeight; - - /// Options for filtering - final FilterOptions filterOptions; - - /// Options for using the category selector. - final CategoriesOptions categoriesOptions; - - /// Require image for post - final bool requireImageForPost; - - /// Minimum length of the title - final int? minTitleLength; - - /// Maximum length of the title - final int? maxTitleLength; - - /// Minimum length of the post content - final int? minContentLength; - - /// Maximum length of the post content - final int? maxContentLength; - - /// Builder for the category selector button - /// on the timeline category selection screen - final Widget Function( - BuildContext context, - Function() onPressed, - String text, - )? categorySelectorButtonBuilder; - - /// This widgetbuilder is placed at the top of the list of posts and can be - /// used to add custom elements - final Widget Function(BuildContext context, String? category)? - listHeaderBuilder; - - /// Builder for the post overview button - /// on the timeline post overview screen - final Widget Function( - BuildContext context, - Function() onPressed, - String text, - TimelinePost post, - )? postOverviewButtonBuilder; - - /// Optional builder to override the default alertdialog for post deletion - /// It should pop the navigator with true to delete the post and - /// false to cancel deletion - final WidgetBuilder? deletionDialogBuilder; - - /// inputdecoration for the title textfield - final InputDecoration? titleInputDecoration; - - /// inputdecoration for the content textfield - final InputDecoration? contentInputDecoration; -} - -class CategoriesOptions { - const CategoriesOptions({ - this.categoryButtonBuilder, - this.categorySelectorHorizontalPadding, - }); - - /// List of categories that the user can select. - /// If this is null no categories will be shown. - - /// Abilty to override the standard category selector - final Widget Function( - TimelineCategory category, - Function() onTap, - // ignore: avoid_positional_boolean_parameters - bool selected, - bool isOnTop, - )? categoryButtonBuilder; - - /// Overides the standard horizontal padding of the whole category selector. - final double? categorySelectorHorizontalPadding; - - TimelineCategory? getCategoryByKey( - List categories, - BuildContext context, - String? key, - ) => - categories.firstWhereOrNull((category) => category.key == key); -} - -class FilterOptions { - const FilterOptions({ - this.initialFilterWord, - this.searchBarBuilder, - this.onFilterEnabledChange, - }); - - /// Set a value to search through posts. When set the searchbar is shown. - /// If null no searchbar is shown. - final String? initialFilterWord; - - // Possibilty to override the standard search bar. - final Widget Function( - Future> Function( - String filterWord, - ) search, - )? searchBarBuilder; - - final void Function({required bool filterEnabled})? onFilterEnabledChange; -} - -typedef ButtonBuilder = Widget Function( - BuildContext context, - VoidCallback onPressed, - String text, { - bool enabled, -}); - -typedef TextInputBuilder = Widget Function( - TextEditingController controller, - Widget? suffixIcon, - String hintText, -); - -typedef UserAvatarBuilder = Widget? Function( - TimelinePosterUserModel user, - double size, -); diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_paddings.dart b/packages/flutter_timeline_view/lib/src/config/timeline_paddings.dart deleted file mode 100644 index 39fc5ac..0000000 --- a/packages/flutter_timeline_view/lib/src/config/timeline_paddings.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter/material.dart'; - -/// This class contains the paddings used in the timeline options -class TimelinePaddingOptions { - const TimelinePaddingOptions({ - this.mainPadding = - const EdgeInsets.only(left: 32, top: 20, right: 32, bottom: 40), - this.postPadding = - const EdgeInsets.only(left: 12.0, top: 12, right: 12.0, bottom: 8), - this.postOverviewButtonBottomPadding = 30.0, - this.categoryButtonTextPadding, - }); - - /// The padding between posts in the timeline - final EdgeInsets mainPadding; - - /// The padding of each post - final EdgeInsets postPadding; - - /// The bottom padding of the button on the post overview screen - final double postOverviewButtonBottomPadding; - - /// The padding between the icon and the text in the category button - final double? categoryButtonTextPadding; -} diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_styles.dart b/packages/flutter_timeline_view/lib/src/config/timeline_styles.dart deleted file mode 100644 index 8343052..0000000 --- a/packages/flutter_timeline_view/lib/src/config/timeline_styles.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/material.dart'; - -@immutable -class TimelineTextStyles { - /// Options to update all the texts in the timeline view - /// with different textstyles - const TimelineTextStyles({ - this.viewPostStyle, - this.listPostTitleStyle, - this.listPostCreatorTitleStyle, - this.listCreatorNameStyle, - this.listPostLikeTitleAndAmount, - this.deletePostStyle, - this.categorySelectionDescriptionStyle, - this.categorySelectionTitleStyle, - this.noPostsStyle, - this.errorTextStyle, - this.postCreatorTitleStyle, - this.postCreatorNameStyle, - this.postTitleStyle, - this.postLikeTitleAndAmount, - this.postCreatedAtStyle, - this.categoryTitleStyle, - }); - - /// The TextStyle for the text indicating that you can view a post - final TextStyle? viewPostStyle; - - /// The TextStyle for the creatorname at the top of the card - /// when it is in the list - final TextStyle? listPostCreatorTitleStyle; - - /// The TextStyle for the post title when it is in the list - final TextStyle? listPostTitleStyle; - - /// The TextStyle for the creatorname at the bottom of the card - /// when it is in the list - final TextStyle? listCreatorNameStyle; - - /// The TextStyle for the amount of like and name of the likes at - /// the bottom of the card when it is in the list - final TextStyle? listPostLikeTitleAndAmount; - - /// The TextStyle for the deletion text that shows in the popupmenu - final TextStyle? deletePostStyle; - - /// The TextStyle for the category explainer on the selection page - final TextStyle? categorySelectionDescriptionStyle; - - /// The TextStyle for the category items in the list on the selection page - final TextStyle? categorySelectionTitleStyle; - - /// The TextStyle for the text when there are no posts - final TextStyle? noPostsStyle; - - /// The TextStyle for all error texts - final TextStyle? errorTextStyle; - - /// The TextStyle for the creatorname at the top of the post page - final TextStyle? postCreatorTitleStyle; - - /// The TextStyle for the creatorname at the bottom of the post page - final TextStyle? postCreatorNameStyle; - - /// The TextStyle for the title of the post on the post page - final TextStyle? postTitleStyle; - - /// The TextStyle for the amount of likes and name of the likes - /// on the post page - final TextStyle? postLikeTitleAndAmount; - - /// The TextStyle for the creation time of the post - final TextStyle? postCreatedAtStyle; - - final TextStyle? categoryTitleStyle; -} diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart b/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart deleted file mode 100644 index ad02c6d..0000000 --- a/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart +++ /dev/null @@ -1,65 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -import 'package:flutter/material.dart'; -import 'package:flutter_timeline_view/src/config/timeline_styles.dart'; - -@immutable -class TimelineTheme { - const TimelineTheme({ - this.iconColor, - this.likeIcon, - this.commentIcon, - this.likedIcon, - this.sendIcon, - this.moreIcon, - this.deleteIcon, - this.categorySelectionButtonBorderColor, - this.categorySelectionButtonBackgroundColor, - this.categorySelectionButtonSelectedTextColor, - this.categorySelectionButtonUnselectedTextColor, - this.postCreationFloatingActionButtonColor, - this.textStyles = const TimelineTextStyles(), - }); - - final Color? iconColor; - - /// The icon to display when the post is not yet liked - final Widget? likeIcon; - - /// The icon to display to indicate that a post has comments enabled - final Widget? commentIcon; - - /// The icon to display when the post is liked - final Widget? likedIcon; - - /// The icon to display to submit a comment - final Widget? sendIcon; - - /// The icon for more actions (open delete menu) - final Widget? moreIcon; - - /// The icon for delete action (delete post) - final Widget? deleteIcon; - - /// The text style overrides for all the texts in the timeline - final TimelineTextStyles textStyles; - - /// The color of the border around the category in the selection screen - final Color? categorySelectionButtonBorderColor; - - /// The color of the background of the category selection button in the - /// selection screen - final Color? categorySelectionButtonBackgroundColor; - - /// The color of the text of the category selection button when it is selected - final Color? categorySelectionButtonSelectedTextColor; - - /// The color of the text of the category selection button when - /// it is not selected - final Color? categorySelectionButtonUnselectedTextColor; - - /// The color of the floating action button on the overview screen - final Color? postCreationFloatingActionButtonColor; -} diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart b/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart deleted file mode 100644 index 9b61753..0000000 --- a/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart +++ /dev/null @@ -1,263 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -import 'package:flutter/material.dart'; - -@immutable - -/// Class that holds all the translations for the timeline component view and -/// the corresponding userstory -class TimelineTranslations { - /// TimelineTranslations constructor where everything is required use this - /// if you want to be sure to have all translations specified - /// If you just want the default values use the empty constructor - /// and optionally override the values with the copyWith method - const TimelineTranslations({ - required this.anonymousUser, - required this.noPosts, - required this.noPostsWithFilter, - required this.title, - required this.titleHintText, - required this.content, - required this.contentHintText, - required this.contentDescription, - required this.uploadImage, - required this.uploadImageDescription, - required this.allowComments, - required this.allowCommentsDescription, - required this.commentsTitleOnPost, - required this.checkPost, - required this.deletePost, - required this.deleteReaction, - required this.deleteConfirmationMessage, - required this.deleteConfirmationTitle, - required this.deleteCancelButton, - required this.deleteButton, - required this.viewPost, - required this.oneLikeTitle, - required this.multipleLikesTitle, - required this.commentsTitle, - required this.firstComment, - required this.writeComment, - required this.postLoadingError, - required this.timelineSelectionDescription, - required this.searchHint, - required this.postOverview, - required this.postIn, - required this.postCreation, - required this.yes, - required this.no, - required this.timeLineScreenTitle, - required this.createCategoryPopuptitle, - required this.addCategoryTitle, - required this.addCategorySubmitButton, - required this.addCategoryCancelButtton, - required this.addCategoryHintText, - required this.addCategoryErrorText, - required this.titleErrorText, - required this.contentErrorText, - }); - - /// Default translations for the timeline component view - const TimelineTranslations.empty({ - this.anonymousUser = 'Anonymous user', - this.noPosts = 'No posts yet', - this.noPostsWithFilter = 'No posts with this filter', - this.title = 'Title', - this.titleHintText = 'Title...', - this.content = 'Content', - this.contentHintText = 'Content...', - this.contentDescription = 'What do you want to share?', - this.uploadImage = 'Upload image', - this.uploadImageDescription = 'Upload an image to your message (optional)', - this.allowComments = 'Are people allowed to comment?', - this.allowCommentsDescription = - 'Indicate whether people are allowed to respond', - this.commentsTitleOnPost = 'Comments', - this.checkPost = 'Overview', - this.deletePost = 'Delete post', - this.deleteConfirmationTitle = 'Delete Post', - this.deleteConfirmationMessage = - 'Are you sure you want to delete this post?', - this.deleteButton = 'Delete', - this.deleteCancelButton = 'Cancel', - this.deleteReaction = 'Delete Reaction', - this.viewPost = 'View post', - this.oneLikeTitle = 'like', - this.multipleLikesTitle = 'likes', - this.commentsTitle = 'Are people allowed to comment?', - this.firstComment = 'Be the first to comment', - this.writeComment = 'Write your comment here...', - this.postLoadingError = 'Something went wrong while loading the post', - this.timelineSelectionDescription = 'Choose a category', - this.searchHint = 'Search...', - this.postOverview = 'Post Overview', - this.postIn = 'Post', - this.postCreation = 'add post', - this.yes = 'Yes', - this.no = 'No', - this.timeLineScreenTitle = 'iconinstagram', - this.createCategoryPopuptitle = 'Choose a title for the new category', - this.addCategoryTitle = 'Add category', - this.addCategorySubmitButton = 'Add category', - this.addCategoryCancelButtton = 'Cancel', - this.addCategoryHintText = 'Category name...', - this.addCategoryErrorText = 'Please enter a category name', - this.titleErrorText = 'Please enter a title', - this.contentErrorText = 'Please enter content', - }); - - final String noPosts; - final String noPostsWithFilter; - final String anonymousUser; - - final String title; - final String content; - final String contentDescription; - final String uploadImage; - final String uploadImageDescription; - final String allowComments; - final String allowCommentsDescription; - final String checkPost; - - final String titleHintText; - final String contentHintText; - final String titleErrorText; - final String contentErrorText; - - final String deletePost; - final String deleteConfirmationTitle; - final String deleteConfirmationMessage; - final String deleteButton; - final String deleteCancelButton; - - final String deleteReaction; - final String viewPost; - final String oneLikeTitle; - final String multipleLikesTitle; - final String commentsTitle; - final String commentsTitleOnPost; - final String writeComment; - final String firstComment; - final String postLoadingError; - - final String timelineSelectionDescription; - - final String searchHint; - - final String postOverview; - final String postIn; - final String postCreation; - - final String createCategoryPopuptitle; - final String addCategoryTitle; - final String addCategorySubmitButton; - final String addCategoryCancelButtton; - final String addCategoryHintText; - final String addCategoryErrorText; - - final String yes; - final String no; - final String timeLineScreenTitle; - - /// Method to override the default values of the translations - TimelineTranslations copyWith({ - String? noPosts, - String? noPostsWithFilter, - String? anonymousUser, - String? title, - String? content, - String? contentDescription, - String? uploadImage, - String? uploadImageDescription, - String? allowComments, - String? allowCommentsDescription, - String? commentsTitleOnPost, - String? checkPost, - String? deletePost, - String? deleteConfirmationTitle, - String? deleteConfirmationMessage, - String? deleteButton, - String? deleteCancelButton, - String? deleteReaction, - String? viewPost, - String? oneLikeTitle, - String? multipleLikesTitle, - String? commentsTitle, - String? writeComment, - String? firstComment, - String? postLoadingError, - String? timelineSelectionDescription, - String? searchHint, - String? postOverview, - String? postIn, - String? postCreation, - String? titleHintText, - String? contentHintText, - String? yes, - String? no, - String? timeLineScreenTitle, - String? createCategoryPopuptitle, - String? addCategoryTitle, - String? addCategorySubmitButton, - String? addCategoryCancelButtton, - String? addCategoryHintText, - String? addCategoryErrorText, - String? titleErrorText, - String? contentErrorText, - }) => - TimelineTranslations( - noPosts: noPosts ?? this.noPosts, - noPostsWithFilter: noPostsWithFilter ?? this.noPostsWithFilter, - anonymousUser: anonymousUser ?? this.anonymousUser, - title: title ?? this.title, - content: content ?? this.content, - contentDescription: contentDescription ?? this.contentDescription, - uploadImage: uploadImage ?? this.uploadImage, - uploadImageDescription: - uploadImageDescription ?? this.uploadImageDescription, - allowComments: allowComments ?? this.allowComments, - allowCommentsDescription: - allowCommentsDescription ?? this.allowCommentsDescription, - commentsTitleOnPost: commentsTitleOnPost ?? this.commentsTitleOnPost, - checkPost: checkPost ?? this.checkPost, - deletePost: deletePost ?? this.deletePost, - deleteConfirmationTitle: - deleteConfirmationTitle ?? this.deleteConfirmationTitle, - deleteConfirmationMessage: - deleteConfirmationMessage ?? this.deleteConfirmationMessage, - deleteButton: deleteButton ?? this.deleteButton, - deleteCancelButton: deleteCancelButton ?? this.deleteCancelButton, - deleteReaction: deleteReaction ?? this.deleteReaction, - viewPost: viewPost ?? this.viewPost, - oneLikeTitle: oneLikeTitle ?? this.oneLikeTitle, - multipleLikesTitle: multipleLikesTitle ?? this.multipleLikesTitle, - commentsTitle: commentsTitle ?? this.commentsTitle, - writeComment: writeComment ?? this.writeComment, - firstComment: firstComment ?? this.firstComment, - postLoadingError: postLoadingError ?? this.postLoadingError, - timelineSelectionDescription: - timelineSelectionDescription ?? this.timelineSelectionDescription, - searchHint: searchHint ?? this.searchHint, - postOverview: postOverview ?? this.postOverview, - postIn: postIn ?? this.postIn, - postCreation: postCreation ?? this.postCreation, - titleHintText: titleHintText ?? this.titleHintText, - contentHintText: contentHintText ?? this.contentHintText, - yes: yes ?? this.yes, - no: no ?? this.no, - timeLineScreenTitle: timeLineScreenTitle ?? this.timeLineScreenTitle, - addCategoryTitle: addCategoryTitle ?? this.addCategoryTitle, - addCategorySubmitButton: - addCategorySubmitButton ?? this.addCategorySubmitButton, - addCategoryCancelButtton: - addCategoryCancelButtton ?? this.addCategoryCancelButtton, - addCategoryHintText: addCategoryHintText ?? this.addCategoryHintText, - createCategoryPopuptitle: - createCategoryPopuptitle ?? this.createCategoryPopuptitle, - addCategoryErrorText: addCategoryErrorText ?? this.addCategoryErrorText, - titleErrorText: titleErrorText ?? this.titleErrorText, - contentErrorText: contentErrorText ?? this.contentErrorText, - ); -} diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart deleted file mode 100644 index 7d1cf1c..0000000 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart +++ /dev/null @@ -1,395 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -import 'dart:math'; -import 'dart:typed_data'; - -import 'package:dotted_border/dotted_border.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/flutter_timeline_view.dart'; -import 'package:flutter_timeline_view/src/config/timeline_options.dart'; -import 'package:flutter_timeline_view/src/widgets/default_filled_button.dart'; -import 'package:flutter_timeline_view/src/widgets/post_creation_textfield.dart'; - -class TimelinePostCreationScreen extends StatefulWidget { - const TimelinePostCreationScreen({ - required this.userId, - required this.onPostCreated, - required this.service, - required this.options, - this.postCategory, - this.onPostOverview, - this.enablePostOverviewScreen = false, - super.key, - }); - - final String userId; - - final String? postCategory; - - /// called when the post is created - final Function(TimelinePost) onPostCreated; - - /// The service to use for creating the post - final TimelineService service; - - /// The options for the timeline - final TimelineOptions options; - - /// Nullable callback for routing to the post overview - final void Function(TimelinePost)? onPostOverview; - final bool enablePostOverviewScreen; - - @override - State createState() => - _TimelinePostCreationScreenState(); -} - -class _TimelinePostCreationScreenState - extends State { - TextEditingController titleController = TextEditingController(); - TextEditingController contentController = TextEditingController(); - Uint8List? image; - bool allowComments = false; - bool titleIsValid = false; - bool contentIsValid = false; - - @override - void initState() { - titleController.addListener(_listenForInputs); - contentController.addListener(_listenForInputs); - - super.initState(); - } - - void _listenForInputs() { - titleIsValid = titleController.text.isNotEmpty; - contentIsValid = contentController.text.isNotEmpty; - setState(() {}); - } - - var formkey = GlobalKey(); - - @override - Widget build(BuildContext context) { - var imageRequired = widget.options.requireImageForPost; - - Future onPostCreated() async { - var user = await widget.service.userService?.getUser(widget.userId); - var post = TimelinePost( - id: 'Post${Random().nextInt(1000)}', - creatorId: widget.userId, - title: titleController.text, - category: widget.postCategory, - content: contentController.text, - likes: 0, - likedBy: const [], - reaction: 0, - createdAt: DateTime.now(), - reactionEnabled: allowComments, - image: image, - creator: user, - ); - - if (widget.enablePostOverviewScreen) { - widget.onPostOverview?.call(post); - } else { - widget.onPostCreated.call(post); - } - } - - var theme = Theme.of(context); - - return GestureDetector( - onTap: () => FocusScope.of(context).unfocus(), - child: SingleChildScrollView( - child: Padding( - padding: widget.options.paddings.mainPadding, - child: Form( - key: formkey, - child: Column( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.options.translations.title, - style: theme.textTheme.titleMedium, - ), - const SizedBox( - height: 4, - ), - widget.options.textInputBuilder?.call( - titleController, - null, - '', - ) ?? - PostCreationTextfield( - fieldKey: const ValueKey('title'), - controller: titleController, - hintText: widget.options.translations.titleHintText, - textMaxLength: widget.options.maxTitleLength, - decoration: widget.options.titleInputDecoration, - textCapitalization: TextCapitalization.sentences, - expands: null, - minLines: null, - maxLines: 1, - validator: (value) { - if (value == null || value.isEmpty) { - return widget.options.translations.titleErrorText; - } - if (value.trim().isEmpty) { - return widget.options.translations.titleErrorText; - } - return null; - }, - ), - const SizedBox(height: 24), - Text( - widget.options.translations.content, - style: theme.textTheme.titleMedium, - ), - Text( - widget.options.translations.contentDescription, - style: theme.textTheme.bodySmall, - ), - const SizedBox( - height: 4, - ), - PostCreationTextfield( - fieldKey: const ValueKey('content'), - controller: contentController, - hintText: widget.options.translations.contentHintText, - textMaxLength: null, - decoration: widget.options.contentInputDecoration, - textCapitalization: TextCapitalization.sentences, - expands: false, - minLines: null, - maxLines: null, - validator: (value) { - if (value == null || value.isEmpty) { - return widget.options.translations.contentErrorText; - } - if (value.trim().isEmpty) { - return widget.options.translations.contentErrorText; - } - return null; - }, - ), - const SizedBox( - height: 24, - ), - Text( - widget.options.translations.uploadImage, - style: theme.textTheme.titleMedium, - ), - Text( - widget.options.translations.uploadImageDescription, - style: theme.textTheme.bodySmall, - ), - const SizedBox( - height: 8, - ), - Stack( - children: [ - GestureDetector( - onTap: () async { - var result = await showModalBottomSheet( - context: context, - builder: (context) => Container( - padding: const EdgeInsets.all(20), - color: theme.colorScheme.surface, - child: ImagePicker( - config: widget.options.imagePickerConfig, - theme: widget.options.imagePickerTheme ?? - ImagePickerTheme( - titleStyle: theme.textTheme.titleMedium, - iconSize: 40, - selectImageText: 'UPLOAD FILE', - makePhotoText: 'TAKE PICTURE', - selectImageIcon: const Icon( - size: 40, - Icons.insert_drive_file, - ), - closeButtonBuilder: (onTap) => TextButton( - onPressed: () { - onTap(); - }, - child: Text( - 'Cancel', - style: theme.textTheme.bodyMedium! - .copyWith( - decoration: TextDecoration.underline, - ), - ), - ), - ), - ), - ), - ); - if (result != null) { - setState(() { - image = result; - }); - } - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(8.0), - child: image != null - ? Image.memory( - image!, - width: double.infinity, - height: 150.0, - fit: BoxFit.cover, - // give it a rounded border - ) - : DottedBorder( - dashPattern: const [4, 4], - radius: const Radius.circular(8.0), - color: theme.textTheme.displayMedium?.color ?? - Colors.white, - child: const SizedBox( - width: double.infinity, - height: 150.0, - child: Icon( - Icons.image, - size: 50, - ), - ), - ), - ), - ), - // if an image is selected, show a delete button - if (image != null) ...[ - Positioned( - top: 8, - right: 8, - child: GestureDetector( - onTap: () { - setState(() { - image = null; - }); - }, - child: Container( - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), - borderRadius: BorderRadius.circular(8.0), - ), - child: const Icon( - Icons.delete, - color: Colors.white, - ), - ), - ), - ), - ], - ], - ), - const SizedBox(height: 16), - Text( - widget.options.translations.commentsTitle, - style: theme.textTheme.titleMedium, - ), - Text( - widget.options.translations.allowCommentsDescription, - style: theme.textTheme.bodySmall, - ), - const SizedBox( - height: 8, - ), - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Checkbox( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: - const VisualDensity(horizontal: -4, vertical: -4), - activeColor: theme.colorScheme.primary, - value: allowComments, - onChanged: (value) { - setState(() { - allowComments = true; - }); - }, - ), - const SizedBox( - width: 4, - ), - Text( - widget.options.translations.yes, - style: theme.textTheme.bodyMedium, - ), - const SizedBox( - width: 32, - ), - Checkbox( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: - const VisualDensity(horizontal: -4, vertical: -4), - activeColor: theme.colorScheme.primary, - value: !allowComments, - onChanged: (value) { - setState(() { - allowComments = false; - }); - }, - ), - const SizedBox( - width: 4, - ), - Text( - widget.options.translations.no, - style: theme.textTheme.bodyMedium, - ), - ], - ), - const SizedBox(height: 120), - SafeArea( - bottom: true, - child: Align( - alignment: Alignment.bottomCenter, - child: widget.options.buttonBuilder?.call( - context, - onPostCreated, - widget.options.translations.checkPost, - enabled: formkey.currentState!.validate(), - ) ?? - Padding( - padding: const EdgeInsets.symmetric(horizontal: 48), - child: Row( - children: [ - Expanded( - child: DefaultFilledButton( - onPressed: titleIsValid && - contentIsValid && - (!imageRequired || image != null) - ? () async { - if (formkey.currentState! - .validate()) { - await onPostCreated(); - await widget.service.postService - .fetchPosts(null); - } - } - : null, - buttonText: widget.enablePostOverviewScreen - ? widget.options.translations.checkPost - : widget - .options.translations.postCreation, - ), - ), - ], - ), - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_overview_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_overview_screen.dart deleted file mode 100644 index 89d3e0a..0000000 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_overview_screen.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; -import 'package:flutter_timeline_view/flutter_timeline_view.dart'; -import 'package:flutter_timeline_view/src/widgets/default_filled_button.dart'; - -class TimelinePostOverviewScreen extends StatelessWidget { - const TimelinePostOverviewScreen({ - required this.timelinePost, - required this.options, - required this.service, - required this.onPostSubmit, - super.key, - }); - final TimelinePost timelinePost; - final TimelineOptions options; - final TimelineService service; - final void Function(TimelinePost) onPostSubmit; - - @override - Widget build(BuildContext context) { - var isSubmitted = false; - return Column( - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - child: TimelinePostScreen( - userId: timelinePost.creatorId, - options: options, - post: timelinePost, - onPostDelete: () async {}, - service: service, - isOverviewScreen: true, - ), - ), - options.postOverviewButtonBuilder?.call( - context, - () { - if (isSubmitted) return; - isSubmitted = true; - onPostSubmit(timelinePost); - }, - options.translations.postIn, - timelinePost, - ) ?? - options.buttonBuilder?.call( - context, - () { - if (isSubmitted) return; - isSubmitted = true; - onPostSubmit(timelinePost); - }, - options.translations.postIn, - enabled: true, - ) ?? - SafeArea( - bottom: true, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 80), - child: Row( - children: [ - Expanded( - child: DefaultFilledButton( - onPressed: () async { - if (isSubmitted) return; - isSubmitted = true; - onPostSubmit(timelinePost); - }, - buttonText: options.translations.postIn, - ), - ), - ], - ), - ), - ), - ], - ); - } -} 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 deleted file mode 100644 index 450f207..0000000 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart +++ /dev/null @@ -1,695 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -import 'dart:async'; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.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:flutter_timeline_view/src/widgets/tappable_image.dart'; -import 'package:flutter_timeline_view/src/widgets/timeline_post_widget.dart'; -import 'package:intl/intl.dart'; - -class TimelinePostScreen extends StatefulWidget { - const TimelinePostScreen({ - required this.userId, - required this.service, - required this.options, - required this.post, - required this.onPostDelete, - this.allowAllDeletion = false, - this.isOverviewScreen = false, - this.onUserTap, - super.key, - }); - - /// The user id of the current user - final String userId; - - /// Allow all posts to be deleted instead of - /// only the posts of the current user - final bool allowAllDeletion; - - /// The timeline service to fetch the post details - final TimelineService service; - - /// Options to configure the timeline screens - final TimelineOptions options; - - /// The post to show - final TimelinePost post; - - /// If this is not null, the user can tap on the user avatar or name - final Function(String userId)? onUserTap; - - final VoidCallback onPostDelete; - - final bool? isOverviewScreen; - - @override - State createState() => _TimelinePostScreenState(); -} - -class _TimelinePostScreenState extends State { - TimelinePost? post; - bool isLoading = true; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) async { - await loadPostDetails(); - }); - } - - Future loadPostDetails() async { - try { - var loadedPost = - await widget.service.postService.fetchPostDetails(widget.post); - setState(() { - post = loadedPost; - isLoading = false; - }); - } on Exception catch (_) { - setState(() { - isLoading = false; - }); - } - } - - void updatePost(TimelinePost newPost) { - setState(() { - post = newPost; - }); - } - - @override - Widget build(BuildContext context) { - var theme = Theme.of(context); - var dateFormat = widget.options.dateFormat ?? - DateFormat( - "dd/MM/yyyy 'at' HH:mm", - Localizations.localeOf(context).languageCode, - ); - if (isLoading) { - return const Center( - child: CircularProgressIndicator.adaptive(), - ); - } - if (this.post == null) { - return Center( - child: Text( - widget.options.translations.postLoadingError, - style: widget.options.theme.textStyles.errorTextStyle, - ), - ); - } - var post = this.post!; - post.reactions?.sort( - (a, b) => widget.options.sortCommentsAscending - ? a.createdAt.compareTo(b.createdAt) - : b.createdAt.compareTo(a.createdAt), - ); - var isLikedByUser = post.likedBy?.contains(widget.userId) ?? false; - - var textInputBuilder = widget.options.textInputBuilder ?? - (controller, suffixIcon, hintText) => TextField( - style: theme.textTheme.bodyMedium, - textCapitalization: TextCapitalization.sentences, - controller: controller, - decoration: InputDecoration( - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(25), - borderSide: const BorderSide( - color: Colors.black, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(25), - borderSide: const BorderSide( - color: Colors.black, - ), - ), - contentPadding: const EdgeInsets.symmetric( - vertical: 0, - horizontal: 16, - ), - hintText: widget.options.translations.writeComment, - hintStyle: theme.textTheme.bodyMedium!.copyWith( - color: theme.textTheme.bodyMedium!.color!.withOpacity(0.5), - ), - fillColor: Colors.white, - filled: true, - border: const OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(25), - ), - borderSide: BorderSide.none, - ), - suffixIcon: suffixIcon, - ), - ); - - return Stack( - children: [ - RefreshIndicator.adaptive( - onRefresh: () async { - updatePost( - await widget.service.postService.fetchPostDetails( - await widget.service.postService.fetchPost( - post, - ), - ), - ); - }, - child: SingleChildScrollView( - child: Padding( - padding: widget.options.paddings.postPadding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (post.creator != null) - InkWell( - onTap: widget.onUserTap != null - ? () => - widget.onUserTap?.call(post.creator!.userId) - : null, - child: Row( - children: [ - if (post.creator!.imageUrl != null) ...[ - widget.options.userAvatarBuilder?.call( - post.creator!, - 28, - ) ?? - CircleAvatar( - radius: 14, - backgroundImage: - CachedNetworkImageProvider( - post.creator!.imageUrl!, - ), - ), - ] else ...[ - widget.options.anonymousAvatarBuilder?.call( - post.creator!, - 28, - ) ?? - const CircleAvatar( - radius: 14, - child: Icon( - Icons.person, - ), - ), - ], - const SizedBox(width: 10), - Text( - widget.options.nameBuilder - ?.call(post.creator) ?? - post.creator?.fullName ?? - widget.options.translations.anonymousUser, - style: widget.options.theme.textStyles - .postCreatorTitleStyle ?? - theme.textTheme.titleSmall!.copyWith( - color: Colors.black, - ), - ), - ], - ), - ), - const Spacer(), - if (!(widget.isOverviewScreen ?? false) && - (widget.allowAllDeletion || - post.creator?.userId == widget.userId)) ...[ - PopupMenuButton( - onSelected: (value) async { - if (value == 'delete') { - await showPostDeletionConfirmationDialog( - widget.options, - context, - widget.onPostDelete, - ); - } - }, - itemBuilder: (BuildContext context) => - >[ - PopupMenuItem( - value: 'delete', - child: Row( - children: [ - Text( - widget.options.translations.deletePost, - style: widget.options.theme.textStyles - .deletePostStyle ?? - theme.textTheme.bodyMedium, - ), - 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, - ), - ), - ], - ], - ), - // image of the posts - if (post.imageUrl != null || post.image != null) ...[ - const SizedBox(height: 8), - ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: widget.options.doubleTapTolike - ? TappableImage( - likeAndDislikeIcon: widget - .options.likeAndDislikeIconsForDoubleTap, - post: post, - userId: widget.userId, - onLike: ({required bool liked}) async { - var userId = widget.userId; - - late TimelinePost result; - - if (!liked) { - result = - await widget.service.postService.likePost( - userId, - post, - ); - } else { - result = await widget.service.postService - .unlikePost( - userId, - post, - ); - } - - await loadPostDetails(); - - return result.likedBy?.contains(userId) ?? - false; - }, - ) - : post.image != null - ? Image.memory( - width: double.infinity, - post.image!, - fit: BoxFit.fitHeight, - ) - : CachedNetworkImage( - width: double.infinity, - imageUrl: post.imageUrl!, - fit: BoxFit.fitHeight, - ), - ), - ], - const SizedBox( - height: 8, - ), - // post information - Row( - children: [ - IconButton( - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - onPressed: () async { - if (widget.isOverviewScreen ?? false) return; - if (isLikedByUser) { - updatePost( - await widget.service.postService.unlikePost( - widget.userId, - post, - ), - ); - setState(() {}); - } else { - updatePost( - await widget.service.postService.likePost( - widget.userId, - post, - ), - ); - setState(() {}); - } - }, - icon: isLikedByUser - ? widget.options.theme.likedIcon ?? - Icon( - Icons.favorite_rounded, - color: widget.options.theme.iconColor, - size: widget.options.iconSize, - ) - : widget.options.theme.likeIcon ?? - Icon( - Icons.favorite_outline_outlined, - color: widget.options.theme.iconColor, - size: widget.options.iconSize, - ), - ), - const SizedBox(width: 8), - if (post.reactionEnabled) - widget.options.theme.commentIcon ?? - SvgPicture.asset( - 'assets/Comment.svg', - package: 'flutter_timeline_view', - // ignore: deprecated_member_use - color: widget.options.theme.iconColor, - width: widget.options.iconSize, - height: widget.options.iconSize, - ), - ], - ), - const SizedBox(height: 8), - // ignore: avoid_bool_literals_in_conditional_expressions - if (widget.isOverviewScreen != null - ? !widget.isOverviewScreen! - : false) ...[ - Text( - // ignore: lines_longer_than_80_chars - '${post.likes} ${post.likes > 1 ? widget.options.translations.multipleLikesTitle : widget.options.translations.oneLikeTitle}', - style: widget.options.theme.textStyles - .postLikeTitleAndAmount ?? - theme.textTheme.titleSmall - ?.copyWith(color: Colors.black), - ), - ], - Text.rich( - TextSpan( - text: widget.options.nameBuilder?.call(post.creator) ?? - post.creator?.fullName ?? - widget.options.translations.anonymousUser, - style: widget - .options.theme.textStyles.postCreatorNameStyle ?? - theme.textTheme.titleSmall! - .copyWith(color: Colors.black), - children: [ - TextSpan( - text: post.title, - style: - widget.options.theme.textStyles.postTitleStyle ?? - theme.textTheme.bodySmall, - ), - ], - ), - ), - const SizedBox(height: 20), - Text( - post.content, - style: theme.textTheme.bodySmall, - ), - Text( - '${dateFormat.format(post.createdAt)} ', - style: theme.textTheme.labelSmall?.copyWith( - letterSpacing: 0.5, - ), - ), - const SizedBox(height: 8), - // ignore: avoid_bool_literals_in_conditional_expressions - if (post.reactionEnabled && widget.isOverviewScreen != null - ? !widget.isOverviewScreen! - : false) ...[ - Text( - widget.options.translations.commentsTitleOnPost, - style: theme.textTheme.titleSmall! - .copyWith(color: Colors.black), - ), - for (var reaction - in post.reactions ?? []) ...[ - const SizedBox(height: 4), - GestureDetector( - onLongPressStart: (details) async { - if (reaction.creatorId == widget.userId || - widget.allowAllDeletion) { - var overlay = Overlay.of(context) - .context - .findRenderObject()! as RenderBox; - var position = RelativeRect.fromRect( - Rect.fromPoints( - details.globalPosition, - details.globalPosition, - ), - Offset.zero & overlay.size, - ); - // Show popup menu for deletion - var value = await showMenu( - context: context, - position: position, - items: [ - PopupMenuItem( - value: 'delete', - child: Text( - widget.options.translations.deleteReaction, - ), - ), - ], - ); - if (value == 'delete') { - // Call service to delete reaction - updatePost( - await widget.service.postService - .deletePostReaction(post, reaction.id), - ); - } - } - }, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (reaction.creator?.imageUrl != null && - reaction.creator!.imageUrl!.isNotEmpty) ...[ - widget.options.userAvatarBuilder?.call( - reaction.creator!, - 14, - ) ?? - CircleAvatar( - radius: 14, - backgroundImage: CachedNetworkImageProvider( - reaction.creator!.imageUrl!, - ), - ), - ] else ...[ - widget.options.anonymousAvatarBuilder?.call( - reaction.creator!, - 14, - ) ?? - const CircleAvatar( - radius: 14, - child: Icon( - Icons.person, - ), - ), - ], - const SizedBox(width: 10), - if (reaction.imageUrl != null) ...[ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.options.nameBuilder - ?.call(reaction.creator) ?? - reaction.creator?.fullName ?? - widget.options.translations - .anonymousUser, - style: theme.textTheme.titleSmall! - .copyWith(color: Colors.black), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: CachedNetworkImage( - imageUrl: reaction.imageUrl!, - fit: BoxFit.fitWidth, - ), - ), - ], - ), - ), - ] else ...[ - Expanded( - child: Text.rich( - TextSpan( - text: widget.options.nameBuilder - ?.call(reaction.creator) ?? - reaction.creator?.fullName ?? - widget - .options.translations.anonymousUser, - style: theme.textTheme.titleSmall! - .copyWith(color: Colors.black), - children: [ - const TextSpan(text: ' '), - TextSpan( - text: reaction.reaction ?? '', - style: theme.textTheme.bodySmall, - ), - const TextSpan(text: '\n'), - TextSpan( - text: dateFormat - .format(reaction.createdAt), - style: theme.textTheme.labelSmall! - .copyWith( - color: theme - .textTheme.labelSmall!.color! - .withOpacity(0.5), - letterSpacing: 0.5, - ), - ), - - // text should go to new line - ], - ), - ), - ), - ], - Builder( - builder: (context) { - var isLikedByUser = - reaction.likedBy?.contains(widget.userId) ?? - false; - return IconButton( - padding: const EdgeInsets.only(left: 12), - constraints: const BoxConstraints(), - onPressed: () async { - if (isLikedByUser) { - updatePost( - await widget.service.postService - .unlikeReaction( - widget.userId, - post, - reaction.id, - ), - ); - setState(() {}); - } else { - updatePost( - await widget.service.postService - .likeReaction( - widget.userId, - post, - reaction.id, - ), - ); - setState(() {}); - } - }, - icon: isLikedByUser - ? widget.options.theme.likedIcon ?? - Icon( - Icons.favorite_rounded, - color: - widget.options.theme.iconColor, - size: 14, - ) - : widget.options.theme.likeIcon ?? - Icon( - Icons.favorite_outline_outlined, - color: - widget.options.theme.iconColor, - size: 14, - ), - ); - }, - ), - ], - ), - ), - const SizedBox(height: 4), - ], - if (post.reactions?.isEmpty ?? true) ...[ - Text( - widget.options.translations.firstComment, - style: theme.textTheme.bodySmall, - ), - ], - const SizedBox(height: 120), - ], - ], - ), - ), - ), - ), - if (post.reactionEnabled && !(widget.isOverviewScreen ?? false)) - Align( - alignment: Alignment.bottomCenter, - child: Container( - color: theme.scaffoldBackgroundColor, - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width, - ), - child: SafeArea( - bottom: true, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.only(left: 8), - child: post.creator!.imageUrl != null - ? widget.options.userAvatarBuilder?.call( - post.creator!, - 28, - ) ?? - CircleAvatar( - radius: 14, - backgroundImage: CachedNetworkImageProvider( - post.creator!.imageUrl!, - ), - ) - : widget.options.anonymousAvatarBuilder?.call( - post.creator!, - 28, - ) ?? - const CircleAvatar( - radius: 14, - child: Icon( - Icons.person, - ), - ), - ), - Flexible( - child: Padding( - padding: const EdgeInsets.only( - left: 8, - right: 16, - top: 8, - bottom: 8, - ), - child: ReactionBottom( - messageInputBuilder: textInputBuilder, - onReactionSubmit: (reaction) async => updatePost( - await widget.service.postService.reactToPost( - post, - TimelinePostReaction( - id: '', - postId: post.id, - reaction: reaction, - creatorId: widget.userId, - createdAt: DateTime.now(), - ), - ), - ), - translations: widget.options.translations, - iconColor: 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 deleted file mode 100644 index f7db082..0000000 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart +++ /dev/null @@ -1,343 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -import 'dart:async'; - -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; -import 'package:flutter_timeline_view/flutter_timeline_view.dart'; - -class TimelineScreen extends StatefulWidget { - const TimelineScreen({ - this.userId = 'test_user', - this.service, - this.options = const TimelineOptions(), - this.onPostTap, - this.scrollController, - this.onUserTap, - this.onRefresh, - this.posts, - this.timelineCategory, - this.postWidgetBuilder, - this.filterEnabled = false, - this.allowAllDeletion = false, - super.key, - }); - - /// The user id of the current user - final String userId; - - /// Allow all posts to be deleted instead of - /// only the posts of the current user - final bool allowAllDeletion; - - /// The service to use for fetching and manipulating posts - final TimelineService? service; - - /// All the configuration options for the timelinescreens and widgets - final TimelineOptions options; - - /// The controller for the scroll view - final ScrollController? scrollController; - - /// The string to filter the timeline by category - final String? timelineCategory; - - /// This is used if you want to pass in a list of posts instead - /// of fetching them from the service - final List? posts; - - /// Called when a post is tapped - final Function(TimelinePost)? onPostTap; - - /// Called when the timeline is refreshed by pulling down - final Function(BuildContext context, String? category)? onRefresh; - - /// If this is not null, the user can tap on the user avatar or name - final Function(String userId)? onUserTap; - - /// Override the standard postwidget - final Widget Function(TimelinePost post)? postWidgetBuilder; - - /// if true the filter textfield is enabled. - final bool filterEnabled; - - @override - State createState() => _TimelineScreenState(); -} - -class _TimelineScreenState extends State { - late ScrollController controller; - late var textFieldController = TextEditingController( - text: widget.options.filterOptions.initialFilterWord, - ); - late var service = widget.service ?? - TimelineService( - postService: LocalTimelinePostService(), - ); - - bool isLoading = true; - - late var category = widget.timelineCategory; - - late var filterWord = widget.options.filterOptions.initialFilterWord; - - bool _isOnTop = true; - - @override - void dispose() { - controller.removeListener(_updateIsOnTop); - controller.dispose(); - super.dispose(); - } - - void _updateIsOnTop() { - setState(() { - _isOnTop = controller.position.pixels < 0.1; - }); - } - - @override - void initState() { - super.initState(); - controller = widget.scrollController ?? ScrollController(); - controller.addListener(_updateIsOnTop); - - // only load the posts after the first frame - WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(loadPosts()); - }); - } - - @override - Widget build(BuildContext context) { - if (isLoading && widget.posts == null) { - return const Center(child: CircularProgressIndicator.adaptive()); - } - - // Build the list of posts - return ListenableBuilder( - listenable: service.postService, - builder: (context, _) { - if (!context.mounted) return const SizedBox(); - var posts = widget.posts ?? service.postService.getPosts(category); - - if (widget.filterEnabled && filterWord != null) { - if (service.postService is TimelineFilterService) { - posts = (service.postService as TimelineFilterService) - .filterPosts(filterWord!, {}); - } else { - debugPrint('Timeline service needs to mixin' - ' with TimelineFilterService'); - } - } - - posts = posts - .where( - (p) => category == null || p.category == category, - ) - .toList(); - - // sort posts by date - if (widget.options.sortPostsAscending != null) { - posts.sort( - (a, b) => widget.options.sortPostsAscending! - ? a.createdAt.compareTo(b.createdAt) - : b.createdAt.compareTo(a.createdAt), - ); - } - - var categories = service.postService.categories; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - height: widget.options.paddings.mainPadding.top, - ), - if (widget.filterEnabled) ...[ - Padding( - padding: EdgeInsets.only( - left: widget.options.paddings.mainPadding.left, - right: widget.options.paddings.mainPadding.right, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: TextField( - controller: textFieldController, - onChanged: (value) { - setState(() { - filterWord = value; - }); - }, - decoration: InputDecoration( - hintText: widget.options.translations.searchHint, - suffixIconConstraints: - const BoxConstraints(maxHeight: 14), - contentPadding: const EdgeInsets.only( - left: 12, - right: 12, - bottom: -10, - ), - suffixIcon: const Padding( - padding: EdgeInsets.only(right: 12), - child: Icon(Icons.search), - ), - ), - ), - ), - const SizedBox( - width: 8, - ), - InkWell( - onTap: () { - setState(() { - textFieldController.clear(); - filterWord = null; - widget.options.filterOptions.onFilterEnabledChange - ?.call(filterEnabled: false); - }); - }, - child: const Padding( - padding: EdgeInsets.all(8), - child: Icon( - Icons.close, - color: Color(0xFF000000), - ), - ), - ), - ], - ), - ), - const SizedBox( - height: 24, - ), - ], - CategorySelector( - categories: categories, - isOnTop: _isOnTop, - filter: category, - options: widget.options, - onTapCategory: (categoryKey) { - setState(() { - service.postService.selectedCategory = - categories.firstWhereOrNull( - (element) => element.key == categoryKey, - ); - category = categoryKey; - }); - }, - ), - const SizedBox( - height: 12, - ), - Expanded( - child: RefreshIndicator.adaptive( - onRefresh: () async { - await widget.onRefresh?.call(context, category); - await loadPosts(); - }, - child: SingleChildScrollView( - controller: controller, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - /// Add a optional custom header to the list of posts - widget.options.listHeaderBuilder - ?.call(context, category) ?? - const SizedBox.shrink(), - ...posts.map( - (post) => Padding( - padding: widget.options.paddings.postPadding, - child: widget.postWidgetBuilder?.call(post) ?? - TimelinePostWidget( - service: service, - userId: widget.userId, - options: widget.options, - allowAllDeletion: widget.allowAllDeletion, - post: post, - onTap: () async { - if (widget.onPostTap != null) { - widget.onPostTap!.call(post); - - return; - } - - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => Scaffold( - body: TimelinePostScreen( - userId: 'test_user', - service: service, - options: widget.options, - post: post, - onPostDelete: () { - service.postService - .deletePost(post); - Navigator.of(context).pop(); - }, - ), - ), - ), - ); - }, - onTapLike: () async => service.postService - .likePost(widget.userId, post), - onTapUnlike: () async => service.postService - .unlikePost(widget.userId, post), - onPostDelete: () async => - service.postService.deletePost(post), - onUserTap: widget.onUserTap, - ), - ), - ), - if (posts.isEmpty) - Center( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - category == null - ? widget.options.translations.noPosts - : widget - .options.translations.noPostsWithFilter, - style: - widget.options.theme.textStyles.noPostsStyle, - ), - ), - ), - SizedBox( - height: widget.options.paddings.mainPadding.bottom, - ), - ], - ), - ), - ), - ), - ], - ); - }, - ); - } - - Future loadPosts() async { - if (widget.posts != null || !context.mounted) return; - try { - await service.postService.fetchCategories(); - await service.postService.fetchPosts(category); - setState(() { - isLoading = false; - }); - } on Exception catch (e) { - // Handle errors here - debugPrint('Error loading posts: $e'); - setState(() { - isLoading = false; - }); - } - } -} diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_selection_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_selection_screen.dart deleted file mode 100644 index 3700e10..0000000 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_selection_screen.dart +++ /dev/null @@ -1,215 +0,0 @@ -import 'package:flutter/material.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/default_filled_button.dart'; -import 'package:flutter_timeline_view/src/widgets/post_creation_textfield.dart'; - -class TimelineSelectionScreen extends StatefulWidget { - const TimelineSelectionScreen({ - required this.options, - required this.categories, - required this.onCategorySelected, - required this.postService, - super.key, - }); - - final List categories; - - final TimelineOptions options; - - final Function(TimelineCategory) onCategorySelected; - - final TimelinePostService postService; - - @override - State createState() => - _TimelineSelectionScreenState(); -} - -class _TimelineSelectionScreenState extends State { - @override - Widget build(BuildContext context) { - var size = MediaQuery.of(context).size; - var theme = Theme.of(context); - - return Padding( - padding: EdgeInsets.symmetric( - horizontal: size.width * 0.05, - ), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 20, bottom: 12), - child: Text( - widget.options.translations.timelineSelectionDescription, - style: theme.textTheme.titleLarge, - ), - ), - for (var category in widget.categories.where( - (element) => element.canCreate && element.key != null, - )) ...[ - widget.options.categorySelectorButtonBuilder?.call( - context, - () { - widget.onCategorySelected.call(category); - }, - category.title, - ) ?? - InkWell( - onTap: () => widget.onCategorySelected.call(category), - child: Container( - height: 60, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: widget.options.theme - .categorySelectionButtonBorderColor ?? - Theme.of(context).primaryColor, - width: 2, - ), - color: widget.options.theme - .categorySelectionButtonBackgroundColor, - ), - margin: const EdgeInsets.symmetric(vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 12.0), - child: Text( - category.title, - style: theme.textTheme.titleMedium, - ), - ), - ], - ), - ), - ), - ], - InkWell( - onTap: showCategoryPopup, - child: Container( - height: 60, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: widget - .options.theme.categorySelectionButtonBorderColor ?? - const Color(0xFF9E9E9E), - width: 2, - ), - color: widget - .options.theme.categorySelectionButtonBackgroundColor, - ), - margin: const EdgeInsets.symmetric(vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Row( - children: [ - Icon( - Icons.add, - color: theme.textTheme.titleMedium?.color! - .withOpacity(0.5), - ), - const SizedBox(width: 8), - Text( - widget.options.translations.addCategoryTitle, - style: theme.textTheme.titleMedium!.copyWith( - color: theme.textTheme.titleMedium?.color! - .withOpacity(0.5), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } - - Future showCategoryPopup() async { - var theme = Theme.of(context); - var controller = TextEditingController(); - await showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: theme.scaffoldBackgroundColor, - insetPadding: const EdgeInsets.symmetric( - horizontal: 16, - ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 50, vertical: 24), - titlePadding: const EdgeInsets.only(left: 44, right: 44, top: 32), - title: Text( - widget.options.translations.createCategoryPopuptitle, - style: theme.textTheme.titleMedium, - textAlign: TextAlign.center, - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - PostCreationTextfield( - controller: controller, - hintText: widget.options.translations.addCategoryHintText, - validator: (p0) => p0!.isEmpty - ? widget.options.translations.addCategoryErrorText - : null, - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: DefaultFilledButton( - onPressed: () async { - if (controller.text.isEmpty) return; - await widget.postService.addCategory( - TimelineCategory( - key: controller.text, - title: controller.text, - ), - ); - setState(() {}); - if (context.mounted) Navigator.pop(context); - }, - buttonText: - widget.options.translations.addCategorySubmitButton, - ), - ), - ), - ], - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(false); - }, - child: Text( - widget.options.translations.addCategoryCancelButtton, - style: theme.textTheme.bodyMedium!.copyWith( - decoration: TextDecoration.underline, - color: theme.textTheme.bodyMedium?.color!.withOpacity(0.5), - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/packages/flutter_timeline_view/lib/src/services/local_post_service.dart b/packages/flutter_timeline_view/lib/src/services/local_post_service.dart deleted file mode 100644 index aecf6fd..0000000 --- a/packages/flutter_timeline_view/lib/src/services/local_post_service.dart +++ /dev/null @@ -1,332 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -import 'dart:typed_data'; - -import 'package:flutter/material.dart'; -import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; - -class LocalTimelinePostService - with ChangeNotifier - implements TimelinePostService { - @override - List posts = []; - - @override - List categories = []; - - @override - TimelineCategory? selectedCategory; - - @override - Future createPost(TimelinePost post) async { - posts.add( - post.copyWith( - creator: const TimelinePosterUserModel( - userId: 'test_user', - imageUrl: - 'https://cdn.britannica.com/68/143568-050-5246474F/Donkey.jpg?w=400&h=300&c=crop', - firstName: 'Ico', - lastName: 'Nica', - ), - ), - ); - notifyListeners(); - return post; - } - - @override - Future deletePost(TimelinePost post) async { - posts = posts.where((element) => element.id != post.id).toList(); - - 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(); - - notifyListeners(); - return updatedPost; - } - return post; - } - - @override - Future fetchPostDetails(TimelinePost post) async { - var reactions = post.reactions ?? []; - var updatedReactions = []; - for (var reaction in reactions) { - updatedReactions.add( - reaction.copyWith( - creator: const TimelinePosterUserModel( - userId: 'test_user', - imageUrl: - 'https://cdn.britannica.com/68/143568-050-5246474F/Donkey.jpg?w=400&h=300&c=crop', - firstName: 'Dirk', - lastName: 'lukassen', - ), - ), - ); - } - 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 { - if (posts.isEmpty) { - posts = getMockedPosts(); - } - notifyListeners(); - return posts; - } - - @override - Future> fetchPostsPaginated( - String? category, - int limit, - ) async { - notifyListeners(); - return posts; - } - - @override - Future fetchPost(TimelinePost post) async { - notifyListeners(); - return post; - } - - @override - Future> refreshPosts(String? category) async { - var newPosts = []; - - posts = [...posts, ...newPosts]; - notifyListeners(); - return posts; - } - - @override - Future getPost(String postId) => Future.value( - (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 { - var updatedPost = post.copyWith( - likes: post.likes + 1, - likedBy: (post.likedBy ?? [])..add(userId), - ); - posts = posts - .map( - (p) => p.id == post.id ? updatedPost : p, - ) - .toList(); - - notifyListeners(); - return updatedPost; - } - - @override - Future unlikePost(String userId, TimelinePost post) async { - var updatedPost = post.copyWith( - likes: post.likes - 1, - likedBy: post.likedBy?..remove(userId), - ); - posts = posts - .map( - (p) => p.id == post.id ? updatedPost : p, - ) - .toList(); - - notifyListeners(); - return updatedPost; - } - - @override - Future reactToPost( - TimelinePost post, - TimelinePostReaction reaction, { - Uint8List? image, - }) async { - var reactionId = DateTime.now().millisecondsSinceEpoch.toString(); - - var updatedReaction = reaction.copyWith( - id: reactionId, - creator: const TimelinePosterUserModel( - userId: 'test_user', - imageUrl: - 'https://cdn.britannica.com/68/143568-050-5246474F/Donkey.jpg?w=400&h=300&c=crop', - firstName: 'Ico', - lastName: 'Nica', - ), - ); - - var updatedPost = post.copyWith( - reaction: post.reaction + 1, - reactions: post.reactions?..add(updatedReaction), - ); - - posts = posts - .map( - (p) => p.id == post.id ? updatedPost : p, - ) - .toList(); - notifyListeners(); - return updatedPost; - } - - List getMockedPosts() => [ - TimelinePost( - id: 'Post0', - creatorId: 'test_user', - title: 'De topper van de maand september', - category: 'Category', - imageUrl: - 'https://firebasestorage.googleapis.com/v0/b/appshell-demo.appspot.com/o/do_not_delete_1.png?alt=media&token=e4b2f9f3-c81f-4ac7-a938-e846691399f7', - content: 'Dit is onze topper van de maand september! Gefeliciteerd!', - likes: 72, - reaction: 0, - createdAt: DateTime.now(), - reactionEnabled: true, - creator: const TimelinePosterUserModel( - userId: 'test_user', - imageUrl: - 'https://firebasestorage.googleapis.com/v0/b/appshell-demo.appspot.com/o/do_not_delete_3.png?alt=media&token=cd7c156d-0dda-43be-9199-f7d31c30132e', - firstName: 'Robin', - lastName: 'De Vries', - ), - ), - TimelinePost( - id: 'Post1', - creatorId: 'test_user2', - title: 'De soep van de week is: Aspergesoep', - category: 'Category with two lines', - content: - 'Aspergesoep is echt een heerlijke delicatesse. Deze soep wordt' - ' vaak gemaakt met verse asperges, bouillon en wat kruiden voor' - ' smaak. Het is een perfecte keuze voor een lichte en smaakvolle' - ' maaltijd, vooral in het voorjaar wanneer asperges in seizoen' - ' zijn. We serveren het met een vleugje room en wat knapperige' - ' croutons voor die extra touch.', - likes: 72, - reaction: 0, - createdAt: DateTime.now(), - reactionEnabled: true, - imageUrl: - 'https://firebasestorage.googleapis.com/v0/b/appshell-demo.appspot.com/o/do_not_delete_2.png?alt=media&token=ee4a8771-531f-4d1d-8613-a2366771e775', - creator: const TimelinePosterUserModel( - userId: 'test_user', - imageUrl: - 'https://firebasestorage.googleapis.com/v0/b/appshell-demo.appspot.com/o/do_not_delete_4.png?alt=media&token=775d4d10-6d2b-4aef-a51b-ba746b7b137f', - firstName: 'Elise', - lastName: 'Welling', - ), - ), - ]; - - @override - Future addCategory(TimelineCategory category) async { - categories.add(category); - notifyListeners(); - return true; - } - - @override - Future> fetchCategories() async { - categories = [ - const TimelineCategory(key: null, title: 'All'), - const TimelineCategory( - key: 'Category', - title: 'Category', - ), - const TimelineCategory( - key: 'Category with two lines', - title: 'Category with two lines', - ), - ]; - notifyListeners(); - - return categories; - } - - @override - Future likeReaction( - String userId, - TimelinePost post, - String reactionId, - ) async { - var updatedPost = post.copyWith( - reactions: post.reactions?.map( - (r) { - if (r.id == reactionId) { - return r.copyWith( - likedBy: (r.likedBy ?? [])..add(userId), - ); - } - return r; - }, - ).toList(), - ); - posts = posts - .map( - (p) => p.id == post.id ? updatedPost : p, - ) - .toList(); - - notifyListeners(); - return updatedPost; - } - - @override - Future unlikeReaction( - String userId, - TimelinePost post, - String reactionId, - ) async { - var updatedPost = post.copyWith( - reactions: post.reactions?.map( - (r) { - if (r.id == reactionId) { - return r.copyWith( - likedBy: r.likedBy?..remove(userId), - ); - } - return r; - }, - ).toList(), - ); - posts = posts - .map( - (p) => p.id == post.id ? updatedPost : p, - ) - .toList(); - - notifyListeners(); - return updatedPost; - } -} diff --git a/packages/flutter_timeline_view/lib/src/widgets/category_selector.dart b/packages/flutter_timeline_view/lib/src/widgets/category_selector.dart deleted file mode 100644 index 007f016..0000000 --- a/packages/flutter_timeline_view/lib/src/widgets/category_selector.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; -import 'package:flutter_timeline_view/flutter_timeline_view.dart'; - -class CategorySelector extends StatefulWidget { - const CategorySelector({ - required this.filter, - required this.options, - required this.onTapCategory, - required this.isOnTop, - required this.categories, - super.key, - }); - - final String? filter; - final TimelineOptions options; - final void Function(String? categoryKey) onTapCategory; - final bool isOnTop; - final List categories; - - @override - State createState() => _CategorySelectorState(); -} - -class _CategorySelectorState extends State { - @override - Widget build(BuildContext context) => SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - children: [ - SizedBox( - width: widget.options.categoriesOptions - .categorySelectorHorizontalPadding ?? - max(widget.options.paddings.mainPadding.left - 20, 0), - ), - for (var category in widget.categories) ...[ - widget.options.categoriesOptions.categoryButtonBuilder?.call( - category, - () => widget.onTapCategory(category.key), - widget.filter == category.key, - widget.isOnTop, - ) ?? - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: CategorySelectorButton( - isOnTop: widget.isOnTop, - category: category, - selected: widget.filter == category.key, - onTap: () => widget.onTapCategory(category.key), - options: widget.options, - ), - ), - ], - SizedBox( - width: widget.options.categoriesOptions - .categorySelectorHorizontalPadding ?? - max(widget.options.paddings.mainPadding.right - 4, 0), - ), - ], - ), - ), - ); -} diff --git a/packages/flutter_timeline_view/lib/src/widgets/category_selector_button.dart b/packages/flutter_timeline_view/lib/src/widgets/category_selector_button.dart deleted file mode 100644 index bfdf33b..0000000 --- a/packages/flutter_timeline_view/lib/src/widgets/category_selector_button.dart +++ /dev/null @@ -1,143 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; -import 'package:flutter_timeline_view/flutter_timeline_view.dart'; - -class CategorySelectorButton extends StatelessWidget { - const CategorySelectorButton({ - required this.category, - required this.selected, - required this.onTap, - required this.options, - required this.isOnTop, - super.key, - }); - - final TimelineCategory category; - final bool selected; - final VoidCallback onTap; - final TimelineOptions options; - final bool isOnTop; - - @override - Widget build(BuildContext context) { - var theme = Theme.of(context); - var size = MediaQuery.of(context).size; - - return AnimatedContainer( - duration: const Duration(milliseconds: 100), - height: isOnTop ? 140 : 40, - child: TextButton( - onPressed: onTap, - style: ButtonStyle( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric( - vertical: 5, - horizontal: 12, - ), - ), - fixedSize: WidgetStatePropertyAll(Size(140, isOnTop ? 140 : 20)), - backgroundColor: WidgetStatePropertyAll( - selected - ? theme.colorScheme.primary - : options.theme.categorySelectionButtonBackgroundColor ?? - Colors.transparent, - ), - shape: WidgetStatePropertyAll( - RoundedRectangleBorder( - borderRadius: const BorderRadius.all( - Radius.circular(8), - ), - side: BorderSide( - color: options.theme.categorySelectionButtonBorderColor ?? - theme.colorScheme.primary, - width: 2, - ), - ), - ), - ), - child: isOnTop - ? SizedBox( - width: size.width, - child: Stack( - children: [ - Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _CategoryButtonText( - category: category, - options: options, - theme: theme, - selected: selected, - ), - ], - ), - Center(child: category.icon), - ], - ), - ) - : Row( - children: [ - Flexible( - child: Row( - children: [ - if (category.icon != null) ...[ - category.icon!, - SizedBox( - width: - options.paddings.categoryButtonTextPadding ?? 8, - ), - ], - Expanded( - child: _CategoryButtonText( - category: category, - options: options, - theme: theme, - selected: selected, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _CategoryButtonText extends StatelessWidget { - const _CategoryButtonText({ - required this.category, - required this.options, - required this.theme, - required this.selected, - this.overflow, - }); - - final TimelineCategory category; - final TimelineOptions options; - final ThemeData theme; - final bool selected; - final TextOverflow? overflow; - - @override - Widget build(BuildContext context) => Text( - category.title, - style: (options.theme.textStyles.categoryTitleStyle ?? - (selected - ? theme.textTheme.titleMedium - : theme.textTheme.bodyMedium)) - ?.copyWith( - color: selected - ? options.theme.categorySelectionButtonSelectedTextColor ?? - theme.colorScheme.onPrimary - : options.theme.categorySelectionButtonUnselectedTextColor ?? - theme.colorScheme.onSurface, - ), - textAlign: TextAlign.start, - overflow: overflow, - ); -} diff --git a/packages/flutter_timeline_view/lib/src/widgets/default_filled_button.dart b/packages/flutter_timeline_view/lib/src/widgets/default_filled_button.dart deleted file mode 100644 index 00ac78b..0000000 --- a/packages/flutter_timeline_view/lib/src/widgets/default_filled_button.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter/material.dart'; - -class DefaultFilledButton extends StatelessWidget { - const DefaultFilledButton({ - required this.onPressed, - required this.buttonText, - super.key, - }); - - final Future Function()? onPressed; - final String buttonText; - - @override - Widget build(BuildContext context) { - var theme = Theme.of(context); - return FilledButton( - style: onPressed != null - ? ButtonStyle( - backgroundColor: WidgetStatePropertyAll( - theme.colorScheme.primary, - ), - ) - : null, - onPressed: onPressed, - child: Padding( - padding: const EdgeInsets.all(8), - child: Text( - buttonText, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.displayLarge, - ), - ), - ); - } -} diff --git a/packages/flutter_timeline_view/lib/src/widgets/reaction_bottom.dart b/packages/flutter_timeline_view/lib/src/widgets/reaction_bottom.dart deleted file mode 100644 index a13dfbe..0000000 --- a/packages/flutter_timeline_view/lib/src/widgets/reaction_bottom.dart +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:flutter_timeline_view/src/config/timeline_options.dart'; -import 'package:flutter_timeline_view/src/config/timeline_translations.dart'; - -class ReactionBottom extends StatefulWidget { - const ReactionBottom({ - required this.onReactionSubmit, - required this.messageInputBuilder, - required this.translations, - this.iconColor, - super.key, - }); - - final Future Function(String text) onReactionSubmit; - final TextInputBuilder messageInputBuilder; - final TimelineTranslations translations; - final Color? iconColor; - - @override - State createState() => _ReactionBottomState(); -} - -class _ReactionBottomState extends State { - final TextEditingController _textEditingController = TextEditingController(); - - @override - Widget build(BuildContext context) => Container( - child: widget.messageInputBuilder( - _textEditingController, - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - ), - child: IconButton( - onPressed: () async { - var value = _textEditingController.text; - if (value.isNotEmpty) { - await widget.onReactionSubmit(value); - _textEditingController.clear(); - } - }, - icon: SvgPicture.asset( - 'assets/send.svg', - package: 'flutter_timeline_view', - // ignore: deprecated_member_use - color: widget.iconColor, - ), - ), - ), - widget.translations.writeComment, - ), - ); -} 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 deleted file mode 100644 index 103aac9..0000000 --- a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart +++ /dev/null @@ -1,444 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.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/default_filled_button.dart'; -import 'package:flutter_timeline_view/src/widgets/tappable_image.dart'; - -class TimelinePostWidget extends StatefulWidget { - const TimelinePostWidget({ - required this.userId, - required this.options, - required this.post, - required this.onTap, - required this.onTapLike, - required this.onTapUnlike, - required this.onPostDelete, - required this.service, - required this.allowAllDeletion, - this.onUserTap, - super.key, - }); - - /// The user id of the current user - final String userId; - - /// Allow all posts to be deleted instead of - /// only the posts of the current user - final bool allowAllDeletion; - - final TimelineOptions options; - - final TimelinePost post; - - /// Optional max height of the post - final VoidCallback onTap; - final VoidCallback onTapLike; - final VoidCallback onTapUnlike; - final VoidCallback onPostDelete; - final TimelineService service; - - /// If this is not null, the user can tap on the user avatar or name - final Function(String userId)? onUserTap; - - @override - State createState() => _TimelinePostWidgetState(); -} - -class _TimelinePostWidgetState extends State { - @override - Widget build(BuildContext context) { - var theme = Theme.of(context); - var isLikedByUser = widget.post.likedBy?.contains(widget.userId) ?? false; - - return SizedBox( - height: widget.post.imageUrl != null || widget.post.image != null - ? widget.options.postWidgetHeight - : null, - width: double.infinity, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (widget.post.creator != null) ...[ - InkWell( - onTap: widget.onUserTap != null - ? () => - widget.onUserTap?.call(widget.post.creator!.userId) - : null, - child: Row( - children: [ - if (widget.post.creator!.imageUrl != null) ...[ - widget.options.userAvatarBuilder?.call( - widget.post.creator!, - 28, - ) ?? - CircleAvatar( - radius: 14, - backgroundImage: CachedNetworkImageProvider( - widget.post.creator!.imageUrl!, - ), - ), - ] else ...[ - widget.options.anonymousAvatarBuilder?.call( - widget.post.creator!, - 28, - ) ?? - const CircleAvatar( - radius: 14, - child: Icon( - Icons.person, - ), - ), - ], - const SizedBox(width: 10), - Text( - widget.options.nameBuilder?.call(widget.post.creator) ?? - widget.post.creator?.fullName ?? - widget.options.translations.anonymousUser, - style: widget.options.theme.textStyles - .postCreatorTitleStyle ?? - theme.textTheme.titleSmall!.copyWith( - color: Colors.black, - ), - ), - ], - ), - ), - ], - const Spacer(), - if (widget.allowAllDeletion || - widget.post.creator?.userId == widget.userId) ...[ - PopupMenuButton( - onSelected: (value) async { - if (value == 'delete') { - await showPostDeletionConfirmationDialog( - widget.options, - context, - widget.onPostDelete, - ); - } - }, - itemBuilder: (BuildContext context) => - >[ - PopupMenuItem( - value: 'delete', - child: Row( - children: [ - Text( - widget.options.translations.deletePost, - style: widget - .options.theme.textStyles.deletePostStyle ?? - theme.textTheme.bodyMedium, - ), - 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, - ), - ), - ], - ], - ), - // image of the post - if (widget.post.imageUrl != null || widget.post.image != null) ...[ - const SizedBox(height: 8), - Flexible( - flex: widget.options.postWidgetHeight != null ? 1 : 0, - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: widget.options.doubleTapTolike - ? TappableImage( - likeAndDislikeIcon: - widget.options.likeAndDislikeIconsForDoubleTap, - post: widget.post, - userId: widget.userId, - onLike: ({required bool liked}) async { - var userId = widget.userId; - - late TimelinePost result; - - if (!liked) { - result = await widget.service.postService.likePost( - userId, - widget.post, - ); - } else { - result = - await widget.service.postService.unlikePost( - userId, - widget.post, - ); - } - - return result.likedBy?.contains(userId) ?? false; - }, - ) - : widget.post.imageUrl != null - ? CachedNetworkImage( - width: double.infinity, - imageUrl: widget.post.imageUrl!, - fit: BoxFit.fitWidth, - ) - : Image.memory( - width: double.infinity, - widget.post.image!, - fit: BoxFit.fitWidth, - ), - ), - ), - ], - const SizedBox( - height: 8, - ), - // post information - if (widget.options.iconsWithValues) ...[ - Row( - children: [ - IconButton( - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - onPressed: () async { - var userId = widget.userId; - - if (!isLikedByUser) { - await widget.service.postService.likePost( - userId, - widget.post, - ); - } else { - await widget.service.postService.unlikePost( - userId, - widget.post, - ); - } - }, - icon: widget.options.theme.likeIcon ?? - Icon( - isLikedByUser - ? Icons.favorite_rounded - : Icons.favorite_outline_outlined, - color: widget.options.theme.iconColor, - size: widget.options.iconSize, - ), - ), - const SizedBox( - width: 4, - ), - Text('${widget.post.likes}'), - if (widget.post.reactionEnabled) ...[ - const SizedBox( - width: 8, - ), - IconButton( - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - onPressed: widget.onTap, - icon: widget.options.theme.commentIcon ?? - SvgPicture.asset( - 'assets/Comment.svg', - package: 'flutter_timeline_view', - // ignore: deprecated_member_use - color: widget.options.theme.iconColor, - width: widget.options.iconSize, - height: widget.options.iconSize, - ), - ), - const SizedBox( - width: 4, - ), - Text('${widget.post.reaction}'), - ], - ], - ), - ] else ...[ - Row( - children: [ - IconButton( - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - onPressed: - isLikedByUser ? widget.onTapUnlike : widget.onTapLike, - icon: (isLikedByUser - ? widget.options.theme.likedIcon - : widget.options.theme.likeIcon) ?? - Icon( - isLikedByUser - ? Icons.favorite_rounded - : Icons.favorite_outline, - color: widget.options.theme.iconColor, - size: widget.options.iconSize, - ), - ), - const SizedBox(width: 8), - if (widget.post.reactionEnabled) ...[ - IconButton( - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - onPressed: widget.onTap, - icon: widget.options.theme.commentIcon ?? - SvgPicture.asset( - 'assets/Comment.svg', - package: 'flutter_timeline_view', - // ignore: deprecated_member_use - color: widget.options.theme.iconColor, - width: widget.options.iconSize, - height: widget.options.iconSize, - ), - ), - ], - ], - ), - ], - - const SizedBox( - height: 8, - ), - - if (widget.options.itemInfoBuilder != null) ...[ - widget.options.itemInfoBuilder!( - post: widget.post, - ), - ] else ...[ - _PostLikeCountText( - post: widget.post, - options: widget.options, - ), - Text.rich( - TextSpan( - text: widget.options.nameBuilder?.call(widget.post.creator) ?? - widget.post.creator?.fullName ?? - widget.options.translations.anonymousUser, - style: widget.options.theme.textStyles.listCreatorNameStyle ?? - theme.textTheme.titleSmall!.copyWith( - color: Colors.black, - ), - children: [ - TextSpan( - text: widget.post.title, - style: widget.options.theme.textStyles.listPostTitleStyle ?? - theme.textTheme.bodySmall, - ), - ], - ), - ), - const SizedBox(height: 4), - InkWell( - onTap: widget.onTap, - child: Text( - widget.options.translations.viewPost, - style: widget.options.theme.textStyles.viewPostStyle ?? - theme.textTheme.titleSmall!.copyWith( - color: const Color(0xFF8D8D8D), - ), - ), - ), - ], - if (widget.options.dividerBuilder != null) - widget.options.dividerBuilder!(), - ], - ), - ); - } -} - -class _PostLikeCountText extends StatelessWidget { - const _PostLikeCountText({ - required this.post, - required this.options, - }); - - final TimelineOptions options; - final TimelinePost post; - @override - Widget build(BuildContext context) { - var theme = Theme.of(context); - var likeTranslation = post.likes > 1 - ? options.translations.multipleLikesTitle - : options.translations.oneLikeTitle; - - return Text( - '${post.likes} ' - '$likeTranslation', - style: options.theme.textStyles.listPostLikeTitleAndAmount ?? - theme.textTheme.titleSmall!.copyWith( - color: Colors.black, - ), - ); - } -} - -Future showPostDeletionConfirmationDialog( - TimelineOptions options, - BuildContext context, - Function() onPostDelete, -) async { - var theme = Theme.of(context); - var result = await showDialog( - context: context, - builder: (BuildContext context) => - options.deletionDialogBuilder?.call(context) ?? - AlertDialog( - insetPadding: const EdgeInsets.symmetric( - horizontal: 16, - ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 64, vertical: 24), - titlePadding: const EdgeInsets.only(left: 44, right: 44, top: 32), - title: Text( - options.translations.deleteConfirmationMessage, - style: theme.textTheme.titleMedium, - textAlign: TextAlign.center, - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Expanded( - child: DefaultFilledButton( - onPressed: () async { - Navigator.of(context).pop(true); - }, - buttonText: options.translations.deleteButton, - ), - ), - ], - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(false); - }, - child: Text( - options.translations.deleteCancelButton, - style: theme.textTheme.bodyMedium!.copyWith( - decoration: TextDecoration.underline, - color: theme.textTheme.bodyMedium?.color!.withOpacity(0.5), - ), - ), - ), - ], - ), - ), - ); - - if (result == true) { - onPostDelete(); - } -} diff --git a/packages/flutter_timeline_view/pubspec.yaml b/packages/flutter_timeline_view/pubspec.yaml deleted file mode 100644 index a0b4580..0000000 --- a/packages/flutter_timeline_view/pubspec.yaml +++ /dev/null @@ -1,37 +0,0 @@ -# SPDX-FileCopyrightText: 2023 Iconica -# -# SPDX-License-Identifier: GPL-3.0-or-later - -name: flutter_timeline_view -description: Visual elements of the Flutter Timeline Component -version: 5.1.0 -publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub - -environment: - sdk: ">=3.1.3 <4.0.0" - -dependencies: - flutter: - sdk: flutter - intl: any - cached_network_image: ^3.2.2 - dotted_border: ^2.1.0 - collection: any - flutter_svg: ^2.0.10+1 - flutter_timeline_interface: - hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub - version: ^5.1.0 - flutter_image_picker: - hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub - version: ^4.0.0 - -dev_dependencies: - flutter_lints: ^2.0.0 - flutter_iconica_analysis: - git: - url: https://github.com/Iconica-Development/flutter_iconica_analysis - ref: 6.0.0 - -flutter: - assets: - - assets/ diff --git a/packages/timeline_repository_interface/.gitignore b/packages/timeline_repository_interface/.gitignore new file mode 100644 index 0000000..ac5aa98 --- /dev/null +++ b/packages/timeline_repository_interface/.gitignore @@ -0,0 +1,29 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +build/ diff --git a/packages/timeline_repository_interface/analysis_options.yaml b/packages/timeline_repository_interface/analysis_options.yaml new file mode 100644 index 0000000..2a97d5c --- /dev/null +++ b/packages/timeline_repository_interface/analysis_options.yaml @@ -0,0 +1,7 @@ +include: package:flutter_iconica_analysis/analysis_options.yaml + +analyzer: + exclude: + +linter: + rules: diff --git a/packages/timeline_repository_interface/lib/src/interfaces/category_repository_interface.dart b/packages/timeline_repository_interface/lib/src/interfaces/category_repository_interface.dart new file mode 100644 index 0000000..1a28be5 --- /dev/null +++ b/packages/timeline_repository_interface/lib/src/interfaces/category_repository_interface.dart @@ -0,0 +1,10 @@ +import "package:timeline_repository_interface/src/models/timeline_category.dart"; + +abstract class CategoryRepositoryInterface { + // everything is done with streams + Stream> getCategories(); + Future createCategory(TimelineCategory category); + TimelineCategory? selectCategory(String? categoryId); + TimelineCategory? getSelectedCategory(); + TimelineCategory? getCategory(String? categoryId); +} diff --git a/packages/timeline_repository_interface/lib/src/interfaces/post_repository_interface.dart b/packages/timeline_repository_interface/lib/src/interfaces/post_repository_interface.dart new file mode 100644 index 0000000..69b9367 --- /dev/null +++ b/packages/timeline_repository_interface/lib/src/interfaces/post_repository_interface.dart @@ -0,0 +1,30 @@ +import "dart:typed_data"; + +import "package:timeline_repository_interface/timeline_repository_interface.dart"; + +abstract class PostRepositoryInterface { + Stream> getPosts(String? categoryId); + Future deletePost(String id); + //like post + Future likePost(String postId, String userId); + Future unlikePost(String postId, String userId); + Future likePostReaction( + TimelinePost post, + TimelinePostReaction reaction, + String userId, + ); + Future unlikePostReaction( + TimelinePost post, + TimelinePostReaction reaction, + String userId, + ); + Future createReaction( + TimelinePost post, + TimelinePostReaction reaction, { + Uint8List? image, + }); + + void setCurrentPost(TimelinePost post); + TimelinePost getCurrentPost(); + Future createPost(TimelinePost post); +} diff --git a/packages/timeline_repository_interface/lib/src/interfaces/timeline_user_repository_interface.dart b/packages/timeline_repository_interface/lib/src/interfaces/timeline_user_repository_interface.dart new file mode 100644 index 0000000..76c01d2 --- /dev/null +++ b/packages/timeline_repository_interface/lib/src/interfaces/timeline_user_repository_interface.dart @@ -0,0 +1,7 @@ +import "package:timeline_repository_interface/src/models/timeline_user.dart"; + +abstract class TimelineUserRepositoryInterface { + Future> getAllUsers(); + Future getCurrentUser(); + Future getUser(String userId); +} diff --git a/packages/timeline_repository_interface/lib/src/local/local_category_repository.dart b/packages/timeline_repository_interface/lib/src/local/local_category_repository.dart new file mode 100644 index 0000000..4f69ed8 --- /dev/null +++ b/packages/timeline_repository_interface/lib/src/local/local_category_repository.dart @@ -0,0 +1,37 @@ +import "package:timeline_repository_interface/timeline_repository_interface.dart"; + +class LocalCategoryRepository implements CategoryRepositoryInterface { + final List _categories = [ + const TimelineCategory(key: null, title: "All"), + const TimelineCategory(key: "1", title: "Category"), + const TimelineCategory(key: "2", title: "Category with two lines"), + ]; + + TimelineCategory? _selectedCategory; + + @override + Future createCategory(TimelineCategory category) async{ + _categories.add(category); + } + + @override + Stream> getCategories() => Stream.value(_categories); + + @override + TimelineCategory selectCategory(String? categoryId) { + _selectedCategory = _categories.firstWhere( + (category) => category.key == categoryId, + orElse: () => _categories.first, + ); + return _selectedCategory!; + } + + @override + TimelineCategory? getSelectedCategory() => _selectedCategory; + + @override + TimelineCategory? getCategory(String? categoryId) => _categories.firstWhere( + (category) => category.key == categoryId, + orElse: () => _categories.first, + ); +} diff --git a/packages/timeline_repository_interface/lib/src/local/local_post_repository.dart b/packages/timeline_repository_interface/lib/src/local/local_post_repository.dart new file mode 100644 index 0000000..26c0f16 --- /dev/null +++ b/packages/timeline_repository_interface/lib/src/local/local_post_repository.dart @@ -0,0 +1,187 @@ +import "dart:async"; +import "dart:typed_data"; + +import "package:rxdart/rxdart.dart"; +import "package:timeline_repository_interface/src/interfaces/post_repository_interface.dart"; +import "package:timeline_repository_interface/src/models/timeline_post.dart"; +import "package:timeline_repository_interface/src/models/timeline_post_reaction.dart"; +import "package:timeline_repository_interface/src/models/timeline_user.dart"; + +class LocalPostRepository implements PostRepositoryInterface { + LocalPostRepository(); + + final StreamController> _postsController = + BehaviorSubject>(); + + TimelinePost? _currentPost; + + final jane = const TimelineUser( + userId: "1", + firstName: "Jane", + lastName: "Doe", + imageUrl: "https://via.placeholder.com/150", + ); + + final List _posts = List.generate( + 10, + (index) => TimelinePost( + id: index.toString(), + creatorId: "1", + title: "test title", + content: "lore ipsum, dolor sit amet, consectetur adipiscing elit," + " sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + likes: 50, + reaction: 5, + createdAt: DateTime.now(), + reactionEnabled: true, + category: "2", + reactions: [ + TimelinePostReaction( + id: "2", + postId: index.toString(), + creatorId: "1", + createdAt: DateTime.now(), + reaction: "This is a test reaction", + likedBy: [], + creator: const TimelineUser( + userId: "2", + firstName: "John", + lastName: "Doe", + imageUrl: "https://via.placeholder.com/150", + ), + ), + ], + likedBy: [], + creator: const TimelineUser( + userId: "1", + firstName: "Jane", + lastName: "Doe", + imageUrl: "https://via.placeholder.com/150", + ), + imageUrl: "https://via.placeholder.com/1000", + ), + ); + + @override + Stream> getPosts(String? categoryId) { + if (categoryId == null) { + _postsController.add(_posts); + } else { + _postsController.add( + _posts.where((element) => element.category == categoryId).toList(), + ); + } + return _postsController.stream; + } + + @override + Future deletePost(String id) async { + _posts.removeWhere((element) => element.id == id); + _postsController.add(_posts); + } + + @override + Future likePost(String postId, String userId) async { + var post = _posts.firstWhere((element) => element.id == postId); + var updatedPost = post.copyWith( + likes: post.likes + 1, + likedBy: post.likedBy?..add(userId), + ); + _posts[_posts.indexWhere((element) => element.id == postId)] = updatedPost; + _postsController.add(_posts); + } + + @override + Future unlikePost(String postId, String userId) async { + var post = _posts.firstWhere((element) => element.id == postId); + var updatedPost = post.copyWith( + likes: post.likes - 1, + likedBy: post.likedBy?..remove(userId), + ); + _posts[_posts.indexWhere((element) => element.id == postId)] = updatedPost; + _postsController.add(_posts); + } + + @override + Future likePostReaction( + TimelinePost post, + TimelinePostReaction reaction, + String userId, + ) async { + var updatedPost = post.copyWith( + reaction: post.reaction + 1, + reactions: post.reactions + ?..[post.reactions! + .indexWhere((element) => element.id == reaction.id)] = + reaction.copyWith( + likedBy: reaction.likedBy?..add(userId), + ), + ); + _posts[_posts.indexWhere((element) => element.id == post.id)] = updatedPost; + _postsController.add(_posts); + } + + @override + Future unlikePostReaction( + TimelinePost post, + TimelinePostReaction reaction, + String userId, + ) async { + var updatedPost = post.copyWith( + reaction: post.reaction - 1, + reactions: post.reactions + ?..[post.reactions! + .indexWhere((element) => element.id == reaction.id)] = + reaction.copyWith( + likedBy: reaction.likedBy?..remove(userId), + ), + ); + _posts[_posts.indexWhere((element) => element.id == post.id)] = updatedPost; + _postsController.add(_posts); + } + + @override + Future createReaction( + TimelinePost post, + TimelinePostReaction reaction, { + Uint8List? image, + }) async { + var reactionId = DateTime.now().millisecondsSinceEpoch.toString(); + var updatedReaction = reaction.copyWith( + id: reactionId, + creator: const TimelineUser( + userId: "2", + firstName: "John", + lastName: "Doe", + imageUrl: "https://via.placeholder.com/150", + ), + ); + + var updatedPost = post.copyWith( + reaction: post.reaction + 1, + reactions: post.reactions?..add(updatedReaction), + ); + _posts[_posts.indexWhere((element) => element.id == post.id)] = updatedPost; + _postsController.add(_posts); + } + + @override + Future setCurrentPost(TimelinePost post) async { + _currentPost = post; + } + + @override + TimelinePost getCurrentPost() => _currentPost!; + + @override + Future createPost(TimelinePost post) async { + var postId = DateTime.now().millisecondsSinceEpoch.toString(); + var updatedPost = post.copyWith( + id: postId, + creator: jane, + createdAt: DateTime.now(), + ); + _posts.add(updatedPost); + _postsController.add(_posts); + } +} diff --git a/packages/timeline_repository_interface/lib/src/local/local_timeline_user_repository.dart b/packages/timeline_repository_interface/lib/src/local/local_timeline_user_repository.dart new file mode 100644 index 0000000..bb22284 --- /dev/null +++ b/packages/timeline_repository_interface/lib/src/local/local_timeline_user_repository.dart @@ -0,0 +1,37 @@ +import "package:timeline_repository_interface/src/interfaces/timeline_user_repository_interface.dart"; +import "package:timeline_repository_interface/src/models/timeline_user.dart"; + +class LocalTimelineUserRepository implements TimelineUserRepositoryInterface { + final List _users = [ + const TimelineUser( + userId: "1", + firstName: "john", + lastName: "doe", + imageUrl: "https://via.placeholder.com/150", + ), + const TimelineUser( + userId: "2", + firstName: "jane", + lastName: "doe", + imageUrl: "https://via.placeholder.com/150", + ), + ]; + + List loadedUsers = []; + + @override + Future> getAllUsers() async { + loadedUsers = _users; + return loadedUsers; + } + + @override + Future getCurrentUser() async => + _users.firstWhere((element) => element.userId == "1"); + + @override + Future getUser(String userId) { + // TODO: implement getUser + throw UnimplementedError(); + } +} diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_category.dart b/packages/timeline_repository_interface/lib/src/models/timeline_category.dart similarity index 61% rename from packages/flutter_timeline_interface/lib/src/model/timeline_category.dart rename to packages/timeline_repository_interface/lib/src/models/timeline_category.dart index 3b9f39c..7f3ecfe 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_category.dart +++ b/packages/timeline_repository_interface/lib/src/models/timeline_category.dart @@ -1,6 +1,3 @@ -import 'package:flutter/material.dart'; - -@immutable class TimelineCategory { const TimelineCategory({ required this.key, @@ -11,22 +8,22 @@ class TimelineCategory { }); TimelineCategory.fromJson(Map json) - : key = json['key'] as String?, - title = json['title'] as String, - icon = json['icon'] as Widget?, - canCreate = json['canCreate'] as bool? ?? true, - canView = json['canView'] as bool? ?? true; + : key = json["key"] as String?, + title = json["title"] as String, + icon = json["icon"] as int?, + canCreate = json["canCreate"] as bool? ?? true, + canView = json["canView"] as bool? ?? true; final String? key; final String title; - final Widget? icon; + final int? icon; final bool canCreate; final bool canView; TimelineCategory copyWith({ String? key, String? title, - Widget? icon, + int? icon, bool? canCreate, bool? canView, }) => @@ -39,10 +36,10 @@ class TimelineCategory { ); Map toJson() => { - 'key': key, - 'title': title, - 'icon': icon, - 'canCreate': canCreate, - 'canView': canView, + "key": key, + "title": title, + "icon": icon, + "canCreate": canCreate, + "canView": canView, }; } diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart b/packages/timeline_repository_interface/lib/src/models/timeline_post.dart similarity index 69% rename from packages/flutter_timeline_interface/lib/src/model/timeline_post.dart rename to packages/timeline_repository_interface/lib/src/models/timeline_post.dart index 4f50c09..00db550 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart +++ b/packages/timeline_repository_interface/lib/src/models/timeline_post.dart @@ -1,15 +1,9 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause +import "dart:typed_data"; -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'; +import "package:timeline_repository_interface/src/models/timeline_post_reaction.dart"; +import "package:timeline_repository_interface/src/models/timeline_user.dart"; /// A post of the timeline. -@immutable class TimelinePost { const TimelinePost({ required this.id, @@ -32,15 +26,15 @@ class TimelinePost { 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 List?) + 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 List?) ?.map( (e) => TimelinePostReaction.fromJson( (e as Map).keys.first, @@ -49,9 +43,9 @@ class TimelinePost { ), ) .toList(), - createdAt: DateTime.parse(json['created_at'] as String), - reactionEnabled: json['reaction_enabled'] as bool, - data: json['data'] ?? {}, + createdAt: DateTime.parse(json["created_at"] as String), + reactionEnabled: json["reaction_enabled"] as bool, + data: json["data"] ?? {}, ); /// The unique identifier of the post. @@ -61,7 +55,7 @@ class TimelinePost { final String creatorId; /// The creator of the post. If null it isn't loaded yet. - final TimelinePosterUserModel? creator; + final TimelineUser? creator; /// The title of the post. final String title; @@ -102,7 +96,7 @@ class TimelinePost { TimelinePost copyWith({ String? id, String? creatorId, - TimelinePosterUserModel? creator, + TimelineUser? creator, String? title, String? category, String? imageUrl, @@ -135,18 +129,18 @@ class TimelinePost { ); Map toJson() => { - 'creator_id': creatorId, - 'title': title, - 'category': category, - 'image_url': imageUrl, - 'content': content, - 'likes': likes, - 'liked_by': likedBy, - 'reaction': reaction, + "creator_id": creatorId, + "title": title, + "category": category, + "image_url": imageUrl, + "content": content, + "likes": likes, + "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() ?? [], - 'created_at': createdAt.toIso8601String(), - 'reaction_enabled': reactionEnabled, - 'data': data, + "reactions": reactions?.map((e) => e.toJson()).toList() ?? [], + "created_at": createdAt.toIso8601String(), + "reaction_enabled": reactionEnabled, + "data": data, }; } diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart b/packages/timeline_repository_interface/lib/src/models/timeline_post_reaction.dart similarity index 65% rename from packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart rename to packages/timeline_repository_interface/lib/src/models/timeline_post_reaction.dart index 4fa4c04..094f8fc 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart +++ b/packages/timeline_repository_interface/lib/src/models/timeline_post_reaction.dart @@ -1,11 +1,5 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause +import "package:timeline_repository_interface/src/models/timeline_user.dart"; -import 'package:flutter/material.dart'; -import 'package:flutter_timeline_interface/src/model/timeline_poster.dart'; - -@immutable class TimelinePostReaction { const TimelinePostReaction({ required this.id, @@ -27,12 +21,12 @@ class TimelinePostReaction { 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), - createdAtString: json['created_at'] as String, - likedBy: (json['liked_by'] as List?)?.cast() ?? [], + 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), + createdAtString: json["created_at"] as String, + likedBy: (json["liked_by"] as List?)?.cast() ?? [], ); /// The unique identifier of the reaction. @@ -45,7 +39,7 @@ class TimelinePostReaction { final String creatorId; /// The creator of the post. If null it isn't loaded yet. - final TimelinePosterUserModel? creator; + final TimelineUser? creator; /// The reaction text if the creator sent one final String? reaction; @@ -65,7 +59,7 @@ class TimelinePostReaction { String? id, String? postId, String? creatorId, - TimelinePosterUserModel? creator, + TimelineUser? creator, String? reaction, String? imageUrl, DateTime? createdAt, @@ -84,21 +78,21 @@ class TimelinePostReaction { Map toJson() => { id: { - 'creator_id': creatorId, - 'reaction': reaction, - 'image_url': imageUrl, - 'created_at': createdAt.toIso8601String(), - 'liked_by': likedBy, + "creator_id": creatorId, + "reaction": reaction, + "image_url": imageUrl, + "created_at": createdAt.toIso8601String(), + "liked_by": likedBy, }, }; Map toJsonWithMicroseconds() => { id: { - 'creator_id': creatorId, - 'reaction': reaction, - 'image_url': imageUrl, - 'created_at': createdAtString, - 'liked_by': likedBy, + "creator_id": creatorId, + "reaction": reaction, + "image_url": imageUrl, + "created_at": createdAtString, + "liked_by": likedBy, }, }; } diff --git a/packages/timeline_repository_interface/lib/src/models/timeline_user.dart b/packages/timeline_repository_interface/lib/src/models/timeline_user.dart new file mode 100644 index 0000000..12a8510 --- /dev/null +++ b/packages/timeline_repository_interface/lib/src/models/timeline_user.dart @@ -0,0 +1,44 @@ +class TimelineUser { + const TimelineUser({ + required this.userId, + this.firstName, + this.lastName, + this.imageUrl, + }); + + factory TimelineUser.fromJson( + Map json, + String userId, + ) => + TimelineUser( + userId: userId, + firstName: json["first_name"] as String?, + lastName: json["last_name"] as String?, + imageUrl: json["image_url"] as String?, + ); + + final String userId; + final String? firstName; + final String? lastName; + final String? imageUrl; + + Map toJson() => { + "first_name": firstName, + "last_name": lastName, + "image_url": imageUrl, + }; + + String? get fullName { + var fullName = ""; + + if (firstName != null && lastName != null) { + fullName += "$firstName $lastName"; + } else if (firstName != null) { + fullName += firstName!; + } else if (lastName != null) { + fullName += lastName!; + } + + return fullName == "" ? null : fullName; + } +} diff --git a/packages/timeline_repository_interface/lib/src/services/timeline_service.dart b/packages/timeline_repository_interface/lib/src/services/timeline_service.dart new file mode 100644 index 0000000..5a04320 --- /dev/null +++ b/packages/timeline_repository_interface/lib/src/services/timeline_service.dart @@ -0,0 +1,74 @@ +import "dart:typed_data"; +import "package:timeline_repository_interface/src/local/local_timeline_user_repository.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.dart"; + +class TimelineService { + TimelineService({ + CategoryRepositoryInterface? categoryRepository, + PostRepositoryInterface? postRepository, + TimelineUserRepositoryInterface? userRepository, + }) : categoryRepository = categoryRepository ?? LocalCategoryRepository(), + postRepository = postRepository ?? LocalPostRepository(), + userRepository = userRepository ?? LocalTimelineUserRepository(); + + final CategoryRepositoryInterface categoryRepository; + final PostRepositoryInterface postRepository; + final TimelineUserRepositoryInterface userRepository; + + Stream> getCategories() => + categoryRepository.getCategories(); + + TimelineCategory? selectCategory(String? categoryId) => + categoryRepository.selectCategory(categoryId); + + TimelineCategory? getSelectedCategory() => + categoryRepository.getSelectedCategory(); + + TimelineCategory? getCategory(String? categoryId) => + categoryRepository.getCategory(categoryId); + + Future createCategory(TimelineCategory category) => + categoryRepository.createCategory(category); + + Stream> getPosts(String categoryId) => + postRepository.getPosts(categoryId); + + Future deletePost(String id) => postRepository.deletePost(id); + + Future likePost(String postId, String userId) => + postRepository.likePost(postId, userId); + + Future unlikePost(String postId, String userId) => + postRepository.unlikePost(postId, userId); + + Future> getUsers() => userRepository.getAllUsers(); + + Future getCurrentUser() => userRepository.getCurrentUser(); + + Future likePostReaction( + TimelinePost post, + TimelinePostReaction reaction, + String userId, + ) => + postRepository.likePostReaction(post, reaction, userId); + + Future unlikePostReaction( + TimelinePost post, + TimelinePostReaction reaction, + String userId, + ) => + postRepository.unlikePostReaction(post, reaction, userId); + + Future createReaction( + TimelinePost post, + TimelinePostReaction reaction, { + Uint8List? image, + }) => + postRepository.createReaction(post, reaction, image: image); + + void setCurrentPost(TimelinePost post) => postRepository.setCurrentPost(post); + + TimelinePost getCurrentPost() => postRepository.getCurrentPost(); + + Future createPost(TimelinePost post) => postRepository.createPost(post); +} diff --git a/packages/timeline_repository_interface/lib/timeline_repository_interface.dart b/packages/timeline_repository_interface/lib/timeline_repository_interface.dart new file mode 100644 index 0000000..f5cd7ad --- /dev/null +++ b/packages/timeline_repository_interface/lib/timeline_repository_interface.dart @@ -0,0 +1,21 @@ +/// Timeline repository interface +library timeline_repository_interface; + +/// Interfaces +export "src/interfaces/category_repository_interface.dart"; +export "src/interfaces/post_repository_interface.dart"; +export "src/interfaces/timeline_user_repository_interface.dart"; + +/// local repositories +export "src/local/local_category_repository.dart"; +export "src/local/local_post_repository.dart"; +export "src/local/local_timeline_user_repository.dart"; + +/// models +export "src/models/timeline_category.dart"; +export "src/models/timeline_post.dart"; +export "src/models/timeline_post_reaction.dart"; +export "src/models/timeline_user.dart"; + +/// services +export "src/services/timeline_service.dart"; diff --git a/packages/timeline_repository_interface/pubspec.yaml b/packages/timeline_repository_interface/pubspec.yaml new file mode 100644 index 0000000..a60766d --- /dev/null +++ b/packages/timeline_repository_interface/pubspec.yaml @@ -0,0 +1,19 @@ +name: timeline_repository_interface +description: "A new Flutter package project." +version: 6.0.0 +publish_to: none + +environment: + sdk: ^3.5.1 + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + rxdart: any + +dev_dependencies: + flutter_iconica_analysis: + git: + url: https://github.com/Iconica-Development/flutter_iconica_analysis + ref: 7.0.0