diff --git a/CHANGELOG.md b/CHANGELOG.md index c4c4f28..68fbe11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 6.0.0 +* Refactor the timeline package to use the new structure + ## 5.1.0 * Added `routeToPostDetail` to the `TimelineUserStory` to allow for navigation to the post detail screen. diff --git a/FEATURES.md b/FEATURES.md deleted file mode 100644 index 3076797..0000000 --- a/FEATURES.md +++ /dev/null @@ -1 +0,0 @@ -List of Features from this component: diff --git a/README.md b/README.md index 1ecc8bc..e8e33d2 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,6 @@ If you are going to use Firebase as the back-end of the Timeline, you should als ``` In firebase add firestore and storage to your project. -In firestore add a collection named `timeline` and a collection named `users`. -In the `timeline` collection all posts will be stored. In the `users` collection all users will be stored. -In the `users` collection you should add your users data. Add the following code in your `main` function, before the runApp(). And import this package: import 'package:intl/date_symbol_data_local.dart'; 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/flutter_timeline_firebase/CHANGELOG.md b/packages/firebase_timeline_repository/CHANGELOG.md similarity index 100% rename from packages/flutter_timeline_firebase/CHANGELOG.md rename to packages/firebase_timeline_repository/CHANGELOG.md diff --git a/packages/firebase_timeline_repository/CONTRIBUTING.md b/packages/firebase_timeline_repository/CONTRIBUTING.md new file mode 120000 index 0000000..f939e75 --- /dev/null +++ b/packages/firebase_timeline_repository/CONTRIBUTING.md @@ -0,0 +1 @@ +../../CONTRIBUTING.md \ No newline at end of file diff --git a/packages/flutter_timeline_firebase/LICENSE b/packages/firebase_timeline_repository/LICENSE similarity index 100% rename from packages/flutter_timeline_firebase/LICENSE rename to packages/firebase_timeline_repository/LICENSE diff --git a/packages/flutter_timeline_firebase/README.md b/packages/firebase_timeline_repository/README.md similarity index 100% rename from packages/flutter_timeline_firebase/README.md rename to packages/firebase_timeline_repository/README.md 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..f6e675c --- /dev/null +++ b/packages/firebase_timeline_repository/lib/src/firebase_category_repository.dart @@ -0,0 +1,73 @@ +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 { + _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..621020c --- /dev/null +++ b/packages/firebase_timeline_repository/lib/src/firebase_user_repository.dart @@ -0,0 +1,48 @@ +import "package:cloud_firestore/cloud_firestore.dart"; +import "package:firebase_auth/firebase_auth.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.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..9f6847d --- /dev/null +++ b/packages/firebase_timeline_repository/pubspec.yaml @@ -0,0 +1,29 @@ +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 + collection: ^1.18.0 + +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/CONTRIBUTING.md b/packages/flutter_timeline/CONTRIBUTING.md new file mode 120000 index 0000000..f939e75 --- /dev/null +++ b/packages/flutter_timeline/CONTRIBUTING.md @@ -0,0 +1 @@ +../../CONTRIBUTING.md \ No newline at end of file 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 0000000..7463844 Binary files /dev/null and b/packages/flutter_timeline/example/fonts/Avenir-Regular.ttf differ 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 0000000..3fecc77 Binary files /dev/null and b/packages/flutter_timeline/example/fonts/Merriweather-Regular.ttf differ diff --git a/packages/flutter_timeline/example/lib/apps/navigator/app.dart b/packages/flutter_timeline/example/lib/apps/navigator/app.dart deleted file mode 100644 index c309c15..0000000 --- a/packages/flutter_timeline/example/lib/apps/navigator/app.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:example/config/config.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_timeline/flutter_timeline.dart'; - -class NavigatorApp extends StatelessWidget { - const NavigatorApp({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 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..ab74f3d 100644 --- a/packages/flutter_timeline/example/lib/main.dart +++ b/packages/flutter_timeline/example/lib/main.dart @@ -1,9 +1,26 @@ -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: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", + ), + ); + } } 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..832242d 100644 --- a/packages/flutter_timeline/example/pubspec.yaml +++ b/packages/flutter_timeline/example/pubspec.yaml @@ -1,92 +1,28 @@ 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 - + 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..f587c76 100644 --- a/packages/flutter_timeline/lib/flutter_timeline.dart +++ b/packages/flutter_timeline/lib/flutter_timeline.dart @@ -1,12 +1,30 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause +// ignore_for_file: directives_ordering -/// Flutter Timeline library -library flutter_timeline; +/// userstory +library; -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'; +export "src/flutter_timeline_navigator_userstory.dart"; +export "package:timeline_repository_interface/timeline_repository_interface.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..fb68a2f 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,115 @@ -// SPDX-FileCopyrightText: 2024 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause +import "package:flutter/material.dart"; +import "package:flutter_timeline/flutter_timeline.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 widget.options.onTapComments?.call(post, currentUser) ?? + await _push(_timelinePostDetailScreenWidget(post, currentUser)); + }, + onTapCreatePost: () async { + var selectedCategory = timelineService.getSelectedCategory(); + if (widget.options.onTapCreatePost != null) { + await widget.options.onTapCreatePost!(selectedCategory); + } else { + 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 widget.options.onTapPost?.call(post, currentUser) ?? + 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 widget.options.onTapCategory?.call(category) ?? + await _push(_timelineAddpostInformationScreenWidget()); + }, + ); + + Widget _timelineAddpostInformationScreenWidget() => + TimelineAddPostInformationScreen( + timelineService: timelineService, + options: widget.options, + onTapOverview: () async { + await widget.options.onTapOverview?.call() ?? + await _push(_timelinePostOverviewWidget()); + }, + ); + + Widget _timelinePostOverviewWidget() => TimelinePostOverview( + timelineService: timelineService, + options: widget.options, + onTapCreatePost: (post) async { + await widget.options.onTapCreatePostInOverview?.call(post) ?? + await _pushAndRemoveUntil(_timelineScreenWidget()); + }, + ); + + Future _push(Widget screen) async { + await Navigator.of(context) + .push(MaterialPageRoute(builder: (context) => screen)); + } + + Future _pushAndRemoveUntil(Widget screen) async { + await Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (context) => screen), + (route) => route.isFirst, + ); + } } 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..254b2ca --- /dev/null +++ b/packages/flutter_timeline/lib/src/models/timeline_options.dart @@ -0,0 +1,190 @@ +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"; + +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, + this.onTapComments, + this.onTapCreatePost, + this.onTapPost, + this.onTapCategory, + this.onTapOverview, + this.onTapCreatePostInOverview, + }); + + // 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; + final Function(TimelinePost post, TimelineUser user)? onTapComments; + final Function(TimelineCategory? category)? onTapCreatePost; + final Function(TimelinePost post, TimelineUser user)? onTapPost; + final Function(TimelineCategory? category)? onTapCategory; + final Function()? onTapOverview; + final Function(TimelinePost post)? onTapCreatePostInOverview; + + // 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..355cabc --- /dev/null +++ b/packages/flutter_timeline/lib/src/screens/timeline_add_post_information_screen.dart @@ -0,0 +1,206 @@ +import "dart:typed_data"; + +import "package:flutter/material.dart"; +import "package:flutter_timeline/flutter_timeline.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..3f23904 --- /dev/null +++ b/packages/flutter_timeline/lib/src/screens/timeline_choose_category_screen.dart @@ -0,0 +1,198 @@ +import "package:flutter/material.dart"; +import "package:flutter_timeline/flutter_timeline.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, + ), + ); + if (context.mounted) + 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..4866f45 --- /dev/null +++ b/packages/flutter_timeline/lib/src/screens/timeline_post_detail_screen.dart @@ -0,0 +1,108 @@ +import "package:flutter/material.dart"; +import "package:flutter_svg/svg.dart"; +import "package:flutter_timeline/flutter_timeline.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..52c12e1 --- /dev/null +++ b/packages/flutter_timeline/lib/src/screens/timeline_post_overview.dart @@ -0,0 +1,76 @@ +import "package:flutter/material.dart"; +import "package:flutter_timeline/flutter_timeline.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..3aa3c81 --- /dev/null +++ b/packages/flutter_timeline/lib/src/widgets/category_list.dart @@ -0,0 +1,57 @@ +import "package:collection/collection.dart"; +import "package:flutter/material.dart"; +import "package:flutter_timeline/src/widgets/category_widget.dart"; +import "package:timeline_repository_interface/timeline_repository_interface.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..8b3df81 --- /dev/null +++ b/packages/flutter_timeline/lib/src/widgets/comment_section.dart @@ -0,0 +1,117 @@ +import "package:cached_network_image/cached_network_image.dart"; +import "package:flutter/material.dart"; +import "package:flutter_timeline/flutter_timeline.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..925adca --- /dev/null +++ b/packages/flutter_timeline/lib/src/widgets/post_list.dart @@ -0,0 +1,51 @@ +import "package:flutter/material.dart"; +import "package:flutter_timeline/flutter_timeline.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..2e11a66 --- /dev/null +++ b/packages/flutter_timeline/lib/src/widgets/post_more_options_widget.dart @@ -0,0 +1,33 @@ +import "package:flutter/material.dart"; +import "package:flutter_timeline/flutter_timeline.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..f91e117 --- /dev/null +++ b/packages/flutter_timeline/lib/src/widgets/reaction_textfield.dart @@ -0,0 +1,82 @@ +import "package:flutter/material.dart"; +import "package:flutter_timeline/flutter_timeline.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..d5afa16 --- /dev/null +++ b/packages/flutter_timeline/lib/src/widgets/timeline_post.dart @@ -0,0 +1,250 @@ +import "package:cached_network_image/cached_network_image.dart"; +import "package:flutter/material.dart"; +import "package:flutter_timeline/flutter_timeline.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..574a43d 100644 --- a/packages/flutter_timeline/pubspec.yaml +++ b/packages/flutter_timeline/pubspec.yaml @@ -1,31 +1,37 @@ -# 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 + collection: ^1.18.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/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/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/flutter_timeline_interface/CHANGELOG.md b/packages/timeline_repository_interface/CHANGELOG.md similarity index 100% rename from packages/flutter_timeline_interface/CHANGELOG.md rename to packages/timeline_repository_interface/CHANGELOG.md diff --git a/packages/timeline_repository_interface/CONTRIBUTING.md b/packages/timeline_repository_interface/CONTRIBUTING.md new file mode 120000 index 0000000..f939e75 --- /dev/null +++ b/packages/timeline_repository_interface/CONTRIBUTING.md @@ -0,0 +1 @@ +../../CONTRIBUTING.md \ No newline at end of file diff --git a/packages/flutter_timeline_interface/LICENSE b/packages/timeline_repository_interface/LICENSE similarity index 100% rename from packages/flutter_timeline_interface/LICENSE rename to packages/timeline_repository_interface/LICENSE diff --git a/packages/flutter_timeline_interface/README.md b/packages/timeline_repository_interface/README.md similarity index 100% rename from packages/flutter_timeline_interface/README.md rename to packages/timeline_repository_interface/README.md 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..825148f --- /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..e61f8be --- /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>(); + + late 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..3b2853c --- /dev/null +++ b/packages/timeline_repository_interface/lib/src/local/local_timeline_user_repository.dart @@ -0,0 +1,38 @@ +import "package:collection/collection.dart"; +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) { + var user = _users.firstWhereOrNull((element) => element.userId == userId); + return Future.value(user); + } +} 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..5e34d0b --- /dev/null +++ b/packages/timeline_repository_interface/lib/src/services/timeline_service.dart @@ -0,0 +1,73 @@ +import "dart:typed_data"; +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..fc0700b --- /dev/null +++ b/packages/timeline_repository_interface/pubspec.yaml @@ -0,0 +1,20 @@ +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 + collection: ^1.18.0 + +dev_dependencies: + flutter_iconica_analysis: + git: + url: https://github.com/Iconica-Development/flutter_iconica_analysis + ref: 7.0.0