diff --git a/.github/workflows/melos-component-ci.yml b/.github/workflows/melos-component-ci.yml index 98255ce..869bed9 100644 --- a/.github/workflows/melos-component-ci.yml +++ b/.github/workflows/melos-component-ci.yml @@ -9,6 +9,4 @@ jobs: call-global-iconica-workflow: uses: Iconica-Development/.github/.github/workflows/melos-ci.yml@master secrets: inherit - permissions: write-all - with: - flutter_version: 3.19.6 \ No newline at end of file + permissions: write-all \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 95b8c19..c4c4f28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 5.1.0 + +* Added `routeToPostDetail` to the `TimelineUserStory` to allow for navigation to the post detail screen. +* Fixed design issues. + ## 4.1.0 - Migrate to flutter 3.22 which deprecates the background and onBackground properties in the ThemeData and also removes MaterialStatePropertyAll - Add categorySelectionButtonSelectedTextColor and categorySelectionButtonUnselectedTextColor to the timeline theme to allow for the customization of the text color of the category selection buttons diff --git a/README.md b/README.md index 1279960..1ecc8bc 100644 --- a/README.md +++ b/README.md @@ -35,45 +35,7 @@ And import this package: import 'package:intl/date_symbol_data_local.dart'; ``` ## How to use -To use the module within your Flutter-application with predefined `Go_router` routes you should add the following: - -Add go_router as dependency to your project. -Add the following configuration to your flutter_application: - -``` -List getTimelineStoryRoutes() => - getTimelineStoryRoutes( - TimelineUserStoryConfiguration( - service: TimelineService( - postService: LocalTimelinePostService(), - ), - optionsBuilder: (context) { - return const TimelineOptions(); - }, - ), - ); -``` - -Add the `getTimelineStoryRoutes()` to your go_router routes like so: - -``` -final GoRouter _router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (BuildContext context, GoRouterState state) { - return const MyHomePage( - title: "home", - ); - }, - ), - ...getTimelineStoryRoutes(configuration: configuration); - ], -); -``` - -The user story can also be used without go router: -Add the following code somewhere in your widget tree: +To use the userstory add the following code somewhere in your widget tree: ```` timeLineNavigatorUserStory(TimelineUserStoryConfiguration, context), diff --git a/packages/flutter_timeline/CHANGELOG.md b/packages/flutter_timeline/CHANGELOG.md new file mode 120000 index 0000000..699cc9e --- /dev/null +++ b/packages/flutter_timeline/CHANGELOG.md @@ -0,0 +1 @@ +../../CHANGELOG.md \ No newline at end of file diff --git a/packages/flutter_timeline/LICENSE b/packages/flutter_timeline/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/packages/flutter_timeline/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/packages/flutter_timeline/README.md b/packages/flutter_timeline/README.md new file mode 120000 index 0000000..fe84005 --- /dev/null +++ b/packages/flutter_timeline/README.md @@ -0,0 +1 @@ +../../README.md \ No newline at end of file diff --git a/packages/flutter_timeline/example/lib/apps/go_router/app.dart b/packages/flutter_timeline/example/lib/apps/go_router/app.dart deleted file mode 100644 index 082edae..0000000 --- a/packages/flutter_timeline/example/lib/apps/go_router/app.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:example/config/config.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_timeline/flutter_timeline.dart'; -import 'package:go_router/go_router.dart'; - -List getTimelineRoutes() => getTimelineStoryRoutes( - configuration: getConfig(TimelineService( - postService: LocalTimelinePostService(), - )), - ); - -final _router = GoRouter( - initialLocation: '/timeline', - routes: [ - ...getTimelineRoutes(), - ], -); - -class GoRouterApp extends StatelessWidget { - const GoRouterApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp.router( - routerConfig: _router, - title: 'Flutter Timeline', - theme: ThemeData( - textTheme: const TextTheme( - titleLarge: TextStyle( - color: Color(0xffb71c6d), fontFamily: 'Playfair Display')), - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFFB8E2E8), - primary: const Color(0xffb71c6d), - ).copyWith( - surface: const Color(0XFFFAF9F6), - ), - useMaterial3: true, - ), - ); - } -} diff --git a/packages/flutter_timeline/example/lib/config/config.dart b/packages/flutter_timeline/example/lib/config/config.dart index 017e672..3e82841 100644 --- a/packages/flutter_timeline/example/lib/config/config.dart +++ b/packages/flutter_timeline/example/lib/config/config.dart @@ -16,25 +16,6 @@ var options = TimelineOptions( paddings: TimelinePaddingOptions( mainPadding: const EdgeInsets.all(20).copyWith(top: 28), ), - categoriesOptions: CategoriesOptions( - categoriesBuilder: (context) => [ - const TimelineCategory( - key: null, - title: 'All', - icon: SizedBox.shrink(), - ), - const TimelineCategory( - key: 'category1', - title: 'Category 1', - icon: SizedBox.shrink(), - ), - const TimelineCategory( - key: 'category2', - title: 'Category 2', - icon: SizedBox.shrink(), - ), - ], - ), ); void navigateToOverview( diff --git a/packages/flutter_timeline/example/lib/main.dart b/packages/flutter_timeline/example/lib/main.dart index 0d097aa..8599ddf 100644 --- a/packages/flutter_timeline/example/lib/main.dart +++ b/packages/flutter_timeline/example/lib/main.dart @@ -1,15 +1,9 @@ -// import 'package:example/apps/go_router/app.dart'; -// import 'package:example/apps/navigator/app.dart'; -import 'package:example/apps/go_router/app.dart'; +import 'package:example/apps/navigator/app.dart'; import 'package:flutter/material.dart'; import 'package:intl/date_symbol_data_local.dart'; void main() { initializeDateFormatting(); - // Uncomment any, but only one, of these lines to run the example with specific navigation. - - // runApp(const WidgetApp()); - // runApp(const NavigatorApp()); - runApp(const GoRouterApp()); + runApp(const NavigatorApp()); } diff --git a/packages/flutter_timeline/example/pubspec.yaml b/packages/flutter_timeline/example/pubspec.yaml index 9008d45..4f0c7fc 100644 --- a/packages/flutter_timeline/example/pubspec.yaml +++ b/packages/flutter_timeline/example/pubspec.yaml @@ -38,7 +38,6 @@ dependencies: flutter_timeline: path: ../ intl: ^0.19.0 - go_router: ^13.0.1 dev_dependencies: flutter_test: diff --git a/packages/flutter_timeline/lib/flutter_timeline.dart b/packages/flutter_timeline/lib/flutter_timeline.dart index f03d1f1..071b8aa 100644 --- a/packages/flutter_timeline/lib/flutter_timeline.dart +++ b/packages/flutter_timeline/lib/flutter_timeline.dart @@ -5,7 +5,6 @@ /// Flutter Timeline library library flutter_timeline; -export 'package:flutter_timeline/src/flutter_timeline_gorouter_userstory.dart'; 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'; diff --git a/packages/flutter_timeline/lib/src/flutter_timeline_gorouter_userstory.dart b/packages/flutter_timeline/lib/src/flutter_timeline_gorouter_userstory.dart deleted file mode 100644 index 7587b76..0000000 --- a/packages/flutter_timeline/lib/src/flutter_timeline_gorouter_userstory.dart +++ /dev/null @@ -1,311 +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_timeline/flutter_timeline.dart'; -import 'package:flutter_timeline/src/go_router.dart'; -import 'package:go_router/go_router.dart'; - -/// Retrieves a list of GoRouter routes for timeline stories. -/// -/// This function retrieves a list of GoRouter routes for displaying timeline -/// stories. It takes an optional [TimelineUserStoryConfiguration] as parameter. -/// If no configuration is provided, default values will be used. -List getTimelineStoryRoutes({ - TimelineUserStoryConfiguration? configuration, -}) { - var config = configuration ?? - TimelineUserStoryConfiguration( - userId: 'test_user', - service: TimelineService( - postService: LocalTimelinePostService(), - ), - optionsBuilder: (context) => const TimelineOptions(), - ); - - return [ - GoRoute( - path: TimelineUserStoryRoutes.timelineHome, - pageBuilder: (context, state) { - var service = config.serviceBuilder?.call(context) ?? config.service; - var timelineScreen = TimelineScreen( - userId: config.getUserId?.call(context) ?? config.userId, - onUserTap: (user) => config.onUserTap?.call(context, user), - allowAllDeletion: config.canDeleteAllPosts?.call(context) ?? false, - onRefresh: config.onRefresh, - service: service, - options: config.optionsBuilder(context), - onPostTap: (post) async => - config.onPostTap?.call(context, post) ?? - await context.push( - TimelineUserStoryRoutes.timelineViewPath(post.id), - ), - filterEnabled: config.filterEnabled, - postWidgetBuilder: config.postWidgetBuilder, - ); - - var button = FloatingActionButton( - backgroundColor: config - .optionsBuilder(context) - .theme - .postCreationFloatingActionButtonColor ?? - Theme.of(context).primaryColor, - onPressed: () async => context.push( - TimelineUserStoryRoutes.timelineCategorySelection, - ), - shape: const CircleBorder(), - child: const Icon( - Icons.add, - color: Colors.white, - size: 30, - ), - ); - - return buildScreenWithoutTransition( - context: context, - state: state, - child: config.homeOpenPageBuilder - ?.call(context, timelineScreen, button) ?? - Scaffold( - appBar: AppBar( - backgroundColor: const Color(0xff212121), - title: Text( - config - .optionsBuilder(context) - .translations - .timeLineScreenTitle, - style: TextStyle( - color: Theme.of(context).primaryColor, - fontSize: 24, - fontWeight: FontWeight.w800, - ), - ), - ), - body: timelineScreen, - floatingActionButton: button, - ), - ); - }, - ), - GoRoute( - path: TimelineUserStoryRoutes.timelineCategorySelection, - pageBuilder: (context, state) { - var timelineSelectionScreen = TimelineSelectionScreen( - options: config.optionsBuilder(context), - categories: config - .optionsBuilder(context) - .categoriesOptions - .categoriesBuilder - ?.call(context) ?? - [], - onCategorySelected: (category) async { - await context.push( - TimelineUserStoryRoutes.timelinepostCreation(category.key ?? ''), - ); - }, - ); - - var backButton = IconButton( - color: Colors.white, - icon: const Icon(Icons.arrow_back_ios), - onPressed: () => context.go(TimelineUserStoryRoutes.timelineHome), - ); - - return buildScreenWithoutTransition( - context: context, - state: state, - child: config.categorySelectionOpenPageBuilder - ?.call(context, timelineSelectionScreen) ?? - Scaffold( - appBar: AppBar( - leading: backButton, - backgroundColor: const Color(0xff212121), - title: Text( - config.optionsBuilder(context).translations.postCreation, - style: TextStyle( - color: Theme.of(context).primaryColor, - fontSize: 24, - fontWeight: FontWeight.w800, - ), - ), - ), - body: timelineSelectionScreen, - ), - ); - }, - ), - GoRoute( - path: TimelineUserStoryRoutes.timelineView, - pageBuilder: (context, state) { - 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.getUserId?.call(context) ?? config.userId, - allowAllDeletion: config.canDeleteAllPosts?.call(context) ?? false, - options: config.optionsBuilder(context), - service: service, - post: 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), - ); - - var backButton = IconButton( - color: Colors.white, - icon: const Icon(Icons.arrow_back_ios), - onPressed: () => context.go(TimelineUserStoryRoutes.timelineHome), - ); - - return buildScreenWithoutTransition( - context: context, - state: state, - child: config.postViewOpenPageBuilder?.call( - context, - timelinePostWidget, - backButton, - post, - category, - ) ?? - Scaffold( - appBar: AppBar( - leading: backButton, - backgroundColor: const Color(0xff212121), - title: Text( - category?.title ?? post.category ?? 'Category', - style: TextStyle( - color: Theme.of(context).primaryColor, - fontSize: 24, - fontWeight: FontWeight.w800, - ), - ), - ), - body: timelinePostWidget, - ), - ); - }, - ), - GoRoute( - path: TimelineUserStoryRoutes.timelinePostCreation, - pageBuilder: (context, state) { - var category = state.pathParameters['category']; - var service = config.serviceBuilder?.call(context) ?? config.service; - var timelinePostCreationWidget = TimelinePostCreationScreen( - userId: config.getUserId?.call(context) ?? config.userId, - options: config.optionsBuilder(context), - service: service, - onPostCreated: (post) async { - 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( - TimelineUserStoryRoutes.timelinePostOverview, - extra: post, - ), - enablePostOverviewScreen: config.enablePostOverviewScreen, - postCategory: category, - ); - - var backButton = IconButton( - icon: const Icon( - Icons.arrow_back_ios, - color: Colors.white, - ), - onPressed: () => - context.go(TimelineUserStoryRoutes.timelineCategorySelection), - ); - - return buildScreenWithoutTransition( - context: context, - state: state, - child: config.postCreationOpenPageBuilder - ?.call(context, timelinePostCreationWidget, backButton) ?? - Scaffold( - appBar: AppBar( - backgroundColor: const Color(0xff212121), - leading: backButton, - title: Text( - config.optionsBuilder(context).translations.postCreation, - style: TextStyle( - color: Theme.of(context).primaryColor, - fontSize: 24, - fontWeight: FontWeight.w800, - ), - ), - ), - body: timelinePostCreationWidget, - ), - ); - }, - ), - GoRoute( - 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: service, - timelinePost: post, - onPostSubmit: (post) async { - await service.postService.createPost(post); - if (!context.mounted) return; - context.go(TimelineUserStoryRoutes.timelineHome); - }, - ); - var backButton = IconButton( - icon: const Icon( - Icons.arrow_back_ios, - color: Colors.white, - ), - onPressed: () async => context.pop(), - ); - - return buildScreenWithoutTransition( - context: context, - state: state, - child: config.postOverviewOpenPageBuilder?.call( - context, - timelinePostOverviewWidget, - ) ?? - Scaffold( - appBar: AppBar( - leading: backButton, - backgroundColor: const Color(0xff212121), - title: Text( - config.optionsBuilder(context).translations.postOverview, - style: TextStyle( - color: Theme.of(context).primaryColor, - fontSize: 24, - fontWeight: FontWeight.w800, - ), - ), - ), - body: timelinePostOverviewWidget, - ), - ); - }, - ), - ]; -} 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 89db27a..aaf36e3 100644 --- a/packages/flutter_timeline/lib/src/flutter_timeline_navigator_userstory.dart +++ b/packages/flutter_timeline/lib/src/flutter_timeline_navigator_userstory.dart @@ -10,11 +10,13 @@ import 'package:flutter_timeline/flutter_timeline.dart'; /// 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; + Widget timeLineNavigatorUserStory({ required BuildContext context, TimelineUserStoryConfiguration? configuration, }) { - var config = configuration ?? + timelineUserStoryConfiguration = configuration ?? TimelineUserStoryConfiguration( userId: 'test_user', service: TimelineService( @@ -23,7 +25,10 @@ Widget timeLineNavigatorUserStory({ optionsBuilder: (context) => const TimelineOptions(), ); - return _timelineScreenRoute(configuration: config, context: context); + return _timelineScreenRoute( + config: timelineUserStoryConfiguration, + context: context, + ); } /// A widget function that creates a timeline screen route. @@ -33,18 +38,11 @@ Widget timeLineNavigatorUserStory({ /// parameters. If no configuration is provided, default values will be used. Widget _timelineScreenRoute({ required BuildContext context, - TimelineUserStoryConfiguration? configuration, + required TimelineUserStoryConfiguration config, + String? initalCategory, }) { - var config = configuration ?? - TimelineUserStoryConfiguration( - userId: 'test_user', - service: TimelineService( - postService: LocalTimelinePostService(), - ), - optionsBuilder: (context) => const TimelineOptions(), - ); - 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), @@ -55,7 +53,7 @@ Widget _timelineScreenRoute({ Navigator.of(context).push( MaterialPageRoute( builder: (context) => _postDetailScreenRoute( - configuration: config, + config: config, context: context, post: post, ), @@ -65,40 +63,50 @@ Widget _timelineScreenRoute({ filterEnabled: config.filterEnabled, postWidgetBuilder: config.postWidgetBuilder, ); - + var theme = Theme.of(context); var button = FloatingActionButton( backgroundColor: config .optionsBuilder(context) .theme .postCreationFloatingActionButtonColor ?? - Theme.of(context).primaryColor, - onPressed: () async => Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => _postCategorySelectionScreen( - configuration: config, - context: context, - ), - ), - ), + 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: 30, + size: 24, ), ); return config.homeOpenPageBuilder?.call(context, timelineScreen, button) ?? Scaffold( appBar: AppBar( - backgroundColor: const Color(0xff212121), title: Text( config.optionsBuilder(context).translations.timeLineScreenTitle, - style: TextStyle( - color: Theme.of(context).primaryColor, - fontSize: 24, - fontWeight: FontWeight.w800, - ), + style: theme.textTheme.headlineLarge, ), ), body: timelineScreen, @@ -115,17 +123,8 @@ Widget _timelineScreenRoute({ Widget _postDetailScreenRoute({ required BuildContext context, required TimelinePost post, - TimelineUserStoryConfiguration? configuration, + required TimelineUserStoryConfiguration config, }) { - var config = configuration ?? - TimelineUserStoryConfiguration( - userId: 'test_user', - service: TimelineService( - postService: LocalTimelinePostService(), - ), - optionsBuilder: (context) => const TimelineOptions(), - ); - var timelinePostScreen = TimelinePostScreen( userId: config.getUserId?.call(context) ?? config.userId, allowAllDeletion: config.canDeleteAllPosts?.call(context) ?? false, @@ -143,11 +142,7 @@ Widget _postDetailScreenRoute({ onUserTap: (user) => config.onUserTap?.call(context, user), ); - var category = config - .optionsBuilder(context) - .categoriesOptions - .categoriesBuilder - ?.call(context) + var category = config.service.postService.categories .firstWhere((element) => element.key == post.category); var backButton = IconButton( @@ -160,10 +155,9 @@ Widget _postDetailScreenRoute({ ?.call(context, timelinePostScreen, backButton, post, category) ?? Scaffold( appBar: AppBar( - leading: backButton, - backgroundColor: const Color(0xff212121), + iconTheme: Theme.of(context).appBarTheme.iconTheme, title: Text( - category?.title ?? post.category ?? 'Category', + category.title.toLowerCase(), style: TextStyle( color: Theme.of(context).primaryColor, fontSize: 24, @@ -183,31 +177,24 @@ Widget _postDetailScreenRoute({ Widget _postCreationScreenRoute({ required BuildContext context, required TimelineCategory category, - TimelineUserStoryConfiguration? configuration, + required TimelineUserStoryConfiguration config, }) { - var config = configuration ?? - TimelineUserStoryConfiguration( - userId: 'test_user', - service: TimelineService( - postService: LocalTimelinePostService(), - ), - optionsBuilder: (context) => const TimelineOptions(), - ); - 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( - configuration: config, + config: config, context: context, + initalCategory: category.title, ), ), ); @@ -216,7 +203,7 @@ Widget _postCreationScreenRoute({ context, MaterialPageRoute( builder: (context) => _postOverviewScreenRoute( - configuration: config, + config: config, context: context, post: newPost, ), @@ -227,7 +214,7 @@ Widget _postCreationScreenRoute({ onPostOverview: (post) async => Navigator.of(context).push( MaterialPageRoute( builder: (context) => _postOverviewScreenRoute( - configuration: config, + config: config, context: context, post: post, ), @@ -249,7 +236,7 @@ Widget _postCreationScreenRoute({ ?.call(context, timelinePostCreationScreen, backButton) ?? Scaffold( appBar: AppBar( - backgroundColor: const Color(0xff212121), + iconTheme: Theme.of(context).appBarTheme.iconTheme, leading: backButton, title: Text( config.optionsBuilder(context).translations.postCreation, @@ -273,28 +260,23 @@ Widget _postCreationScreenRoute({ Widget _postOverviewScreenRoute({ required BuildContext context, required TimelinePost post, - TimelineUserStoryConfiguration? configuration, + required TimelineUserStoryConfiguration config, }) { - var config = configuration ?? - TimelineUserStoryConfiguration( - userId: 'test_user', - service: TimelineService( - postService: LocalTimelinePostService(), - ), - optionsBuilder: (context) => const TimelineOptions(), - ); - var timelinePostOverviewWidget = TimelinePostOverviewScreen( options: config.optionsBuilder(context), service: config.service, timelinePost: post, onPostSubmit: (post) async { - await config.service.postService.createPost(post); + var createdPost = await config.service.postService.createPost(post); + config.onPostCreate?.call(createdPost); if (context.mounted) { await Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( - builder: (context) => - _timelineScreenRoute(configuration: config, context: context), + builder: (context) => _timelineScreenRoute( + config: config, + context: context, + initalCategory: post.category, + ), ), (route) => false, ); @@ -316,10 +298,10 @@ Widget _postOverviewScreenRoute({ ) ?? Scaffold( appBar: AppBar( + iconTheme: Theme.of(context).appBarTheme.iconTheme, leading: backButton, - backgroundColor: const Color(0xff212121), title: Text( - config.optionsBuilder(context).translations.postOverview, + config.optionsBuilder(context).translations.postCreation, style: TextStyle( color: Theme.of(context).primaryColor, fontSize: 24, @@ -333,30 +315,17 @@ Widget _postOverviewScreenRoute({ Widget _postCategorySelectionScreen({ required BuildContext context, - TimelineUserStoryConfiguration? configuration, + required TimelineUserStoryConfiguration config, }) { - var config = configuration ?? - TimelineUserStoryConfiguration( - userId: 'test_user', - service: TimelineService( - postService: LocalTimelinePostService(), - ), - optionsBuilder: (context) => const TimelineOptions(), - ); - var timelineSelectionScreen = TimelineSelectionScreen( + postService: config.service.postService, options: config.optionsBuilder(context), - categories: config - .optionsBuilder(context) - .categoriesOptions - .categoriesBuilder - ?.call(context) ?? - [], + categories: config.service.postService.categories, onCategorySelected: (category) async { await Navigator.of(context).push( MaterialPageRoute( builder: (context) => _postCreationScreenRoute( - configuration: config, + config: config, context: context, category: category, ), @@ -377,8 +346,8 @@ Widget _postCategorySelectionScreen({ ?.call(context, timelineSelectionScreen) ?? Scaffold( appBar: AppBar( + iconTheme: Theme.of(context).appBarTheme.iconTheme, leading: backButton, - backgroundColor: const Color(0xff212121), title: Text( config.optionsBuilder(context).translations.postCreation, style: TextStyle( @@ -391,3 +360,15 @@ Widget _postCategorySelectionScreen({ body: timelineSelectionScreen, ); } + +Future routeToPostDetail(BuildContext context, TimelinePost post) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => _postDetailScreenRoute( + config: timelineUserStoryConfiguration, + context: context, + post: post, + ), + ), + ); +} diff --git a/packages/flutter_timeline/lib/src/go_router.dart b/packages/flutter_timeline/lib/src/go_router.dart deleted file mode 100644 index c9113db..0000000 --- a/packages/flutter_timeline/lib/src/go_router.dart +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -CustomTransitionPage buildScreenWithFadeTransition({ - required BuildContext context, - required GoRouterState state, - required Widget child, -}) => - CustomTransitionPage( - key: state.pageKey, - child: child, - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ); - -CustomTransitionPage buildScreenWithoutTransition({ - required BuildContext context, - required GoRouterState state, - required Widget child, -}) => - CustomTransitionPage( - key: state.pageKey, - child: child, - transitionsBuilder: (context, animation, secondaryAnimation, child) => - child, - ); diff --git a/packages/flutter_timeline/lib/src/models/timeline_configuration.dart b/packages/flutter_timeline/lib/src/models/timeline_configuration.dart index 884bcff..8dcc2a5 100644 --- a/packages/flutter_timeline/lib/src/models/timeline_configuration.dart +++ b/packages/flutter_timeline/lib/src/models/timeline_configuration.dart @@ -65,6 +65,7 @@ class TimelineUserStoryConfiguration { this.afterPostCreationGoHome = false, this.enablePostOverviewScreen = true, this.categorySelectionOpenPageBuilder, + this.onPostCreate, }); /// The ID of the user associated with this user story configuration. @@ -159,4 +160,6 @@ class TimelineUserStoryConfiguration { BuildContext context, Widget child, )? categorySelectionOpenPageBuilder; + + final Function(TimelinePost post)? onPostCreate; } diff --git a/packages/flutter_timeline/pubspec.yaml b/packages/flutter_timeline/pubspec.yaml index cc4238c..a152f39 100644 --- a/packages/flutter_timeline/pubspec.yaml +++ b/packages/flutter_timeline/pubspec.yaml @@ -3,9 +3,8 @@ # SPDX-License-Identifier: GPL-3.0-or-later name: flutter_timeline description: Visual elements and interface combined into one package -version: 4.1.0 - -publish_to: none +version: 5.1.0 +publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub environment: sdk: ">=3.1.3 <4.0.0" @@ -13,21 +12,14 @@ environment: dependencies: flutter: 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: 4.1.0 + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub + version: ^5.1.0 flutter_timeline_interface: - git: - url: https://github.com/Iconica-Development/flutter_timeline - path: packages/flutter_timeline_interface - ref: 4.1.0 + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub + version: ^5.1.0 + collection: any dev_dependencies: flutter_lints: ^2.0.0 diff --git a/packages/flutter_timeline_firebase/CHANGELOG.md b/packages/flutter_timeline_firebase/CHANGELOG.md new file mode 120000 index 0000000..699cc9e --- /dev/null +++ b/packages/flutter_timeline_firebase/CHANGELOG.md @@ -0,0 +1 @@ +../../CHANGELOG.md \ No newline at end of file diff --git a/packages/flutter_timeline_firebase/LICENSE b/packages/flutter_timeline_firebase/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/packages/flutter_timeline_firebase/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/packages/flutter_timeline_firebase/README.md b/packages/flutter_timeline_firebase/README.md new file mode 120000 index 0000000..fe84005 --- /dev/null +++ b/packages/flutter_timeline_firebase/README.md @@ -0,0 +1 @@ +../../README.md \ No newline at end of file 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 0c83cb9..03f32e3 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,8 +9,10 @@ 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/service/firebase_post_service.dart b/packages/flutter_timeline_firebase/lib/src/service/firebase_post_service.dart index 1c5ec58..aa27af4 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 @@ -5,6 +5,7 @@ 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'; @@ -38,6 +39,12 @@ class FirebaseTimelinePostService @override List posts = []; + @override + List categories = []; + + @override + TimelineCategory? selectedCategory; + @override Future createPost(TimelinePost post) async { var postId = const Uuid().v4(); @@ -118,7 +125,6 @@ class FirebaseTimelinePostService @override Future> fetchPosts(String? category) async { - debugPrint('fetching posts from firebase with category: $category'); var snapshot = (category != null) ? await _db .collection(_options.timelineCollectionName) @@ -239,10 +245,20 @@ class FirebaseTimelinePostService } @override - TimelinePost? getPost(String postId) => - (posts.any((element) => element.id == postId)) - ? posts.firstWhere((element) => element.id == postId) - : null; + 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 @@ -358,4 +374,122 @@ class FirebaseTimelinePostService 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/pubspec.yaml b/packages/flutter_timeline_firebase/pubspec.yaml index cd7c5b6..d094194 100644 --- a/packages/flutter_timeline_firebase/pubspec.yaml +++ b/packages/flutter_timeline_firebase/pubspec.yaml @@ -4,12 +4,11 @@ name: flutter_timeline_firebase description: Implementation of the Flutter Timeline interface for Firebase. -version: 4.1.0 - -publish_to: none +version: 5.1.0 +publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub environment: - sdk: '>=3.1.3 <4.0.0' + sdk: ">=3.1.3 <4.0.0" dependencies: flutter: @@ -18,12 +17,10 @@ dependencies: firebase_core: ^2.22.0 firebase_storage: ^11.5.1 uuid: ^4.2.1 - + collection: ^1.18.0 flutter_timeline_interface: - git: - url: https://github.com/Iconica-Development/flutter_timeline - path: packages/flutter_timeline_interface - ref: 4.1.0 + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub + version: ^5.1.0 dev_dependencies: flutter_lints: ^2.0.0 @@ -33,4 +30,3 @@ dev_dependencies: ref: 6.0.0 flutter: - diff --git a/packages/flutter_timeline_interface/CHANGELOG.md b/packages/flutter_timeline_interface/CHANGELOG.md new file mode 120000 index 0000000..699cc9e --- /dev/null +++ b/packages/flutter_timeline_interface/CHANGELOG.md @@ -0,0 +1 @@ +../../CHANGELOG.md \ No newline at end of file diff --git a/packages/flutter_timeline_interface/LICENSE b/packages/flutter_timeline_interface/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/packages/flutter_timeline_interface/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/packages/flutter_timeline_interface/README.md b/packages/flutter_timeline_interface/README.md new file mode 120000 index 0000000..fe84005 --- /dev/null +++ b/packages/flutter_timeline_interface/README.md @@ -0,0 +1 @@ +../../README.md \ No newline at end of file diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_category.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_category.dart index b88d1d8..3b9f39c 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_category.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_category.dart @@ -5,13 +5,44 @@ class TimelineCategory { const TimelineCategory({ required this.key, required this.title, - required this.icon, + this.icon, this.canCreate = true, this.canView = true, }); + + 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; + final String? key; final String title; - final Widget icon; + final Widget? icon; final bool canCreate; final bool canView; + + TimelineCategory copyWith({ + String? key, + String? title, + Widget? icon, + bool? canCreate, + bool? canView, + }) => + TimelineCategory( + key: key ?? this.key, + title: title ?? this.title, + icon: icon ?? this.icon, + canCreate: canCreate ?? this.canCreate, + canView: canView ?? this.canView, + ); + + Map toJson() => { + 'key': key, + 'title': title, + 'icon': icon, + 'canCreate': canCreate, + 'canView': canView, + }; } diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart index c652508..a07f3fd 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart @@ -13,11 +13,28 @@ class TimelinePosterUserModel { 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 = ''; diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart index f880362..4fa4c04 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart @@ -16,6 +16,7 @@ class TimelinePostReaction { this.imageUrl, this.creator, this.createdAtString, + this.likedBy, }); factory TimelinePostReaction.fromJson( @@ -31,6 +32,7 @@ class TimelinePostReaction { 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. @@ -57,6 +59,8 @@ class TimelinePostReaction { /// Reaction creation date as String with microseconds. final String? createdAtString; + final List? likedBy; + TimelinePostReaction copyWith({ String? id, String? postId, @@ -65,6 +69,7 @@ class TimelinePostReaction { String? reaction, String? imageUrl, DateTime? createdAt, + List? likedBy, }) => TimelinePostReaction( id: id ?? this.id, @@ -74,6 +79,7 @@ class TimelinePostReaction { reaction: reaction ?? this.reaction, imageUrl: imageUrl ?? this.imageUrl, createdAt: createdAt ?? this.createdAt, + likedBy: likedBy ?? this.likedBy, ); Map toJson() => { @@ -82,6 +88,7 @@ class TimelinePostReaction { 'reaction': reaction, 'image_url': imageUrl, 'created_at': createdAt.toIso8601String(), + 'liked_by': likedBy, }, }; @@ -91,6 +98,7 @@ class TimelinePostReaction { 'reaction': reaction, 'image_url': imageUrl, 'created_at': createdAtString, + 'liked_by': likedBy, }, }; } 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 index 4933c4e..9a464f3 100644 --- a/packages/flutter_timeline_interface/lib/src/services/timeline_post_service.dart +++ b/packages/flutter_timeline_interface/lib/src/services/timeline_post_service.dart @@ -5,11 +5,12 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; -import 'package:flutter_timeline_interface/src/model/timeline_post.dart'; -import 'package:flutter_timeline_interface/src/model/timeline_reaction.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); @@ -17,7 +18,7 @@ abstract class TimelinePostService with ChangeNotifier { Future> fetchPosts(String? category); Future fetchPost(TimelinePost post); Future> fetchPostsPaginated(String? category, int limit); - TimelinePost? getPost(String postId); + Future getPost(String postId); List getPosts(String? category); Future> refreshPosts(String? category); Future fetchPostDetails(TimelinePost post); @@ -28,4 +29,17 @@ abstract class TimelinePostService with ChangeNotifier { }); 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/pubspec.yaml b/packages/flutter_timeline_interface/pubspec.yaml index 4d56795..d1e82e5 100644 --- a/packages/flutter_timeline_interface/pubspec.yaml +++ b/packages/flutter_timeline_interface/pubspec.yaml @@ -4,9 +4,8 @@ name: flutter_timeline_interface description: Interface for the service of the Flutter Timeline component -version: 4.1.0 - -publish_to: none +version: 5.1.0 +publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub environment: sdk: '>=3.1.3 <4.0.0' diff --git a/packages/flutter_timeline_view/CHANGELOG.md b/packages/flutter_timeline_view/CHANGELOG.md new file mode 120000 index 0000000..699cc9e --- /dev/null +++ b/packages/flutter_timeline_view/CHANGELOG.md @@ -0,0 +1 @@ +../../CHANGELOG.md \ No newline at end of file diff --git a/packages/flutter_timeline_view/LICENSE b/packages/flutter_timeline_view/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/packages/flutter_timeline_view/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/packages/flutter_timeline_view/README.md b/packages/flutter_timeline_view/README.md new file mode 120000 index 0000000..fe84005 --- /dev/null +++ b/packages/flutter_timeline_view/README.md @@ -0,0 +1 @@ +../../README.md \ No newline at end of file diff --git a/packages/flutter_timeline_view/assets/Comment.svg b/packages/flutter_timeline_view/assets/Comment.svg new file mode 100644 index 0000000..35a7950 --- /dev/null +++ b/packages/flutter_timeline_view/assets/Comment.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/flutter_timeline_view/assets/send.svg b/packages/flutter_timeline_view/assets/send.svg new file mode 100644 index 0000000..0293ec9 --- /dev/null +++ b/packages/flutter_timeline_view/assets/send.svg @@ -0,0 +1,3 @@ + + + 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 e22f2b0..49e35f2 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart @@ -16,10 +16,10 @@ class TimelineOptions { this.translations = const TimelineTranslations.empty(), this.paddings = const TimelinePaddingOptions(), this.imagePickerConfig = const ImagePickerConfig(), - this.imagePickerTheme = const ImagePickerTheme(), + this.imagePickerTheme, this.timelinePostHeight, this.sortCommentsAscending = true, - this.sortPostsAscending, + this.sortPostsAscending = false, this.doubleTapTolike = false, this.iconsWithValues = false, this.likeAndDislikeIconsForDoubleTap = const ( @@ -38,7 +38,7 @@ class TimelineOptions { this.userAvatarBuilder, this.anonymousAvatarBuilder, this.nameBuilder, - this.iconSize = 26, + this.iconSize = 24, this.postWidgetHeight, this.filterOptions = const FilterOptions(), this.categoriesOptions = const CategoriesOptions(), @@ -93,7 +93,7 @@ class TimelineOptions { /// ImagePickerTheme can be used to change the UI of the /// Image Picker Widget to change the text/icons to your liking. - final ImagePickerTheme imagePickerTheme; + final ImagePickerTheme? imagePickerTheme; /// ImagePickerConfig can be used to define the /// size and quality for the uploaded image. @@ -160,6 +160,7 @@ class TimelineOptions { BuildContext context, Function() onPressed, String text, + TimelinePost post, )? postOverviewButtonBuilder; /// Optional builder to override the default alertdialog for post deletion @@ -174,53 +175,14 @@ class TimelineOptions { final InputDecoration? contentInputDecoration; } -List _getDefaultCategories(context) => [ - const TimelineCategory( - key: null, - title: 'All', - icon: Padding( - padding: EdgeInsets.only(right: 8.0), - child: Icon( - Icons.apps, - color: Colors.black, - ), - ), - ), - const TimelineCategory( - key: 'Category', - title: 'Category', - icon: Padding( - padding: EdgeInsets.only(right: 8.0), - child: Icon( - Icons.category, - color: Colors.black, - ), - ), - ), - const TimelineCategory( - key: 'Category with two lines', - title: 'Category with two lines', - icon: Padding( - padding: EdgeInsets.only(right: 8.0), - child: Icon( - Icons.category, - color: Colors.black, - ), - ), - ), - ]; - class CategoriesOptions { const CategoriesOptions({ - this.categoriesBuilder = _getDefaultCategories, this.categoryButtonBuilder, this.categorySelectorHorizontalPadding, }); /// List of categories that the user can select. /// If this is null no categories will be shown. - final List Function(BuildContext context)? - categoriesBuilder; /// Abilty to override the standard category selector final Widget Function( @@ -235,17 +197,11 @@ class CategoriesOptions { final double? categorySelectorHorizontalPadding; TimelineCategory? getCategoryByKey( + List categories, BuildContext context, String? key, - ) { - if (categoriesBuilder == null) { - return null; - } - - return categoriesBuilder! - .call(context) - .firstWhereOrNull((category) => category.key == key); - } + ) => + categories.firstWhereOrNull((category) => category.key == key); } class FilterOptions { diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_paddings.dart b/packages/flutter_timeline_view/lib/src/config/timeline_paddings.dart index 33a4ba4..39fc5ac 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_paddings.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_paddings.dart @@ -4,9 +4,9 @@ import 'package:flutter/material.dart'; class TimelinePaddingOptions { const TimelinePaddingOptions({ this.mainPadding = - const EdgeInsets.only(left: 12.0, top: 24.0, right: 12.0, bottom: 12.0), + const EdgeInsets.only(left: 32, top: 20, right: 32, bottom: 40), this.postPadding = - const EdgeInsets.symmetric(vertical: 12.0, horizontal: 12.0), + const EdgeInsets.only(left: 12.0, top: 12, right: 12.0, bottom: 8), this.postOverviewButtonBottomPadding = 30.0, this.categoryButtonTextPadding, }); 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 bdc6985..9b61753 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart @@ -28,7 +28,6 @@ class TimelineTranslations { required this.allowCommentsDescription, required this.commentsTitleOnPost, required this.checkPost, - required this.postAt, required this.deletePost, required this.deleteReaction, required this.deleteConfirmationMessage, @@ -36,7 +35,8 @@ class TimelineTranslations { required this.deleteCancelButton, required this.deleteButton, required this.viewPost, - required this.likesTitle, + required this.oneLikeTitle, + required this.multipleLikesTitle, required this.commentsTitle, required this.firstComment, required this.writeComment, @@ -49,6 +49,14 @@ class TimelineTranslations { 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 @@ -67,7 +75,7 @@ class TimelineTranslations { this.allowCommentsDescription = 'Indicate whether people are allowed to respond', this.commentsTitleOnPost = 'Comments', - this.checkPost = 'Check post overview', + this.checkPost = 'Overview', this.deletePost = 'Delete post', this.deleteConfirmationTitle = 'Delete Post', this.deleteConfirmationMessage = @@ -76,20 +84,28 @@ class TimelineTranslations { this.deleteCancelButton = 'Cancel', this.deleteReaction = 'Delete Reaction', this.viewPost = 'View post', - this.likesTitle = 'Likes', + 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.postAt = 'at', this.postLoadingError = 'Something went wrong while loading the post', this.timelineSelectionDescription = 'Choose a category', this.searchHint = 'Search...', this.postOverview = 'Post Overview', - this.postIn = 'Post in', + 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; @@ -104,10 +120,11 @@ class TimelineTranslations { final String allowComments; final String allowCommentsDescription; final String checkPost; - final String postAt; final String titleHintText; final String contentHintText; + final String titleErrorText; + final String contentErrorText; final String deletePost; final String deleteConfirmationTitle; @@ -117,7 +134,8 @@ class TimelineTranslations { final String deleteReaction; final String viewPost; - final String likesTitle; + final String oneLikeTitle; + final String multipleLikesTitle; final String commentsTitle; final String commentsTitleOnPost; final String writeComment; @@ -132,6 +150,13 @@ class TimelineTranslations { 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; @@ -150,7 +175,6 @@ class TimelineTranslations { String? allowCommentsDescription, String? commentsTitleOnPost, String? checkPost, - String? postAt, String? deletePost, String? deleteConfirmationTitle, String? deleteConfirmationMessage, @@ -158,7 +182,8 @@ class TimelineTranslations { String? deleteCancelButton, String? deleteReaction, String? viewPost, - String? likesTitle, + String? oneLikeTitle, + String? multipleLikesTitle, String? commentsTitle, String? writeComment, String? firstComment, @@ -173,6 +198,14 @@ class TimelineTranslations { 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, @@ -189,7 +222,6 @@ class TimelineTranslations { allowCommentsDescription ?? this.allowCommentsDescription, commentsTitleOnPost: commentsTitleOnPost ?? this.commentsTitleOnPost, checkPost: checkPost ?? this.checkPost, - postAt: postAt ?? this.postAt, deletePost: deletePost ?? this.deletePost, deleteConfirmationTitle: deleteConfirmationTitle ?? this.deleteConfirmationTitle, @@ -199,7 +231,8 @@ class TimelineTranslations { deleteCancelButton: deleteCancelButton ?? this.deleteCancelButton, deleteReaction: deleteReaction ?? this.deleteReaction, viewPost: viewPost ?? this.viewPost, - likesTitle: likesTitle ?? this.likesTitle, + oneLikeTitle: oneLikeTitle ?? this.oneLikeTitle, + multipleLikesTitle: multipleLikesTitle ?? this.multipleLikesTitle, commentsTitle: commentsTitle ?? this.commentsTitle, writeComment: writeComment ?? this.writeComment, firstComment: firstComment ?? this.firstComment, @@ -215,5 +248,16 @@ class TimelineTranslations { 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 index 64b45b9..7d1cf1c 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 @@ -11,6 +11,8 @@ 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({ @@ -51,52 +53,32 @@ class _TimelinePostCreationScreenState TextEditingController titleController = TextEditingController(); TextEditingController contentController = TextEditingController(); Uint8List? image; - bool editingDone = false; bool allowComments = false; + bool titleIsValid = false; + bool contentIsValid = false; @override void initState() { + titleController.addListener(_listenForInputs); + contentController.addListener(_listenForInputs); + super.initState(); - titleController.addListener(checkIfEditingDone); - contentController.addListener(checkIfEditingDone); } - @override - void dispose() { - titleController.dispose(); - contentController.dispose(); - super.dispose(); + void _listenForInputs() { + titleIsValid = titleController.text.isNotEmpty; + contentIsValid = contentController.text.isNotEmpty; + setState(() {}); } - void checkIfEditingDone() { - setState(() { - editingDone = - titleController.text.isNotEmpty && contentController.text.isNotEmpty; - if (widget.options.requireImageForPost) { - editingDone = editingDone && image != null; - } - if (widget.options.minTitleLength != null) { - editingDone = editingDone && - titleController.text.length >= widget.options.minTitleLength!; - } - if (widget.options.maxTitleLength != null) { - editingDone = editingDone && - titleController.text.length <= widget.options.maxTitleLength!; - } - if (widget.options.minContentLength != null) { - editingDone = editingDone && - contentController.text.length >= widget.options.minContentLength!; - } - if (widget.options.maxContentLength != null) { - editingDone = editingDone && - contentController.text.length <= widget.options.maxContentLength!; - } - }); - } + 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, @@ -109,6 +91,7 @@ class _TimelinePostCreationScreenState createdAt: DateTime.now(), reactionEnabled: allowComments, image: image, + creator: user, ); if (widget.enablePostOverviewScreen) { @@ -119,234 +102,291 @@ class _TimelinePostCreationScreenState } var theme = Theme.of(context); + return GestureDetector( onTap: () => FocusScope.of(context).unfocus(), - child: Padding( - padding: widget.options.paddings.mainPadding, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.options.translations.title, - style: const TextStyle( - fontWeight: FontWeight.w800, - fontSize: 20, + 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, ), - ), - widget.options.textInputBuilder?.call( - titleController, - null, - '', - ) ?? - TextField( - maxLength: widget.options.maxTitleLength, - controller: titleController, - decoration: widget.options.contentInputDecoration ?? - InputDecoration( - hintText: widget.options.translations.titleHintText, - ), - ), - const SizedBox(height: 16), - Text( - widget.options.translations.content, - style: const TextStyle( - fontWeight: FontWeight.w800, - fontSize: 20, + const SizedBox( + height: 4, ), - ), - const SizedBox(height: 4), - Text( - widget.options.translations.contentDescription, - style: theme.textTheme.bodyMedium, - ), - // input field for the content - TextField( - controller: contentController, - textCapitalization: TextCapitalization.sentences, - expands: false, - maxLines: null, - minLines: null, - decoration: widget.options.contentInputDecoration ?? - InputDecoration( - hintText: widget.options.translations.contentHintText, + 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: 16, - ), - // input field for the content - Text( - widget.options.translations.uploadImage, - style: const TextStyle( - fontWeight: FontWeight.w800, - fontSize: 20, + const SizedBox(height: 24), + Text( + widget.options.translations.content, + style: theme.textTheme.titleMedium, ), - ), - Text( - widget.options.translations.uploadImageDescription, - style: theme.textTheme.bodyMedium, - ), - // image picker field - const SizedBox( - height: 8, - ), - Stack( - children: [ - GestureDetector( - onTap: () async { - // open a dialog to choose between camera and gallery - var result = await showModalBottomSheet( - context: context, - builder: (context) => Container( - padding: const EdgeInsets.all(8.0), - color: theme.colorScheme.surface, - child: ImagePicker( - imagePickerConfig: widget.options.imagePickerConfig, - imagePickerTheme: widget.options.imagePickerTheme, + 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; - }); - } - checkIfEditingDone(); - }, - 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( + ); + 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, - child: Icon( - Icons.image, - size: 50, + 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; - }); - checkIfEditingDone(); - }, - child: Container( - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), - borderRadius: BorderRadius.circular(8.0), - ), - child: const Icon( - Icons.delete, - color: Colors.white, + // 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: 16), - - Text( - widget.options.translations.commentsTitle, - style: const TextStyle( - fontWeight: FontWeight.w800, - fontSize: 20, ), - ), - Text( - widget.options.translations.allowCommentsDescription, - style: theme.textTheme.bodyMedium, - ), - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Checkbox( - activeColor: theme.colorScheme.primary, - value: allowComments, - onChanged: (value) { - setState(() { - allowComments = true; - }); - }, - ), - Text(widget.options.translations.yes), - Checkbox( - activeColor: theme.colorScheme.primary, - value: !allowComments, - onChanged: (value) { - setState(() { - allowComments = false; - }); - }, - ), - Text(widget.options.translations.no), - ], - ), - const SizedBox(height: 120), - - Align( - alignment: Alignment.bottomCenter, - child: (widget.options.buttonBuilder != null) - ? widget.options.buttonBuilder!( - context, - onPostCreated, - widget.options.translations.checkPost, - enabled: editingDone, - ) - : ElevatedButton( - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll( - theme.colorScheme.primary, + 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, + ), + ), + ], ), ), - onPressed: editingDone - ? () async { - await onPostCreated(); - await widget.service.postService - .fetchPosts(null); - } - : null, - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Text( - widget.enablePostOverviewScreen - ? widget.options.translations.checkPost - : widget.options.translations.postCreation, - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w800, - ), - ), - ), - ), - ), - ], + ), + ), + ], + ), ), ), ), 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 df34fc4..89d3e0a 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,9 +1,7 @@ -// 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'; +import 'package:flutter_timeline_view/src/widgets/default_filled_button.dart'; class TimelinePostOverviewScreen extends StatelessWidget { const TimelinePostOverviewScreen({ @@ -20,13 +18,7 @@ class TimelinePostOverviewScreen extends StatelessWidget { @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'; + var isSubmitted = false; return Column( mainAxisSize: MainAxisSize.max, children: [ @@ -43,39 +35,43 @@ class TimelinePostOverviewScreen extends StatelessWidget { options.postOverviewButtonBuilder?.call( context, () { + if (isSubmitted) return; + isSubmitted = true; onPostSubmit(timelinePost); }, - buttonText, + options.translations.postIn, + timelinePost, ) ?? options.buttonBuilder?.call( context, () { + if (isSubmitted) return; + isSubmitted = true; onPostSubmit(timelinePost); }, - buttonText, + options.translations.postIn, enabled: true, ) ?? - ElevatedButton( - style: ButtonStyle( - backgroundColor: - WidgetStatePropertyAll(Theme.of(context).primaryColor), - ), - onPressed: () { - onPostSubmit(timelinePost); - }, + SafeArea( + bottom: true, child: Padding( - padding: const EdgeInsets.all(12.0), - child: Text( - buttonText, - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w800, - ), + 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, + ), + ), + ], ), ), ), - 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 491b8b8..450f207 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 @@ -3,12 +3,10 @@ // SPDX-License-Identifier: BSD-3-Clause import 'dart:async'; -import 'dart:typed_data'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_html/flutter_html.dart'; -import 'package:flutter_image_picker/flutter_image_picker.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'; @@ -60,20 +58,6 @@ class _TimelinePostScreenState extends State { TimelinePost? post; bool isLoading = true; - late var textInputBuilder = widget.options.textInputBuilder ?? - (controller, suffixIcon, hintText) => TextField( - textCapitalization: TextCapitalization.sentences, - controller: controller, - decoration: InputDecoration( - hintText: hintText, - suffixIcon: suffixIcon, - border: OutlineInputBorder( - borderRadius: - BorderRadius.circular(20.0), // Adjust the value as needed - ), - ), - ); - @override void initState() { super.initState(); @@ -90,8 +74,7 @@ class _TimelinePostScreenState extends State { post = loadedPost; isLoading = false; }); - } on Exception catch (e) { - debugPrint('Error loading post: $e'); + } on Exception catch (_) { setState(() { isLoading = false; }); @@ -108,9 +91,10 @@ class _TimelinePostScreenState extends State { Widget build(BuildContext context) { var theme = Theme.of(context); var dateFormat = widget.options.dateFormat ?? - DateFormat('dd/MM/yyyy', Localizations.localeOf(context).languageCode); - var timeFormat = widget.options.timeFormat ?? DateFormat('HH:mm'); - + DateFormat( + "dd/MM/yyyy 'at' HH:mm", + Localizations.localeOf(context).languageCode, + ); if (isLoading) { return const Center( child: CircularProgressIndicator.adaptive(), @@ -130,6 +114,45 @@ class _TimelinePostScreenState extends State { ? 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: [ @@ -145,7 +168,7 @@ class _TimelinePostScreenState extends State { }, child: SingleChildScrollView( child: Padding( - padding: widget.options.paddings.mainPadding, + padding: widget.options.paddings.postPadding, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -191,7 +214,9 @@ class _TimelinePostScreenState extends State { widget.options.translations.anonymousUser, style: widget.options.theme.textStyles .postCreatorTitleStyle ?? - theme.textTheme.titleMedium, + theme.textTheme.titleSmall!.copyWith( + color: Colors.black, + ), ), ], ), @@ -199,7 +224,7 @@ class _TimelinePostScreenState extends State { const Spacer(), if (!(widget.isOverviewScreen ?? false) && (widget.allowAllDeletion || - post.creator?.userId == widget.userId)) + post.creator?.userId == widget.userId)) ...[ PopupMenuButton( onSelected: (value) async { if (value == 'delete') { @@ -238,6 +263,7 @@ class _TimelinePostScreenState extends State { color: widget.options.theme.iconColor, ), ), + ], ], ), // image of the posts @@ -295,72 +321,70 @@ class _TimelinePostScreenState extends State { // post information Row( children: [ - if (post.likedBy?.contains(widget.userId) ?? false) ...[ - InkWell( - onTap: () async { + 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, ), ); - }, - child: Container( - color: Colors.transparent, - child: widget.options.theme.likedIcon ?? - Icon( - widget.post.likedBy - ?.contains(widget.userId) ?? - false - ? Icons.favorite_rounded - : Icons.favorite_outline_outlined, - ), - ), - ), - ] else ...[ - InkWell( - onTap: () async { - if (widget.isOverviewScreen ?? false) return; + setState(() {}); + } else { updatePost( await widget.service.postService.likePost( widget.userId, post, ), ); - }, - child: Container( - color: Colors.transparent, - child: widget.options.theme.likeIcon ?? + setState(() {}); + } + }, + icon: isLikedByUser + ? widget.options.theme.likedIcon ?? Icon( - widget.post.likedBy - ?.contains(widget.userId) ?? - false - ? Icons.favorite_rounded - : Icons.favorite_outline_outlined, + 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 ?? - Icon( - Icons.chat_bubble_outline_rounded, + SvgPicture.asset( + 'assets/Comment.svg', + package: 'flutter_timeline_view', + // ignore: deprecated_member_use color: widget.options.theme.iconColor, - size: widget.options.iconSize, + width: widget.options.iconSize, + height: widget.options.iconSize, ), ], ), const SizedBox(height: 8), - Text( - '${post.likes} ${widget.options.translations.likesTitle}', - style: widget - .options.theme.textStyles.postLikeTitleAndAmount ?? - theme.textTheme.titleSmall - ?.copyWith(color: Colors.black), - ), - const SizedBox(height: 4), + // 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) ?? @@ -368,62 +392,42 @@ class _TimelinePostScreenState extends State { widget.options.translations.anonymousUser, style: widget .options.theme.textStyles.postCreatorNameStyle ?? - theme.textTheme.titleSmall, + theme.textTheme.titleSmall! + .copyWith(color: Colors.black), children: [ - const TextSpan(text: ' '), TextSpan( text: post.title, style: widget.options.theme.textStyles.postTitleStyle ?? - theme.textTheme.bodyMedium, + theme.textTheme.bodySmall, ), ], ), ), const SizedBox(height: 20), - Html( - data: post.content, - style: { - 'body': Style( - padding: HtmlPaddings.zero, - margin: Margins.zero, - ), - '#': Style( - maxLines: 3, - textOverflow: TextOverflow.ellipsis, - padding: HtmlPaddings.zero, - margin: Margins.zero, - ), - 'H1': Style( - padding: HtmlPaddings.zero, - margin: Margins.zero, - ), - 'H2': Style( - padding: HtmlPaddings.zero, - margin: Margins.zero, - ), - 'H3': Style( - padding: HtmlPaddings.zero, - margin: Margins.zero, - ), - }, - ), - const SizedBox(height: 4), Text( - '${dateFormat.format(post.createdAt)} ' - '${widget.options.translations.postAt} ' - '${timeFormat.format(post.createdAt)}', + post.content, style: theme.textTheme.bodySmall, ), - const SizedBox(height: 20), - if (post.reactionEnabled) ...[ + 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.titleMedium, + style: theme.textTheme.titleSmall! + .copyWith(color: Colors.black), ), for (var reaction in post.reactions ?? []) ...[ - const SizedBox(height: 16), + const SizedBox(height: 4), GestureDetector( onLongPressStart: (details) async { if (reaction.creatorId == widget.userId || @@ -461,15 +465,13 @@ class _TimelinePostScreenState extends State { } }, child: Row( - crossAxisAlignment: reaction.imageUrl != null - ? CrossAxisAlignment.start - : CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ if (reaction.creator?.imageUrl != null && reaction.creator!.imageUrl!.isNotEmpty) ...[ widget.options.userAvatarBuilder?.call( reaction.creator!, - 28, + 14, ) ?? CircleAvatar( radius: 14, @@ -480,7 +482,7 @@ class _TimelinePostScreenState extends State { ] else ...[ widget.options.anonymousAvatarBuilder?.call( reaction.creator!, - 28, + 14, ) ?? const CircleAvatar( radius: 14, @@ -501,7 +503,8 @@ class _TimelinePostScreenState extends State { reaction.creator?.fullName ?? widget.options.translations .anonymousUser, - style: theme.textTheme.titleSmall, + style: theme.textTheme.titleSmall! + .copyWith(color: Colors.black), ), Padding( padding: const EdgeInsets.all(8.0), @@ -522,27 +525,91 @@ class _TimelinePostScreenState extends State { reaction.creator?.fullName ?? widget .options.translations.anonymousUser, - style: theme.textTheme.titleSmall, + style: theme.textTheme.titleSmall! + .copyWith(color: Colors.black), children: [ - const TextSpan(text: ' '), + const TextSpan(text: ' '), TextSpan( text: reaction.reaction ?? '', - style: theme.textTheme.bodyMedium, + 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) ...[ - const SizedBox(height: 16), Text( widget.options.translations.firstComment, + style: theme.textTheme.bodySmall, ), ], const SizedBox(height: 120), @@ -555,50 +622,71 @@ class _TimelinePostScreenState extends State { if (post.reactionEnabled && !(widget.isOverviewScreen ?? false)) Align( alignment: Alignment.bottomCenter, - child: ReactionBottom( - messageInputBuilder: textInputBuilder, - onPressSelectImage: () async { - // open the image picker - var result = await showModalBottomSheet( - context: context, - builder: (context) => Container( - padding: const EdgeInsets.all(8.0), - color: theme.colorScheme.surface, - child: ImagePicker( - imagePickerConfig: widget.options.imagePickerConfig, - imagePickerTheme: widget.options.imagePickerTheme, + 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, + ), + ), ), - ), - ); - if (result != null) { - updatePost( - await widget.service.postService.reactToPost( - post, - TimelinePostReaction( - id: '', - postId: post.id, - creatorId: widget.userId, - createdAt: DateTime.now(), + 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, + ), ), - image: result, ), - ); - } - }, - 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 index 3be9c56..f7db082 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart @@ -4,6 +4,7 @@ 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'; @@ -94,7 +95,7 @@ class _TimelineScreenState extends State { void _updateIsOnTop() { setState(() { - _isOnTop = controller.position.pixels < 40; + _isOnTop = controller.position.pixels < 0.1; }); } @@ -148,6 +149,8 @@ class _TimelineScreenState extends State { ); } + var categories = service.postService.categories; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -215,11 +218,16 @@ class _TimelineScreenState extends State { ), ], CategorySelector( + categories: categories, isOnTop: _isOnTop, filter: category, options: widget.options, onTapCategory: (categoryKey) { setState(() { + service.postService.selectedCategory = + categories.firstWhereOrNull( + (element) => element.key == categoryKey, + ); category = categoryKey; }); }, @@ -302,14 +310,14 @@ class _TimelineScreenState extends State { ), ), ), + SizedBox( + height: widget.options.paddings.mainPadding.bottom, + ), ], ), ), ), ), - SizedBox( - height: widget.options.paddings.mainPadding.bottom, - ), ], ); }, @@ -319,6 +327,7 @@ class _TimelineScreenState extends State { Future loadPosts() async { if (widget.posts != null || !context.mounted) return; try { + await service.postService.fetchCategories(); await service.postService.fetchPosts(category); 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 index 8d23418..3700e10 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 @@ -1,12 +1,15 @@ 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 StatelessWidget { +class TimelineSelectionScreen extends StatefulWidget { const TimelineSelectionScreen({ required this.options, required this.categories, required this.onCategorySelected, + required this.postService, super.key, }); @@ -16,74 +19,196 @@ class TimelineSelectionScreen extends StatelessWidget { 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: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.only(top: size.height * 0.05, bottom: 8), - child: Text( - options.translations.timelineSelectionDescription, - style: const TextStyle( - fontWeight: FontWeight.w800, - fontSize: 20, + 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, ), ), - ), - const SizedBox(height: 4), - for (var category in categories.where( - (element) => element.canCreate && element.key != null, - )) ...[ - options.categorySelectorButtonBuilder?.call( - context, - () { - onCategorySelected.call(category); - }, - category.title, - ) ?? - InkWell( - onTap: () => onCategorySelected.call(category), - child: Container( - height: 60, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: - options.theme.categorySelectionButtonBorderColor ?? - Theme.of(context).primaryColor, - width: 2, + 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, ), - color: - 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: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w800, + 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 index f6e44d6..aecf6fd 100644 --- a/packages/flutter_timeline_view/lib/src/services/local_post_service.dart +++ b/packages/flutter_timeline_view/lib/src/services/local_post_service.dart @@ -13,6 +13,12 @@ class LocalTimelinePostService @override List posts = []; + @override + List categories = []; + + @override + TimelineCategory? selectedCategory; + @override Future createPost(TimelinePost post) async { posts.add( @@ -118,10 +124,11 @@ class LocalTimelinePostService } @override - TimelinePost? getPost(String postId) => - (posts.any((element) => element.id == postId)) - ? posts.firstWhere((element) => element.id == postId) - : null; + 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 @@ -241,4 +248,85 @@ class LocalTimelinePostService ), ), ]; + + @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 index c9fe02b..007f016 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/category_selector.dart +++ b/packages/flutter_timeline_view/lib/src/widgets/category_selector.dart @@ -1,6 +1,7 @@ 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 { @@ -9,6 +10,7 @@ class CategorySelector extends StatefulWidget { required this.options, required this.onTapCategory, required this.isOnTop, + required this.categories, super.key, }); @@ -16,6 +18,7 @@ class CategorySelector extends StatefulWidget { final TimelineOptions options; final void Function(String? categoryKey) onTapCategory; final bool isOnTop; + final List categories; @override State createState() => _CategorySelectorState(); @@ -23,47 +26,42 @@ class CategorySelector extends StatefulWidget { class _CategorySelectorState extends State { @override - Widget build(BuildContext context) { - if (widget.options.categoriesOptions.categoriesBuilder == null) { - return const SizedBox.shrink(); - } - - var categories = - widget.options.categoriesOptions.categoriesBuilder!(context); - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - SizedBox( - width: widget.options.categoriesOptions - .categorySelectorHorizontalPadding ?? - max(widget.options.paddings.mainPadding.left - 20, 0), + 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), + ), + ], ), - for (var category in 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 index edd7bf5..bfdf33b 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 @@ -23,7 +23,8 @@ class CategorySelectorButton extends StatelessWidget { var theme = Theme.of(context); var size = MediaQuery.of(context).size; - return SizedBox( + return AnimatedContainer( + duration: const Duration(milliseconds: 100), height: isOnTop ? 140 : 40, child: TextButton( onPressed: onTap, @@ -81,11 +82,13 @@ class CategorySelectorButton extends StatelessWidget { Flexible( child: Row( children: [ - category.icon, - SizedBox( - width: - options.paddings.categoryButtonTextPadding ?? 8, - ), + if (category.icon != null) ...[ + category.icon!, + SizedBox( + width: + options.paddings.categoryButtonTextPadding ?? 8, + ), + ], Expanded( child: _CategoryButtonText( category: category, @@ -124,7 +127,9 @@ class _CategoryButtonText extends StatelessWidget { Widget build(BuildContext context) => Text( category.title, style: (options.theme.textStyles.categoryTitleStyle ?? - theme.textTheme.labelLarge) + (selected + ? theme.textTheme.titleMedium + : theme.textTheme.bodyMedium)) ?.copyWith( color: selected ? options.theme.categorySelectionButtonSelectedTextColor ?? 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 new file mode 100644 index 0000000..00ac78b --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/widgets/default_filled_button.dart @@ -0,0 +1,35 @@ +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/post_creation_textfield.dart b/packages/flutter_timeline_view/lib/src/widgets/post_creation_textfield.dart new file mode 100644 index 0000000..9a7e1a0 --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/widgets/post_creation_textfield.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +class PostCreationTextfield extends StatelessWidget { + const PostCreationTextfield({ + required this.controller, + required this.hintText, + required this.validator, + super.key, + this.textMaxLength, + this.decoration, + this.textCapitalization, + this.expands, + this.minLines, + this.maxLines, + this.fieldKey, + }); + + final TextEditingController controller; + final String hintText; + final int? textMaxLength; + final InputDecoration? decoration; + final TextCapitalization? textCapitalization; + // ignore: avoid_positional_boolean_parameters + final bool? expands; + final int? minLines; + final int? maxLines; + final String? Function(String?)? validator; + final Key? fieldKey; + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + return TextFormField( + key: fieldKey, + validator: validator, + style: theme.textTheme.bodySmall, + controller: controller, + maxLength: textMaxLength, + decoration: decoration ?? + InputDecoration( + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + vertical: 0, + horizontal: 16, + ), + hintText: hintText, + hintStyle: theme.textTheme.bodySmall!.copyWith( + color: theme.textTheme.bodySmall!.color!.withOpacity(0.5), + ), + fillColor: Colors.white, + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + textCapitalization: textCapitalization ?? TextCapitalization.none, + expands: expands ?? false, + minLines: minLines, + maxLines: maxLines, + ); + } +} 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 171c597..a13dfbe 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/reaction_bottom.dart +++ b/packages/flutter_timeline_view/lib/src/widgets/reaction_bottom.dart @@ -3,6 +3,7 @@ // 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'; @@ -11,14 +12,12 @@ class ReactionBottom extends StatefulWidget { required this.onReactionSubmit, required this.messageInputBuilder, required this.translations, - this.onPressSelectImage, this.iconColor, super.key, }); final Future Function(String text) onReactionSubmit; final TextInputBuilder messageInputBuilder; - final VoidCallback? onPressSelectImage; final TimelineTranslations translations; final Color? iconColor; @@ -30,56 +29,30 @@ class _ReactionBottomState extends State { final TextEditingController _textEditingController = TextEditingController(); @override - Widget build(BuildContext context) => SafeArea( - bottom: true, - child: Container( - color: Theme.of(context).colorScheme.surface, - child: Container( - margin: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, + Widget build(BuildContext context) => Container( + child: widget.messageInputBuilder( + _textEditingController, + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, ), - height: 48, - child: widget.messageInputBuilder( - _textEditingController, - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - ), - 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; - if (value.isNotEmpty) { - await widget.onReactionSubmit(value); - _textEditingController.clear(); - } - }, - icon: Icon( - Icons.send, - color: widget.iconColor, - ), - ), - ], - ), + 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, ), ), + widget.translations.writeComment, ), ); } diff --git a/packages/flutter_timeline_view/lib/src/widgets/tappable_image.dart b/packages/flutter_timeline_view/lib/src/widgets/tappable_image.dart index 9217c08..fcf2b48 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/tappable_image.dart +++ b/packages/flutter_timeline_view/lib/src/widgets/tappable_image.dart @@ -141,6 +141,7 @@ class _HeartAnimationState extends State { unawaited( Future.delayed(const Duration(milliseconds: 100)).then((value) async { active = widget.liked; + // ignore: use_build_context_synchronously var navigator = Navigator.of(context); await Future.delayed(widget.duration); navigator.pop(); 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 1051d42..103aac9 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 @@ -4,8 +4,10 @@ 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 { @@ -52,324 +54,387 @@ class _TimelinePostWidgetState extends State { @override Widget build(BuildContext context) { var theme = Theme.of(context); - return InkWell( - onTap: widget.onTap, - child: 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!, - ), + 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.titleMedium, - ), - ], - ), - ), - 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, - ), - ], - ), + ] 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, + ), ), ], - child: widget.options.theme.moreIcon ?? - Icon( - Icons.more_horiz_rounded, + ), + ), + ], + 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}'), + ], ], ), - // 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, - ), + ] 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( - height: 8, - ), - // post information - if (widget.options.iconsWithValues) - Row( - children: [ - TextButton.icon( - onPressed: () async { - var userId = widget.userId; - - var liked = - widget.post.likedBy?.contains(userId) ?? false; - - if (!liked) { - await widget.service.postService.likePost( - userId, - widget.post, - ); - } else { - await widget.service.postService.unlikePost( - userId, - widget.post, - ); - } - }, - icon: widget.options.theme.likeIcon ?? - Icon( - widget.post.likedBy?.contains(widget.userId) ?? false - ? Icons.favorite_rounded - : Icons.favorite_outline_outlined, + 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, ), - label: Text('${widget.post.likes}'), ), - if (widget.post.reactionEnabled) - TextButton.icon( - onPressed: widget.onTap, - icon: widget.options.theme.commentIcon ?? - const Icon( - Icons.chat_bubble_outline_outlined, - ), - label: Text('${widget.post.reaction}'), - ), ], - ) - else - Row( - children: [ - if (widget.post.likedBy?.contains(widget.userId) ?? - false) ...[ - InkWell( - onTap: widget.onTapUnlike, - child: Container( - color: Colors.transparent, - child: widget.options.theme.likedIcon ?? - Icon( - Icons.favorite_rounded, - color: widget.options.theme.iconColor, - size: widget.options.iconSize, - ), - ), - ), - ] else ...[ - InkWell( - onTap: widget.onTapLike, - child: Container( - color: Colors.transparent, - child: widget.options.theme.likeIcon ?? - Icon( - Icons.favorite_outline, - color: widget.options.theme.iconColor, - size: widget.options.iconSize, - ), - ), - ), - ], - const SizedBox(width: 8), - if (widget.post.reactionEnabled) ...[ - Container( - color: Colors.transparent, - child: widget.options.theme.commentIcon ?? - Icon( - Icons.chat_bubble_outline_rounded, - color: widget.options.theme.iconColor, - size: widget.options.iconSize, - ), - ), - ], - ], - ), - - const SizedBox( - height: 8, + ], ), + ], - if (widget.options.itemInfoBuilder != null) ...[ - widget.options.itemInfoBuilder!( - post: widget.post, - ), - ] else ...[ - Text( - '${widget.post.likes} ' - '${widget.options.translations.likesTitle}', - style: widget - .options.theme.textStyles.listPostLikeTitleAndAmount ?? - theme.textTheme.titleSmall, - ), - const SizedBox(height: 4), - 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, - children: [ - const TextSpan(text: ' '), - TextSpan( - text: widget.post.title, - style: - widget.options.theme.textStyles.listPostTitleStyle ?? - theme.textTheme.bodyMedium, + 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), - Text( + ), + const SizedBox(height: 4), + InkWell( + onTap: widget.onTap, + child: Text( widget.options.translations.viewPost, style: widget.options.theme.textStyles.viewPostStyle ?? - theme.textTheme.bodySmall, + theme.textTheme.titleSmall!.copyWith( + color: const Color(0xFF8D8D8D), + ), ), - ], - if (widget.options.dividerBuilder != null) - widget.options.dividerBuilder!(), + ), ], - ), + 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( - 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, + 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), + ), + ), + ), + ], + ), ), ); diff --git a/packages/flutter_timeline_view/pubspec.yaml b/packages/flutter_timeline_view/pubspec.yaml index e5d8830..a0b4580 100644 --- a/packages/flutter_timeline_view/pubspec.yaml +++ b/packages/flutter_timeline_view/pubspec.yaml @@ -4,12 +4,11 @@ name: flutter_timeline_view description: Visual elements of the Flutter Timeline Component -version: 4.1.0 - -publish_to: none +version: 5.1.0 +publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub environment: - sdk: '>=3.1.3 <4.0.0' + sdk: ">=3.1.3 <4.0.0" dependencies: flutter: @@ -17,18 +16,14 @@ dependencies: intl: any cached_network_image: ^3.2.2 dotted_border: ^2.1.0 - flutter_html: ^3.0.0-beta.2 - - flutter_timeline_interface: - git: - url: https://github.com/Iconica-Development/flutter_timeline - path: packages/flutter_timeline_interface - ref: 4.1.0 - flutter_image_picker: - git: - url: https://github.com/Iconica-Development/flutter_image_picker - ref: 1.0.5 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 @@ -38,4 +33,5 @@ dev_dependencies: ref: 6.0.0 flutter: - + assets: + - assets/