diff --git a/.github/workflows/melos-component-ci.yml b/.github/workflows/melos-component-ci.yml index 869bed9..98255ce 100644 --- a/.github/workflows/melos-component-ci.yml +++ b/.github/workflows/melos-component-ci.yml @@ -9,4 +9,6 @@ jobs: call-global-iconica-workflow: uses: Iconica-Development/.github/.github/workflows/melos-ci.yml@master secrets: inherit - permissions: write-all \ No newline at end of file + permissions: write-all + with: + flutter_version: 3.19.6 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 61ef9cf..2d909e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,28 @@ +## 4.0.0 + +- Add a serviceBuilder to the userstory configuration +- Add a listHeaderBuilder for showing a header at the top of the list of posts in the timeline +- Add a getUserId function to retrieve the userId when needed in the userstory configuration +- Fix the timelinecategory selection by removing the categories with key null +- Set an optional max length on the default post title input field +- Add a postCreationFloatingActionButtonColor to the timeline theme to set the color of the floating action button +- Add a post and a category to the postViewOpenPageBuilder function +- Add a refresh functionality to the timeline with a pull to refresh callback to allow additional functionality when refreshing the timeline +- Use the adaptive variants of the material elements in the timeline +- Change the default blue color to the primary color of the Theme.of(context) in the timeline +- Change the TimelineTranslations constructor to require all translations or use the TimelineTranslations.empty constructor if you don't want to specify all translations +- Add a TimelinePaddingOptions class to store the padding options for the timeline +- fix the avatar size to match the new design +- Add the iconbutton for image uploading back to the ReactionBottom +- Fix category key is correctly used for saving timeline posts and category title is shown everywhere +- Fix when clicking on post delete in the post screen of the userstory it will now navigate back to the timeline and delete the post +- Fix like icon being used for both like and unliked posts +- Fix post creator can only like the post once and after it is actually created +- Change the CategorySelectorButton to use more styling options and allow for an icon to be shown +- Fix incorrect timeline reaction name +- Add a dialog for post deletion confirmation +- Add a callback method to determine if a user can delete posts that gets called when needed + ## 3.0.1 - Fixed postOverviewScreen not displaying the creators name. diff --git a/packages/flutter_timeline/example/lib/config/config.dart b/packages/flutter_timeline/example/lib/config/config.dart index 5eede94..017e672 100644 --- a/packages/flutter_timeline/example/lib/config/config.dart +++ b/packages/flutter_timeline/example/lib/config/config.dart @@ -7,13 +7,15 @@ TimelineUserStoryConfiguration getConfig(TimelineService service) { userId: 'test_user', optionsBuilder: (context) => options, enablePostOverviewScreen: false, + canDeleteAllPosts: (_) => true, ); } var options = TimelineOptions( textInputBuilder: null, - padding: const EdgeInsets.all(20).copyWith(top: 28), - allowAllDeletion: true, + paddings: TimelinePaddingOptions( + mainPadding: const EdgeInsets.all(20).copyWith(top: 28), + ), categoriesOptions: CategoriesOptions( categoriesBuilder: (context) => [ const TimelineCategory( diff --git a/packages/flutter_timeline/lib/src/flutter_timeline_gorouter_userstory.dart b/packages/flutter_timeline/lib/src/flutter_timeline_gorouter_userstory.dart index 41e68fc..7587b76 100644 --- a/packages/flutter_timeline/lib/src/flutter_timeline_gorouter_userstory.dart +++ b/packages/flutter_timeline/lib/src/flutter_timeline_gorouter_userstory.dart @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_timeline/flutter_timeline.dart'; import 'package:flutter_timeline/src/go_router.dart'; @@ -28,10 +29,13 @@ List getTimelineStoryRoutes({ GoRoute( path: TimelineUserStoryRoutes.timelineHome, pageBuilder: (context, state) { + var service = config.serviceBuilder?.call(context) ?? config.service; var timelineScreen = TimelineScreen( - userId: config.userId, + userId: config.getUserId?.call(context) ?? config.userId, onUserTap: (user) => config.onUserTap?.call(context, user), - service: config.service, + allowAllDeletion: config.canDeleteAllPosts?.call(context) ?? false, + onRefresh: config.onRefresh, + service: service, options: config.optionsBuilder(context), onPostTap: (post) async => config.onPostTap?.call(context, post) ?? @@ -43,7 +47,11 @@ List getTimelineStoryRoutes({ ); var button = FloatingActionButton( - backgroundColor: const Color(0xff71C6D1), + backgroundColor: config + .optionsBuilder(context) + .theme + .postCreationFloatingActionButtonColor ?? + Theme.of(context).primaryColor, onPressed: () async => context.push( TimelineUserStoryRoutes.timelineCategorySelection, ), @@ -67,9 +75,9 @@ List getTimelineStoryRoutes({ config .optionsBuilder(context) .translations - .timeLineScreenTitle!, - style: const TextStyle( - color: Color(0xff71C6D1), + .timeLineScreenTitle, + style: TextStyle( + color: Theme.of(context).primaryColor, fontSize: 24, fontWeight: FontWeight.w800, ), @@ -87,12 +95,14 @@ List getTimelineStoryRoutes({ var timelineSelectionScreen = TimelineSelectionScreen( options: config.optionsBuilder(context), categories: config - .optionsBuilder(context) - .categoriesOptions - .categoriesBuilder!(context), + .optionsBuilder(context) + .categoriesOptions + .categoriesBuilder + ?.call(context) ?? + [], onCategorySelected: (category) async { await context.push( - TimelineUserStoryRoutes.timelinepostCreation(category.title), + TimelineUserStoryRoutes.timelinepostCreation(category.key ?? ''), ); }, ); @@ -113,9 +123,9 @@ List getTimelineStoryRoutes({ leading: backButton, backgroundColor: const Color(0xff212121), title: Text( - config.optionsBuilder(context).translations.postCreation!, - style: const TextStyle( - color: Color(0xff71C6D1), + config.optionsBuilder(context).translations.postCreation, + style: TextStyle( + color: Theme.of(context).primaryColor, fontSize: 24, fontWeight: FontWeight.w800, ), @@ -129,15 +139,30 @@ List getTimelineStoryRoutes({ GoRoute( path: TimelineUserStoryRoutes.timelineView, pageBuilder: (context, state) { - var post = - config.service.postService.getPost(state.pathParameters['post']!); + var service = config.serviceBuilder?.call(context) ?? config.service; + var post = service.postService.getPost(state.pathParameters['post']!); + var category = config.optionsBuilder + .call(context) + .categoriesOptions + .categoriesBuilder + ?.call(context) + .firstWhereOrNull( + (element) => element.key == post?.category, + ); var timelinePostWidget = TimelinePostScreen( - userId: config.userId, + userId: config.getUserId?.call(context) ?? config.userId, + allowAllDeletion: config.canDeleteAllPosts?.call(context) ?? false, options: config.optionsBuilder(context), - service: config.service, + service: service, post: post!, - onPostDelete: () => config.onPostDelete?.call(context, post), + onPostDelete: () async => + config.onPostDelete?.call(context, post) ?? + () async { + await service.postService.deletePost(post); + if (!context.mounted) return; + context.go(TimelineUserStoryRoutes.timelineHome); + }.call(), onUserTap: (user) => config.onUserTap?.call(context, user), ); @@ -150,16 +175,21 @@ List getTimelineStoryRoutes({ return buildScreenWithoutTransition( context: context, state: state, - child: config.postViewOpenPageBuilder - ?.call(context, timelinePostWidget, backButton) ?? + child: config.postViewOpenPageBuilder?.call( + context, + timelinePostWidget, + backButton, + post, + category, + ) ?? Scaffold( appBar: AppBar( leading: backButton, backgroundColor: const Color(0xff212121), title: Text( - post.category ?? 'Category', - style: const TextStyle( - color: Color(0xff71C6D1), + category?.title ?? post.category ?? 'Category', + style: TextStyle( + color: Theme.of(context).primaryColor, fontSize: 24, fontWeight: FontWeight.w800, ), @@ -174,19 +204,19 @@ List getTimelineStoryRoutes({ path: TimelineUserStoryRoutes.timelinePostCreation, pageBuilder: (context, state) { var category = state.pathParameters['category']; + var service = config.serviceBuilder?.call(context) ?? config.service; var timelinePostCreationWidget = TimelinePostCreationScreen( - userId: config.userId, + userId: config.getUserId?.call(context) ?? config.userId, options: config.optionsBuilder(context), - service: config.service, + service: service, onPostCreated: (post) async { - var newPost = await config.service.postService.createPost(post); - if (context.mounted) { - if (config.afterPostCreationGoHome) { - context.go(TimelineUserStoryRoutes.timelineHome); - } else { - await context - .push(TimelineUserStoryRoutes.timelineViewPath(newPost.id)); - } + var newPost = await service.postService.createPost(post); + if (!context.mounted) return; + if (config.afterPostCreationGoHome) { + context.go(TimelineUserStoryRoutes.timelineHome); + } else { + await context + .push(TimelineUserStoryRoutes.timelineViewPath(newPost.id)); } }, onPostOverview: (post) async => context.push( @@ -216,9 +246,9 @@ List getTimelineStoryRoutes({ backgroundColor: const Color(0xff212121), leading: backButton, title: Text( - config.optionsBuilder(context).translations.postCreation!, - style: const TextStyle( - color: Color(0xff71C6D1), + config.optionsBuilder(context).translations.postCreation, + style: TextStyle( + color: Theme.of(context).primaryColor, fontSize: 24, fontWeight: FontWeight.w800, ), @@ -233,16 +263,15 @@ List getTimelineStoryRoutes({ path: TimelineUserStoryRoutes.timelinePostOverview, pageBuilder: (context, state) { var post = state.extra! as TimelinePost; - + var service = config.serviceBuilder?.call(context) ?? config.service; var timelinePostOverviewWidget = TimelinePostOverviewScreen( options: config.optionsBuilder(context), - service: config.service, + service: service, timelinePost: post, onPostSubmit: (post) async { - await config.service.postService.createPost(post); - if (context.mounted) { - context.go(TimelineUserStoryRoutes.timelineHome); - } + await service.postService.createPost(post); + if (!context.mounted) return; + context.go(TimelineUserStoryRoutes.timelineHome); }, ); var backButton = IconButton( @@ -265,9 +294,9 @@ List getTimelineStoryRoutes({ leading: backButton, backgroundColor: const Color(0xff212121), title: Text( - config.optionsBuilder(context).translations.postOverview!, - style: const TextStyle( - color: Color(0xff71C6D1), + config.optionsBuilder(context).translations.postOverview, + style: TextStyle( + color: Theme.of(context).primaryColor, fontSize: 24, fontWeight: FontWeight.w800, ), 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 da9325a..89db27a 100644 --- a/packages/flutter_timeline/lib/src/flutter_timeline_navigator_userstory.dart +++ b/packages/flutter_timeline/lib/src/flutter_timeline_navigator_userstory.dart @@ -45,7 +45,8 @@ Widget _timelineScreenRoute({ ); var timelineScreen = TimelineScreen( - userId: config.userId, + 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), @@ -60,12 +61,17 @@ Widget _timelineScreenRoute({ ), ), ), + onRefresh: config.onRefresh, filterEnabled: config.filterEnabled, postWidgetBuilder: config.postWidgetBuilder, ); var button = FloatingActionButton( - backgroundColor: const Color(0xff71C6D1), + backgroundColor: config + .optionsBuilder(context) + .theme + .postCreationFloatingActionButtonColor ?? + Theme.of(context).primaryColor, onPressed: () async => Navigator.of(context).push( MaterialPageRoute( builder: (context) => _postCategorySelectionScreen( @@ -87,9 +93,9 @@ Widget _timelineScreenRoute({ appBar: AppBar( backgroundColor: const Color(0xff212121), title: Text( - config.optionsBuilder(context).translations.timeLineScreenTitle!, - style: const TextStyle( - color: Color(0xff71C6D1), + config.optionsBuilder(context).translations.timeLineScreenTitle, + style: TextStyle( + color: Theme.of(context).primaryColor, fontSize: 24, fontWeight: FontWeight.w800, ), @@ -121,16 +127,29 @@ Widget _postDetailScreenRoute({ ); var timelinePostScreen = TimelinePostScreen( - userId: config.userId, + 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) ?? - await config.service.postService.deletePost(post), + () async { + await config.service.postService.deletePost(post); + if (context.mounted) { + Navigator.of(context).pop(); + } + }.call(), onUserTap: (user) => config.onUserTap?.call(context, user), ); + var category = config + .optionsBuilder(context) + .categoriesOptions + .categoriesBuilder + ?.call(context) + .firstWhere((element) => element.key == post.category); + var backButton = IconButton( color: Colors.white, icon: const Icon(Icons.arrow_back_ios), @@ -138,15 +157,15 @@ Widget _postDetailScreenRoute({ ); return config.postViewOpenPageBuilder - ?.call(context, timelinePostScreen, backButton) ?? + ?.call(context, timelinePostScreen, backButton, post, category) ?? Scaffold( appBar: AppBar( leading: backButton, backgroundColor: const Color(0xff212121), title: Text( - post.category ?? 'Category', - style: const TextStyle( - color: Color(0xff71C6D1), + category?.title ?? post.category ?? 'Category', + style: TextStyle( + color: Theme.of(context).primaryColor, fontSize: 24, fontWeight: FontWeight.w800, ), @@ -176,34 +195,33 @@ Widget _postCreationScreenRoute({ ); var timelinePostCreationScreen = TimelinePostCreationScreen( - userId: config.userId, + 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) { - if (config.afterPostCreationGoHome) { - await Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => _timelineScreenRoute( - configuration: config, - context: context, - ), + if (!context.mounted) return; + if (config.afterPostCreationGoHome) { + await Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => _timelineScreenRoute( + configuration: config, + context: context, ), - ); - } else { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => _postOverviewScreenRoute( - configuration: config, - context: context, - post: newPost, - ), + ), + ); + } else { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => _postOverviewScreenRoute( + configuration: config, + context: context, + post: newPost, ), - ); - } + ), + ); } }, onPostOverview: (post) async => Navigator.of(context).push( @@ -216,7 +234,7 @@ Widget _postCreationScreenRoute({ ), ), enablePostOverviewScreen: config.enablePostOverviewScreen, - postCategory: category.title, + postCategory: category.key, ); var backButton = IconButton( @@ -234,9 +252,9 @@ Widget _postCreationScreenRoute({ backgroundColor: const Color(0xff212121), leading: backButton, title: Text( - config.optionsBuilder(context).translations.postCreation!, - style: const TextStyle( - color: Color(0xff71C6D1), + config.optionsBuilder(context).translations.postCreation, + style: TextStyle( + color: Theme.of(context).primaryColor, fontSize: 24, fontWeight: FontWeight.w800, ), @@ -282,7 +300,6 @@ Widget _postOverviewScreenRoute({ ); } }, - isOverviewScreen: true, ); var backButton = IconButton( @@ -302,9 +319,9 @@ Widget _postOverviewScreenRoute({ leading: backButton, backgroundColor: const Color(0xff212121), title: Text( - config.optionsBuilder(context).translations.postOverview!, - style: const TextStyle( - color: Color(0xff71C6D1), + config.optionsBuilder(context).translations.postOverview, + style: TextStyle( + color: Theme.of(context).primaryColor, fontSize: 24, fontWeight: FontWeight.w800, ), @@ -330,9 +347,11 @@ Widget _postCategorySelectionScreen({ var timelineSelectionScreen = TimelineSelectionScreen( options: config.optionsBuilder(context), categories: config - .optionsBuilder(context) - .categoriesOptions - .categoriesBuilder!(context), + .optionsBuilder(context) + .categoriesOptions + .categoriesBuilder + ?.call(context) ?? + [], onCategorySelected: (category) async { await Navigator.of(context).push( MaterialPageRoute( @@ -361,9 +380,9 @@ Widget _postCategorySelectionScreen({ leading: backButton, backgroundColor: const Color(0xff212121), title: Text( - config.optionsBuilder(context).translations.postCreation!, - style: const TextStyle( - color: Color(0xff71C6D1), + config.optionsBuilder(context).translations.postCreation, + style: TextStyle( + color: Theme.of(context).primaryColor, fontSize: 24, fontWeight: FontWeight.w800, ), diff --git a/packages/flutter_timeline/lib/src/models/timeline_configuration.dart b/packages/flutter_timeline/lib/src/models/timeline_configuration.dart index 264be11..884bcff 100644 --- a/packages/flutter_timeline/lib/src/models/timeline_configuration.dart +++ b/packages/flutter_timeline/lib/src/models/timeline_configuration.dart @@ -48,6 +48,9 @@ class TimelineUserStoryConfiguration { const TimelineUserStoryConfiguration({ required this.service, required this.optionsBuilder, + this.getUserId, + this.serviceBuilder, + this.canDeleteAllPosts, this.userId = 'test_user', this.homeOpenPageBuilder, this.postCreationOpenPageBuilder, @@ -55,6 +58,7 @@ class TimelineUserStoryConfiguration { this.postOverviewOpenPageBuilder, this.onPostTap, this.onUserTap, + this.onRefresh, this.onPostDelete, this.filterEnabled = false, this.postWidgetBuilder, @@ -66,9 +70,19 @@ class TimelineUserStoryConfiguration { /// 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; @@ -100,6 +114,8 @@ class TimelineUserStoryConfiguration { BuildContext context, Widget child, IconButton? button, + TimelinePost post, + TimelineCategory? category, )? postViewOpenPageBuilder; /// Open page builder function for the post overview page. This function @@ -117,6 +133,9 @@ class TimelineUserStoryConfiguration { /// 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; diff --git a/packages/flutter_timeline/pubspec.yaml b/packages/flutter_timeline/pubspec.yaml index bfa62ef..46c77ba 100644 --- a/packages/flutter_timeline/pubspec.yaml +++ b/packages/flutter_timeline/pubspec.yaml @@ -3,7 +3,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later name: flutter_timeline description: Visual elements and interface combined into one package -version: 3.0.1 +version: 4.0.0 publish_to: none @@ -15,17 +15,19 @@ dependencies: sdk: flutter go_router: any + collection: any + flutter_timeline_view: git: url: https://github.com/Iconica-Development/flutter_timeline path: packages/flutter_timeline_view - ref: 3.0.1 + ref: 4.0.0 flutter_timeline_interface: git: url: https://github.com/Iconica-Development/flutter_timeline path: packages/flutter_timeline_interface - ref: 3.0.1 + ref: 4.0.0 dev_dependencies: flutter_lints: ^2.0.0 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 index 6acd32a..0c83cb9 100644 --- a/packages/flutter_timeline_firebase/lib/src/config/firebase_timeline_options.dart +++ b/packages/flutter_timeline_firebase/lib/src/config/firebase_timeline_options.dart @@ -9,10 +9,8 @@ class FirebaseTimelineOptions { const FirebaseTimelineOptions({ this.usersCollectionName = 'users', this.timelineCollectionName = 'timeline', - this.allTimelineCategories = const [], }); final String usersCollectionName; final String timelineCollectionName; - final List allTimelineCategories; } diff --git a/packages/flutter_timeline_firebase/lib/src/service/firebase_post_service.dart b/packages/flutter_timeline_firebase/lib/src/service/firebase_post_service.dart index 5956ae0..1c5ec58 100644 --- a/packages/flutter_timeline_firebase/lib/src/service/firebase_post_service.dart +++ b/packages/flutter_timeline_firebase/lib/src/service/firebase_post_service.dart @@ -254,7 +254,7 @@ class FirebaseTimelinePostService // update the post with the new like var updatedPost = post.copyWith( likes: post.likes + 1, - likedBy: post.likedBy?..add(userId), + likedBy: [...post.likedBy ?? [], userId], ); posts = posts .map( diff --git a/packages/flutter_timeline_firebase/pubspec.yaml b/packages/flutter_timeline_firebase/pubspec.yaml index 3b670d5..89ad8a8 100644 --- a/packages/flutter_timeline_firebase/pubspec.yaml +++ b/packages/flutter_timeline_firebase/pubspec.yaml @@ -4,7 +4,7 @@ name: flutter_timeline_firebase description: Implementation of the Flutter Timeline interface for Firebase. -version: 3.0.1 +version: 4.0.0 publish_to: none @@ -23,7 +23,7 @@ dependencies: git: url: https://github.com/Iconica-Development/flutter_timeline path: packages/flutter_timeline_interface - ref: 3.0.1 + ref: 4.0.0 dev_dependencies: flutter_lints: ^2.0.0 diff --git a/packages/flutter_timeline_interface/pubspec.yaml b/packages/flutter_timeline_interface/pubspec.yaml index 3960249..6e0fc04 100644 --- a/packages/flutter_timeline_interface/pubspec.yaml +++ b/packages/flutter_timeline_interface/pubspec.yaml @@ -4,7 +4,7 @@ name: flutter_timeline_interface description: Interface for the service of the Flutter Timeline component -version: 3.0.1 +version: 4.0.0 publish_to: none diff --git a/packages/flutter_timeline_view/lib/flutter_timeline_view.dart b/packages/flutter_timeline_view/lib/flutter_timeline_view.dart index 1a63bf4..89264d4 100644 --- a/packages/flutter_timeline_view/lib/flutter_timeline_view.dart +++ b/packages/flutter_timeline_view/lib/flutter_timeline_view.dart @@ -5,6 +5,7 @@ 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'; diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart index e7c7c9b..e22f2b0 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart @@ -5,6 +5,7 @@ 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'; @@ -12,11 +13,11 @@ import 'package:intl/intl.dart'; class TimelineOptions { const TimelineOptions({ this.theme = const TimelineTheme(), - this.translations = const TimelineTranslations(), + this.translations = const TimelineTranslations.empty(), + this.paddings = const TimelinePaddingOptions(), this.imagePickerConfig = const ImagePickerConfig(), this.imagePickerTheme = const ImagePickerTheme(), this.timelinePostHeight, - this.allowAllDeletion = false, this.sortCommentsAscending = true, this.sortPostsAscending, this.doubleTapTolike = false, @@ -37,12 +38,8 @@ class TimelineOptions { this.userAvatarBuilder, this.anonymousAvatarBuilder, this.nameBuilder, - this.padding = - const EdgeInsets.only(left: 12.0, top: 24.0, right: 12.0, bottom: 12.0), this.iconSize = 26, this.postWidgetHeight, - this.postPadding = - const EdgeInsets.symmetric(vertical: 12.0, horizontal: 12.0), this.filterOptions = const FilterOptions(), this.categoriesOptions = const CategoriesOptions(), this.requireImageForPost = false, @@ -52,6 +49,8 @@ class TimelineOptions { this.maxContentLength, this.categorySelectorButtonBuilder, this.postOverviewButtonBuilder, + this.deletionDialogBuilder, + this.listHeaderBuilder, this.titleInputDecoration, this.contentInputDecoration, }); @@ -71,15 +70,15 @@ class TimelineOptions { /// Whether to sort posts ascending or descending final bool? sortPostsAscending; - /// Allow all posts to be deleted instead of - /// only the posts of the current user - final bool allowAllDeletion; - /// 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; @@ -115,18 +114,12 @@ class TimelineOptions { /// The builder for the divider final Widget Function()? dividerBuilder; - /// The padding between posts in the timeline - final EdgeInsets padding; - /// 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; - /// Padding of each post - final EdgeInsets postPadding; - /// Options for filtering final FilterOptions filterOptions; @@ -156,6 +149,11 @@ class TimelineOptions { 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( @@ -164,6 +162,11 @@ class TimelineOptions { String text, )? 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; @@ -221,8 +224,7 @@ class CategoriesOptions { /// Abilty to override the standard category selector final Widget Function( - String? categoryKey, - String categoryName, + TimelineCategory category, Function() onTap, // ignore: avoid_positional_boolean_parameters bool selected, diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_paddings.dart b/packages/flutter_timeline_view/lib/src/config/timeline_paddings.dart new file mode 100644 index 0000000..33a4ba4 --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/config/timeline_paddings.dart @@ -0,0 +1,25 @@ +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: 12.0, top: 24.0, right: 12.0, bottom: 12.0), + this.postPadding = + const EdgeInsets.symmetric(vertical: 12.0, horizontal: 12.0), + 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_theme.dart b/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart index a2bb2c0..49609cd 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart @@ -15,6 +15,9 @@ class TimelineTheme { this.sendIcon, this.moreIcon, this.deleteIcon, + this.categorySelectionButtonBorderColor, + this.categorySelectionButtonBackgroundColor, + this.postCreationFloatingActionButtonColor, this.textStyles = const TimelineTextStyles(), }); @@ -38,5 +41,16 @@ class TimelineTheme { /// 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 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 index 61f2d27..bdc6985 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart @@ -5,8 +5,54 @@ 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.postAt, + required this.deletePost, + required this.deleteReaction, + required this.deleteConfirmationMessage, + required this.deleteConfirmationTitle, + required this.deleteCancelButton, + required this.deleteButton, + required this.viewPost, + required this.likesTitle, + 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, + }); + + /// 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', @@ -23,6 +69,11 @@ class TimelineTranslations { this.commentsTitleOnPost = 'Comments', this.checkPost = 'Check post 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.likesTitle = 'Likes', @@ -41,45 +92,51 @@ class TimelineTranslations { this.timeLineScreenTitle = 'iconinstagram', }); - final String? noPosts; - final String? noPostsWithFilter; - final String? anonymousUser; + 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? postAt; + 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 postAt; - final String? titleHintText; - final String? contentHintText; + final String titleHintText; + final String contentHintText; - final String? deletePost; - final String? deleteReaction; - final String? viewPost; - final String? likesTitle; - final String? commentsTitle; - final String? commentsTitleOnPost; - final String? writeComment; - final String? firstComment; - final String? postLoadingError; + final String deletePost; + final String deleteConfirmationTitle; + final String deleteConfirmationMessage; + final String deleteButton; + final String deleteCancelButton; - final String? timelineSelectionDescription; + final String deleteReaction; + final String viewPost; + final String likesTitle; + final String commentsTitle; + final String commentsTitleOnPost; + final String writeComment; + final String firstComment; + final String postLoadingError; - final String? searchHint; + final String timelineSelectionDescription; - final String? postOverview; - final String? postIn; - final String? postCreation; + final String searchHint; - final String? yes; - final String? no; - final String? timeLineScreenTitle; + final String postOverview; + final String postIn; + final String postCreation; + final String yes; + final String no; + final String timeLineScreenTitle; + + /// Method to override the default values of the translations TimelineTranslations copyWith({ String? noPosts, String? noPostsWithFilter, @@ -95,6 +152,10 @@ class TimelineTranslations { String? checkPost, String? postAt, String? deletePost, + String? deleteConfirmationTitle, + String? deleteConfirmationMessage, + String? deleteButton, + String? deleteCancelButton, String? deleteReaction, String? viewPost, String? likesTitle, @@ -130,6 +191,12 @@ class TimelineTranslations { checkPost: checkPost ?? this.checkPost, postAt: postAt ?? this.postAt, 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, likesTitle: likesTitle ?? this.likesTitle, 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 index 2623912..44e4d38 100644 --- 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 @@ -104,6 +104,7 @@ class _TimelinePostCreationScreenState category: widget.postCategory, content: contentController.text, likes: 0, + likedBy: const [], reaction: 0, createdAt: DateTime.now(), reactionEnabled: allowComments, @@ -121,14 +122,14 @@ class _TimelinePostCreationScreenState return GestureDetector( onTap: () => FocusScope.of(context).unfocus(), child: Padding( - padding: widget.options.padding, + padding: widget.options.paddings.mainPadding, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - widget.options.translations.title!, + widget.options.translations.title, style: const TextStyle( fontWeight: FontWeight.w800, fontSize: 20, @@ -140,6 +141,7 @@ class _TimelinePostCreationScreenState '', ) ?? TextField( + maxLength: widget.options.maxTitleLength, controller: titleController, decoration: widget.options.contentInputDecoration ?? InputDecoration( @@ -148,7 +150,7 @@ class _TimelinePostCreationScreenState ), const SizedBox(height: 16), Text( - widget.options.translations.content!, + widget.options.translations.content, style: const TextStyle( fontWeight: FontWeight.w800, fontSize: 20, @@ -156,7 +158,7 @@ class _TimelinePostCreationScreenState ), const SizedBox(height: 4), Text( - widget.options.translations.contentDescription!, + widget.options.translations.contentDescription, style: theme.textTheme.bodyMedium, ), // input field for the content @@ -176,14 +178,14 @@ class _TimelinePostCreationScreenState ), // input field for the content Text( - widget.options.translations.uploadImage!, + widget.options.translations.uploadImage, style: const TextStyle( fontWeight: FontWeight.w800, fontSize: 20, ), ), Text( - widget.options.translations.uploadImageDescription!, + widget.options.translations.uploadImageDescription, style: theme.textTheme.bodyMedium, ), // image picker field @@ -270,21 +272,21 @@ class _TimelinePostCreationScreenState const SizedBox(height: 16), Text( - widget.options.translations.commentsTitle!, + widget.options.translations.commentsTitle, style: const TextStyle( fontWeight: FontWeight.w800, fontSize: 20, ), ), Text( - widget.options.translations.allowCommentsDescription!, + widget.options.translations.allowCommentsDescription, style: theme.textTheme.bodyMedium, ), Row( mainAxisAlignment: MainAxisAlignment.start, children: [ Checkbox( - activeColor: const Color(0xff71C6D1), + activeColor: theme.colorScheme.primary, value: allowComments, onChanged: (value) { setState(() { @@ -292,9 +294,9 @@ class _TimelinePostCreationScreenState }); }, ), - Text(widget.options.translations.yes!), + Text(widget.options.translations.yes), Checkbox( - activeColor: const Color(0xff71C6D1), + activeColor: theme.colorScheme.primary, value: !allowComments, onChanged: (value) { setState(() { @@ -302,7 +304,7 @@ class _TimelinePostCreationScreenState }); }, ), - Text(widget.options.translations.no!), + Text(widget.options.translations.no), ], ), const SizedBox(height: 120), @@ -313,13 +315,14 @@ class _TimelinePostCreationScreenState ? widget.options.buttonBuilder!( context, onPostCreated, - widget.options.translations.checkPost!, + widget.options.translations.checkPost, enabled: editingDone, ) : ElevatedButton( - style: const ButtonStyle( - backgroundColor: - MaterialStatePropertyAll(Color(0xff71C6D1)), + style: ButtonStyle( + backgroundColor: MaterialStatePropertyAll( + theme.colorScheme.primary, + ), ), onPressed: editingDone ? () async { @@ -332,8 +335,8 @@ class _TimelinePostCreationScreenState padding: const EdgeInsets.all(12.0), child: Text( widget.enablePostOverviewScreen - ? widget.options.translations.checkPost! - : widget.options.translations.postCreation!, + ? widget.options.translations.checkPost + : widget.options.translations.postCreation, style: const TextStyle( color: Colors.white, fontSize: 20, 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 index 3d7e949..6293dfc 100644 --- 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 @@ -1,5 +1,6 @@ // ignore_for_file: prefer_expression_function_bodies +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'; @@ -10,27 +11,33 @@ class TimelinePostOverviewScreen extends StatelessWidget { required this.options, required this.service, required this.onPostSubmit, - this.isOverviewScreen, super.key, }); final TimelinePost timelinePost; final TimelineOptions options; final TimelineService service; final void Function(TimelinePost) onPostSubmit; - final bool? isOverviewScreen; @override Widget build(BuildContext context) { + // the timelinePost.category is a key so we need to get the category object + var timelineCategoryName = options.categoriesOptions.categoriesBuilder + ?.call(context) + .firstWhereOrNull((element) => element.key == timelinePost.category) + ?.title ?? + timelinePost.category; + var buttonText = '${options.translations.postIn} $timelineCategoryName'; return Column( + mainAxisSize: MainAxisSize.max, children: [ - Flexible( + Expanded( child: TimelinePostScreen( userId: timelinePost.creatorId, options: options, post: timelinePost, onPostDelete: () async {}, service: service, - isOverviewScreen: isOverviewScreen, + isOverviewScreen: true, ), ), options.postOverviewButtonBuilder?.call( @@ -38,30 +45,37 @@ class TimelinePostOverviewScreen extends StatelessWidget { () { onPostSubmit(timelinePost); }, - '${options.translations.postIn} ${timelinePost.category}', + buttonText, ) ?? - Padding( - padding: const EdgeInsets.only(bottom: 30.0), - child: ElevatedButton( - style: const ButtonStyle( - backgroundColor: MaterialStatePropertyAll(Color(0xff71C6D1)), - ), - onPressed: () { - onPostSubmit(timelinePost); - }, - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Text( - '${options.translations.postIn} ${timelinePost.category}', - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w800, - ), + options.buttonBuilder?.call( + context, + () { + onPostSubmit(timelinePost); + }, + buttonText, + enabled: true, + ) ?? + ElevatedButton( + style: ButtonStyle( + backgroundColor: + MaterialStatePropertyAll(Theme.of(context).primaryColor), + ), + onPressed: () { + onPostSubmit(timelinePost); + }, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Text( + buttonText, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w800, ), ), ), ), + SizedBox(height: options.paddings.postOverviewButtonBottomPadding), ], ); } 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 index 6d0c1bd..6b857d0 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart @@ -13,15 +13,17 @@ 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 StatelessWidget { +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, @@ -30,6 +32,10 @@ class TimelinePostScreen extends StatelessWidget { /// 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; @@ -47,49 +53,10 @@ class TimelinePostScreen extends StatelessWidget { final bool? isOverviewScreen; @override - Widget build(BuildContext context) => Scaffold( - body: _TimelinePostScreen( - userId: userId, - service: service, - options: options, - post: post, - onPostDelete: onPostDelete, - onUserTap: onUserTap, - isOverviewScreen: isOverviewScreen, - ), - ); + State createState() => _TimelinePostScreenState(); } -class _TimelinePostScreen extends StatefulWidget { - const _TimelinePostScreen({ - required this.userId, - required this.service, - required this.options, - required this.post, - required this.onPostDelete, - this.onUserTap, - this.isOverviewScreen, - }); - - final String userId; - - final TimelineService service; - - final TimelineOptions options; - - final TimelinePost post; - - final Function(String userId)? onUserTap; - - final VoidCallback onPostDelete; - - final bool? isOverviewScreen; - - @override - State<_TimelinePostScreen> createState() => _TimelinePostScreenState(); -} - -class _TimelinePostScreenState extends State<_TimelinePostScreen> { +class _TimelinePostScreenState extends State { TimelinePost? post; bool isLoading = true; @@ -146,13 +113,13 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> { if (isLoading) { const Center( - child: CircularProgressIndicator(), + child: CircularProgressIndicator.adaptive(), ); } if (this.post == null) { return Center( child: Text( - widget.options.translations.postLoadingError!, + widget.options.translations.postLoadingError, style: widget.options.theme.textStyles.errorTextStyle, ), ); @@ -166,7 +133,7 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> { return Stack( children: [ - RefreshIndicator( + RefreshIndicator.adaptive( onRefresh: () async { updatePost( await widget.service.postService.fetchPostDetails( @@ -178,7 +145,7 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> { }, child: SingleChildScrollView( child: Padding( - padding: widget.options.padding, + padding: widget.options.paddings.mainPadding, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -198,7 +165,7 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> { 28, ) ?? CircleAvatar( - radius: 20, + radius: 14, backgroundImage: CachedNetworkImageProvider( post.creator!.imageUrl!, @@ -210,7 +177,7 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> { 28, ) ?? const CircleAvatar( - radius: 20, + radius: 14, child: Icon( Icons.person, ), @@ -221,7 +188,7 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> { widget.options.nameBuilder ?.call(post.creator) ?? post.creator?.fullName ?? - widget.options.translations.anonymousUser!, + widget.options.translations.anonymousUser, style: widget.options.theme.textStyles .postCreatorTitleStyle ?? theme.textTheme.titleMedium, @@ -230,10 +197,19 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> { ), ), const Spacer(), - if (widget.options.allowAllDeletion || - post.creator?.userId == widget.userId) + if (!(widget.isOverviewScreen ?? false) && + (widget.allowAllDeletion || + post.creator?.userId == widget.userId)) PopupMenuButton( - onSelected: (value) => widget.onPostDelete(), + onSelected: (value) async { + if (value == 'delete') { + await showPostDeletionConfirmationDialog( + widget.options, + context, + widget.onPostDelete, + ); + } + }, itemBuilder: (BuildContext context) => >[ PopupMenuItem( @@ -241,7 +217,7 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> { child: Row( children: [ Text( - widget.options.translations.deletePost!, + widget.options.translations.deletePost, style: widget.options.theme.textStyles .deletePostStyle ?? theme.textTheme.bodyMedium, @@ -344,6 +320,7 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> { ] else ...[ InkWell( onTap: () async { + if (widget.isOverviewScreen ?? false) return; updatePost( await widget.service.postService.likePost( widget.userId, @@ -441,7 +418,7 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> { const SizedBox(height: 20), if (post.reactionEnabled) ...[ Text( - widget.options.translations.commentsTitleOnPost!, + widget.options.translations.commentsTitleOnPost, style: theme.textTheme.titleMedium, ), for (var reaction @@ -450,7 +427,7 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> { GestureDetector( onLongPressStart: (details) async { if (reaction.creatorId == widget.userId || - widget.options.allowAllDeletion) { + widget.allowAllDeletion) { var overlay = Overlay.of(context) .context .findRenderObject()! as RenderBox; @@ -469,7 +446,7 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> { PopupMenuItem( value: 'delete', child: Text( - widget.options.translations.deleteReaction!, + widget.options.translations.deleteReaction, ), ), ], @@ -495,7 +472,7 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> { 28, ) ?? CircleAvatar( - radius: 20, + radius: 14, backgroundImage: CachedNetworkImageProvider( reaction.creator!.imageUrl!, ), @@ -506,7 +483,7 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> { 28, ) ?? const CircleAvatar( - radius: 20, + radius: 14, child: Icon( Icons.person, ), @@ -520,10 +497,10 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> { children: [ Text( widget.options.nameBuilder - ?.call(post.creator) ?? + ?.call(reaction.creator) ?? reaction.creator?.fullName ?? widget.options.translations - .anonymousUser!, + .anonymousUser, style: theme.textTheme.titleSmall, ), Padding( @@ -541,7 +518,7 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> { child: Text.rich( TextSpan( text: widget.options.nameBuilder - ?.call(post.creator) ?? + ?.call(reaction.creator) ?? reaction.creator?.fullName ?? widget .options.translations.anonymousUser, @@ -565,7 +542,7 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> { if (post.reactions?.isEmpty ?? true) ...[ const SizedBox(height: 16), Text( - widget.options.translations.firstComment!, + widget.options.translations.firstComment, ), ], const SizedBox(height: 120), @@ -575,7 +552,7 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> { ), ), ), - if (post.reactionEnabled && !widget.isOverviewScreen!) + if (post.reactionEnabled && !(widget.isOverviewScreen ?? false)) Align( alignment: Alignment.bottomCenter, child: ReactionBottom( diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart index 1d25524..3be9c56 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart @@ -16,16 +16,22 @@ class TimelineScreen extends StatefulWidget { 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; @@ -45,6 +51,9 @@ class TimelineScreen extends StatefulWidget { /// 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; @@ -104,7 +113,7 @@ class _TimelineScreenState extends State { @override Widget build(BuildContext context) { if (isLoading && widget.posts == null) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: CircularProgressIndicator.adaptive()); } // Build the list of posts @@ -143,12 +152,13 @@ class _TimelineScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - height: widget.options.padding.top, + height: widget.options.paddings.mainPadding.top, ), if (widget.filterEnabled) ...[ Padding( - padding: EdgeInsets.symmetric( - horizontal: widget.options.padding.horizontal, + padding: EdgeInsets.only( + left: widget.options.paddings.mainPadding.left, + right: widget.options.paddings.mainPadding.right, ), child: Row( crossAxisAlignment: CrossAxisAlignment.end, @@ -218,74 +228,87 @@ class _TimelineScreenState extends State { height: 12, ), Expanded( - child: SingleChildScrollView( - controller: controller, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ...posts.map( - (post) => Padding( - padding: widget.options.postPadding, - child: widget.postWidgetBuilder?.call(post) ?? - TimelinePostWidget( - service: service, - userId: widget.userId, - options: widget.options, - post: post, - onTap: () async { - if (widget.onPostTap != null) { - widget.onPostTap!.call(post); + 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; - } + 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(); - }, + 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, - ), + ); + }, + 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.padding.bottom, + height: widget.options.paddings.mainPadding.bottom, ), ], ); 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 index e9e16d0..8d23418 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_selection_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_selection_screen.dart @@ -29,7 +29,7 @@ class TimelineSelectionScreen extends StatelessWidget { Padding( padding: EdgeInsets.only(top: size.height * 0.05, bottom: 8), child: Text( - options.translations.timelineSelectionDescription!, + options.translations.timelineSelectionDescription, style: const TextStyle( fontWeight: FontWeight.w800, fontSize: 20, @@ -38,7 +38,7 @@ class TimelineSelectionScreen extends StatelessWidget { ), const SizedBox(height: 4), for (var category in categories.where( - (element) => element.canCreate, + (element) => element.canCreate && element.key != null, )) ...[ options.categorySelectorButtonBuilder?.call( context, @@ -55,9 +55,13 @@ class TimelineSelectionScreen extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), border: Border.all( - color: const Color(0xff71C6D1), + color: + options.theme.categorySelectionButtonBorderColor ?? + Theme.of(context).primaryColor, width: 2, ), + color: + options.theme.categorySelectionButtonBackgroundColor, ), margin: const EdgeInsets.symmetric(vertical: 4), child: Column( diff --git a/packages/flutter_timeline_view/lib/src/widgets/category_selector.dart b/packages/flutter_timeline_view/lib/src/widgets/category_selector.dart index 9953883..c9fe02b 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/category_selector.dart +++ b/packages/flutter_timeline_view/lib/src/widgets/category_selector.dart @@ -37,12 +37,11 @@ class _CategorySelectorState extends State { SizedBox( width: widget.options.categoriesOptions .categorySelectorHorizontalPadding ?? - max(widget.options.padding.horizontal - 20, 0), + max(widget.options.paddings.mainPadding.left - 20, 0), ), for (var category in categories) ...[ widget.options.categoriesOptions.categoryButtonBuilder?.call( - category.key, - category.title, + category, () => widget.onTapCategory(category.key), widget.filter == category.key, widget.isOnTop, @@ -61,7 +60,7 @@ class _CategorySelectorState extends State { SizedBox( width: widget.options.categoriesOptions .categorySelectorHorizontalPadding ?? - max(widget.options.padding.horizontal - 4, 0), + 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 index d05c33d..5ef5611 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/category_selector_button.dart +++ b/packages/flutter_timeline_view/lib/src/widgets/category_selector_button.dart @@ -14,7 +14,7 @@ class CategorySelectorButton extends StatelessWidget { final TimelineCategory category; final bool selected; - final void Function() onTap; + final VoidCallback onTap; final TimelineOptions options; final bool isOnTop; @@ -36,15 +36,19 @@ class CategorySelectorButton extends StatelessWidget { ), fixedSize: MaterialStatePropertyAll(Size(140, isOnTop ? 140 : 20)), backgroundColor: MaterialStatePropertyAll( - selected ? const Color(0xff71C6D1) : Colors.transparent, + selected + ? theme.colorScheme.primary + : options.theme.categorySelectionButtonBackgroundColor ?? + Colors.transparent, ), - shape: const MaterialStatePropertyAll( + shape: MaterialStatePropertyAll( RoundedRectangleBorder( - borderRadius: BorderRadius.all( + borderRadius: const BorderRadius.all( Radius.circular(8), ), side: BorderSide( - color: Color(0xff71C6D1), + color: options.theme.categorySelectionButtonBorderColor ?? + theme.colorScheme.primary, width: 2, ), ), @@ -53,38 +57,55 @@ class CategorySelectorButton extends StatelessWidget { child: isOnTop ? SizedBox( width: MediaQuery.of(context).size.width, - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, + child: Stack( children: [ - Text( - category.title, - style: (options.theme.textStyles.categoryTitleStyle ?? - theme.textTheme.labelLarge) - ?.copyWith( - color: selected - ? theme.colorScheme.onPrimary - : theme.colorScheme.onSurface, - ), - textAlign: TextAlign.start, + Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + category.title, + style: (options.theme.textStyles.categoryTitleStyle ?? + theme.textTheme.labelLarge) + ?.copyWith( + color: selected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + ), + textAlign: TextAlign.start, + ), + ], ), + Center(child: category.icon), ], ), ) : Row( children: [ Flexible( - child: Text( - category.title, - style: (options.theme.textStyles.categoryTitleStyle ?? - theme.textTheme.labelLarge) - ?.copyWith( - color: selected - ? theme.colorScheme.onPrimary - : theme.colorScheme.onSurface, - ), - textAlign: TextAlign.start, - overflow: TextOverflow.ellipsis, + child: Row( + children: [ + category.icon, + SizedBox( + width: + options.paddings.categoryButtonTextPadding ?? 8, + ), + Expanded( + child: Text( + category.title, + style: + (options.theme.textStyles.categoryTitleStyle ?? + theme.textTheme.labelLarge) + ?.copyWith( + color: selected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + ), + textAlign: TextAlign.start, + overflow: TextOverflow.ellipsis, + ), + ), + ], ), ), ], diff --git a/packages/flutter_timeline_view/lib/src/widgets/reaction_bottom.dart b/packages/flutter_timeline_view/lib/src/widgets/reaction_bottom.dart index 8a62f7c..57c9ec1 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/reaction_bottom.dart +++ b/packages/flutter_timeline_view/lib/src/widgets/reaction_bottom.dart @@ -49,6 +49,18 @@ class _ReactionBottomState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ + if (widget.onPressSelectImage != null) ...[ + IconButton( + onPressed: () async { + _textEditingController.clear(); + widget.onPressSelectImage?.call(); + }, + icon: Icon( + Icons.image, + color: widget.iconColor, + ), + ), + ], IconButton( onPressed: () async { var value = _textEditingController.text; @@ -65,7 +77,7 @@ class _ReactionBottomState extends State { ], ), ), - widget.translations.writeComment!, + 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 index abbb8a4..1051d42 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart +++ b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart @@ -18,12 +18,18 @@ class TimelinePostWidget extends StatefulWidget { 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; @@ -72,7 +78,7 @@ class _TimelinePostWidgetState extends State { 28, ) ?? CircleAvatar( - radius: 20, + radius: 14, backgroundImage: CachedNetworkImageProvider( widget.post.creator!.imageUrl!, ), @@ -80,10 +86,10 @@ class _TimelinePostWidgetState extends State { ] else ...[ widget.options.anonymousAvatarBuilder?.call( widget.post.creator!, - 40, + 28, ) ?? const CircleAvatar( - radius: 20, + radius: 14, child: Icon( Icons.person, ), @@ -94,7 +100,7 @@ class _TimelinePostWidgetState extends State { widget.options.nameBuilder ?.call(widget.post.creator) ?? widget.post.creator?.fullName ?? - widget.options.translations.anonymousUser!, + widget.options.translations.anonymousUser, style: widget.options.theme.textStyles .postCreatorTitleStyle ?? theme.textTheme.titleMedium, @@ -103,12 +109,16 @@ class _TimelinePostWidgetState extends State { ), ), const Spacer(), - if (widget.options.allowAllDeletion || + if (widget.allowAllDeletion || widget.post.creator?.userId == widget.userId) PopupMenuButton( - onSelected: (value) { + onSelected: (value) async { if (value == 'delete') { - widget.onPostDelete(); + await showPostDeletionConfirmationDialog( + widget.options, + context, + widget.onPostDelete, + ); } }, itemBuilder: (BuildContext context) => @@ -118,7 +128,7 @@ class _TimelinePostWidgetState extends State { child: Row( children: [ Text( - widget.options.translations.deletePost!, + widget.options.translations.deletePost, style: widget.options.theme.textStyles .deletePostStyle ?? theme.textTheme.bodyMedium, @@ -257,7 +267,7 @@ class _TimelinePostWidgetState extends State { onTap: widget.onTapLike, child: Container( color: Colors.transparent, - child: widget.options.theme.likedIcon ?? + child: widget.options.theme.likeIcon ?? Icon( Icons.favorite_outline, color: widget.options.theme.iconColor, @@ -318,7 +328,7 @@ class _TimelinePostWidgetState extends State { ), const SizedBox(height: 4), Text( - widget.options.translations.viewPost!, + widget.options.translations.viewPost, style: widget.options.theme.textStyles.viewPostStyle ?? theme.textTheme.bodySmall, ), @@ -331,3 +341,39 @@ class _TimelinePostWidgetState extends State { ); } } + +Future showPostDeletionConfirmationDialog( + TimelineOptions options, + BuildContext context, + Function() onPostDelete, +) async { + var result = await showDialog( + context: context, + builder: (BuildContext context) => + options.deletionDialogBuilder?.call(context) ?? + AlertDialog( + title: Text(options.translations.deleteConfirmationTitle), + content: Text(options.translations.deleteConfirmationMessage), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Text(options.translations.deleteCancelButton), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: Text( + options.translations.deleteButton, + ), + ), + ], + ), + ); + + if (result == true) { + onPostDelete(); + } +} diff --git a/packages/flutter_timeline_view/pubspec.yaml b/packages/flutter_timeline_view/pubspec.yaml index 48f76bf..35f0c84 100644 --- a/packages/flutter_timeline_view/pubspec.yaml +++ b/packages/flutter_timeline_view/pubspec.yaml @@ -4,7 +4,7 @@ name: flutter_timeline_view description: Visual elements of the Flutter Timeline Component -version: 3.0.1 +version: 4.0.0 publish_to: none @@ -23,7 +23,7 @@ dependencies: git: url: https://github.com/Iconica-Development/flutter_timeline path: packages/flutter_timeline_interface - ref: 3.0.1 + ref: 4.0.0 flutter_image_picker: git: url: https://github.com/Iconica-Development/flutter_image_picker