diff --git a/README.md b/README.md index 67dea13..83062cf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Flutter Timeline +Flutter Timeline is a package which shows a list posts by a user. This package also has additional features like liking a post and leaving comments. Default this package adds support for a Firebase back-end. You can add your custom back-end (like a Websocket-API) by extending the `CommunityChatInterface` interface from the `flutter_community_chat_interface` package. ![Flutter Timeline GIF](example.gif) @@ -23,7 +24,137 @@ If you are going to use Firebase as the back-end of the Timeline, you should als ``` ## How to use -To use the module within your Flutter-application you should add the following code to the build-method of a chosen widget. +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: FirebaseTimelineService(), + userService: FirebaseUserService(), + userId: currentUserId, + optionsBuilder: (context) {}, + ), + ); +``` + +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(timelineUserStoryConfiguration) + ], +); +``` + +The user story can also be used without go router: +Add the following code somewhere in your widget tree: + +```` +timeLineNavigatorUserStory(TimelineUserStoryConfiguration, context), +```` + + +Or create your own routing using the Screens: +To add the `TimelineScreen` add the following code: + +```` +TimelineScreen( + userId: currentUserId, + service: timelineService, + options: timelineOptions, + onPostTap: (post) {} +), +```` + +`TimelineScreen` is supplied with a standard `TimelinePostScreen` which opens the detail page of the selected post. Needed parameter like `TimelineService` and `TimelineOptions` will be the same as the ones supplied to the `TimelineScreen`. + +The standard `TimelinePostScreen` can be overridden by supplying `onPostTap` as shown below. + +``` +TimelineScreen( + userId: currentUserId, + service: timelineService, + options: timelineOptions, + onPostTap: (tappedPost) { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => Scaffold( + body: TimelinePostScreen( + userId: currentUserId, + service: timelineService, + options: timelineOptions, + post: post, + onPostDelete: () { + service.deletePost(post); + }, + ), + ), + ), + ); + }, +), +``` + +A standard post creation is provided named: `TimelinePostCreationScreen`. Routing to this has to be done manually. + +``` +TimelinePostCreationScreen( + postCategory: selectedCategory, + userId: currentUserId, + service: timelineService, + options: timelineOptions, + onPostCreated: (post) { + Navigator.of(context).pop(); + }, +), +``` + +The `TimelineOptions` has its own parameters, as specified below: + +| Parameter | Explanation | +|-----------|-------------| +| theme | Used to set icon colors and textstyles | +| translations | Ability to provide desired text and tanslations. | +| imagePickerConfig | Config for the image picker in the post creation screen. | +| imagePickerTheme | Theme for the image picker in the post creation screen. | +| timelinePostHeight | Sets the height for each post widget in the list of post. If null, the size depends on the size of the image. | +| allowAllDeletion | Determines of users are allowed to delete thier own posts. | +| sortCommentsAscending | Determines if the comments are sorted from old to new or new to old. | +| sortPostsAscending | Determines if the posts are sorted from old to new or new to old. | +| doubleTapToLike | Enables the abilty to double tap the image to like the post. | +| iconsWithValues | Ability to provide desired text and tanslations. | +| likeAndDislikeIconsForDoubleTap | Ability to override the standard icon which appears on double tap. | +| itemInfoBuilder | Ability to override the bottom of the postwidgets. (Everything under the like and comment icons) | +| dateFormat | Sets the used date format | +| timeFormat | Sets the used time format | +| buttonBuilder | The ability to provide a custom button for the post creation screen. | +| textInputBuilder | The ability to provide a custom text input widget for the post creation screen. | +| dividerBuilder | Ability to provide desired text and tanslations. | +| userAvatarBuilder | The ability to provide a custom avatar. | +| anonymousAvatarBuilder | The ability to provide a custom avatar for anonymous users. | +| nameBuilder | The ability to override the standard way of display the post creator name. | +| padding | Padding used for the whole page. | +| iconSize | Size of icons like the comment and like icons. Dafualts to 26. | +| postWidgetHeight | Ability to provide desired text and tanslations. | +| postPadding | Padding for each post. | +| filterOptions | Options for using the filter to filter posts. | +| categoriesOptions | Options for using the category selector to provide posts of a certain category. | + + +The `ImagePickerTheme` ans `imagePickerConfig` also have their own parameters, how to use these parameters can be found in [the documentation of the flutter_image_picker package](https://github.com/Iconica-Development/flutter_image_picker). ## Issues diff --git a/packages/flutter_timeline_view/example/.gitignore b/packages/flutter_timeline/example/.gitignore similarity index 98% rename from packages/flutter_timeline_view/example/.gitignore rename to packages/flutter_timeline/example/.gitignore index 24476c5..29a3a50 100644 --- a/packages/flutter_timeline_view/example/.gitignore +++ b/packages/flutter_timeline/example/.gitignore @@ -27,7 +27,6 @@ migrate_working_dir/ .dart_tool/ .flutter-plugins .flutter-plugins-dependencies -.packages .pub-cache/ .pub/ /build/ diff --git a/packages/flutter_timeline_view/example/analysis_options.yaml b/packages/flutter_timeline/example/analysis_options.yaml similarity index 100% rename from packages/flutter_timeline_view/example/analysis_options.yaml rename to packages/flutter_timeline/example/analysis_options.yaml diff --git a/packages/flutter_timeline/example/lib/apps/go_router/app.dart b/packages/flutter_timeline/example/lib/apps/go_router/app.dart new file mode 100644 index 0000000..ef18fef --- /dev/null +++ b/packages/flutter_timeline/example/lib/apps/go_router/app.dart @@ -0,0 +1,37 @@ +import 'package:example/config/config.dart'; +import 'package:example/services/timeline_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_timeline/flutter_timeline.dart'; +import 'package:go_router/go_router.dart'; + +List getTimelineRoutes() => getTimelineStoryRoutes( + getConfig( + TestTimelineService(), + ), + ); + +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( + colorScheme: + ColorScheme.fromSeed(seedColor: Colors.deepPurple).copyWith( + background: const Color(0xFFB8E2E8), + ), + useMaterial3: true, + ), + ); + } +} diff --git a/packages/flutter_timeline/example/lib/apps/navigator/app.dart b/packages/flutter_timeline/example/lib/apps/navigator/app.dart new file mode 100644 index 0000000..2473d64 --- /dev/null +++ b/packages/flutter_timeline/example/lib/apps/navigator/app.dart @@ -0,0 +1,71 @@ +import 'package:example/config/config.dart'; +import 'package:example/services/timeline_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_timeline/flutter_timeline.dart'; + +class NavigatorApp extends StatelessWidget { + const NavigatorApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Timeline', + theme: ThemeData( + colorScheme: + ColorScheme.fromSeed(seedColor: Colors.deepPurple).copyWith( + background: const Color(0xFFB8E2E8), + ), + useMaterial3: true, + ), + home: const MyHomePage(), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({ + super.key, + }); + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + var timelineService = TestTimelineService(); + var timelineOptions = options; + + @override + Widget build(BuildContext context) { + return Scaffold( + floatingActionButton: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FloatingActionButton( + heroTag: 'btn1', + onPressed: () => + createPost(context, timelineService, timelineOptions), + child: const Icon( + Icons.edit, + color: Colors.white, + ), + ), + const SizedBox( + height: 8, + ), + FloatingActionButton( + heroTag: 'btn2', + onPressed: () => generatePost(timelineService), + child: const Icon( + Icons.add, + color: Colors.white, + ), + ), + ], + ), + body: SafeArea( + child: timeLineNavigatorUserStory(getConfig(timelineService), context), + ), + ); + } +} diff --git a/packages/flutter_timeline/example/lib/apps/widgets/app.dart b/packages/flutter_timeline/example/lib/apps/widgets/app.dart new file mode 100644 index 0000000..8367742 --- /dev/null +++ b/packages/flutter_timeline/example/lib/apps/widgets/app.dart @@ -0,0 +1,95 @@ +import 'package:example/config/config.dart'; +import 'package:example/services/timeline_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_timeline/flutter_timeline.dart'; + +class WidgetApp extends StatelessWidget { + const WidgetApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Timeline', + theme: ThemeData( + colorScheme: + ColorScheme.fromSeed(seedColor: Colors.deepPurple).copyWith( + background: const Color(0xFFB8E2E8), + ), + useMaterial3: true, + ), + home: const MyHomePage(), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({ + super.key, + }); + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + var timelineService = TestTimelineService(); + var timelineOptions = options; + + @override + Widget build(BuildContext context) { + return Scaffold( + floatingActionButton: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FloatingActionButton( + onPressed: () { + createPost(context, timelineService, timelineOptions); + }, + child: const Icon( + Icons.edit, + color: Colors.white, + ), + ), + const SizedBox( + height: 8, + ), + FloatingActionButton( + onPressed: () { + generatePost(timelineService); + }, + child: const Icon( + Icons.add, + color: Colors.white, + ), + ), + ], + ), + body: SafeArea( + child: TimelineScreen( + userId: 'test_user', + service: timelineService, + options: timelineOptions, + onPostTap: (post) async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => Scaffold( + body: TimelinePostScreen( + userId: 'test_user', + service: timelineService, + options: timelineOptions, + post: post, + onPostDelete: () { + timelineService.deletePost(post); + Navigator.of(context).pop(); + }, + ), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/packages/flutter_timeline/example/lib/apps/widgets/screens/post_screen.dart b/packages/flutter_timeline/example/lib/apps/widgets/screens/post_screen.dart new file mode 100644 index 0000000..0bae2fb --- /dev/null +++ b/packages/flutter_timeline/example/lib/apps/widgets/screens/post_screen.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_timeline/flutter_timeline.dart'; + +class PostScreen extends StatefulWidget { + const PostScreen({ + required this.service, + required this.post, + super.key, + }); + + final TimelineService service; + final TimelinePost post; + + @override + State createState() => _PostScreenState(); +} + +class _PostScreenState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + body: TimelinePostScreen( + userId: 'test_user', + service: widget.service, + options: TimelineOptions(), + post: widget.post, + onPostDelete: () { + Navigator.of(context).pop(); + }, + ), + ); + } +} + +class TestUserService implements TimelineUserService { + final Map _users = { + 'test_user': const TimelinePosterUserModel(userId: 'test_user') + }; + + @override + Future getUser(String userId) async { + if (_users.containsKey(userId)) { + return _users[userId]!; + } + + _users[userId] = TimelinePosterUserModel(userId: userId); + + return TimelinePosterUserModel(userId: userId); + } +} diff --git a/packages/flutter_timeline/example/lib/config/config.dart b/packages/flutter_timeline/example/lib/config/config.dart new file mode 100644 index 0000000..734c1eb --- /dev/null +++ b/packages/flutter_timeline/example/lib/config/config.dart @@ -0,0 +1,77 @@ +import 'package:example/apps/widgets/screens/post_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_timeline/flutter_timeline.dart'; + +TimelineUserStoryConfiguration getConfig(TimelineService service) { + return TimelineUserStoryConfiguration( + service: service, + userService: TestUserService(), + userId: 'test_user', + optionsBuilder: (context) => options); +} + +var options = TimelineOptions( + textInputBuilder: null, + padding: const EdgeInsets.all(20).copyWith(top: 28), + allowAllDeletion: true, + 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 createPost(BuildContext context, TimelineService service, + TimelineOptions options) async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => Scaffold( + body: TimelinePostCreationScreen( + postCategory: null, + userId: 'test_user', + service: service, + options: options, + onPostCreated: (post) { + Navigator.of(context).pop(); + }, + ), + ), + ), + ); +} + +void generatePost(TimelineService service) { + var amountOfPosts = service.getPosts(null).length; + + service.createPost( + TimelinePost( + id: 'Post$amountOfPosts', + creatorId: 'test_user', + title: 'Post $amountOfPosts', + category: amountOfPosts % 2 == 0 ? 'category1' : 'category2', + content: "Post $amountOfPosts content", + likes: 0, + reaction: 0, + createdAt: DateTime.now(), + reactionEnabled: amountOfPosts % 2 == 0 ? false : true, + imageUrl: amountOfPosts % 3 != 0 + ? 'https://s3-eu-west-1.amazonaws.com/sortlist-core-api/6qpvvqjtmniirpkvp8eg83bicnc2' + : null, + ), + ); +} diff --git a/packages/flutter_timeline/example/lib/main.dart b/packages/flutter_timeline/example/lib/main.dart new file mode 100644 index 0000000..9f32ce9 --- /dev/null +++ b/packages/flutter_timeline/example/lib/main.dart @@ -0,0 +1,15 @@ +// import 'package:example/apps/go_router/app.dart'; +// import 'package:example/apps/navigator/app.dart'; +import 'package:example/apps/widgets/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()); +} diff --git a/packages/flutter_timeline/example/lib/services/timeline_service.dart b/packages/flutter_timeline/example/lib/services/timeline_service.dart new file mode 100644 index 0000000..3788e41 --- /dev/null +++ b/packages/flutter_timeline/example/lib/services/timeline_service.dart @@ -0,0 +1,187 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_timeline/flutter_timeline.dart'; + +// ignore: depend_on_referenced_packages +import 'package:uuid/uuid.dart'; + +class TestTimelineService with ChangeNotifier implements TimelineService { + @override + List posts = []; + + @override + Future createPost(TimelinePost post) async { + posts.add( + post.copyWith( + creator: const TimelinePosterUserModel(userId: 'test_user'), + ), + ); + notifyListeners(); + return post; + } + + @override + Future deletePost(TimelinePost post) async { + posts = posts.where((element) => element.id != post.id).toList(); + + notifyListeners(); + } + + @override + Future deletePostReaction( + TimelinePost post, + String reactionId, + ) async { + if (post.reactions != null && post.reactions!.isNotEmpty) { + var reaction = + post.reactions!.firstWhere((element) => element.id == reactionId); + var updatedPost = post.copyWith( + reaction: post.reaction - 1, + reactions: (post.reactions ?? [])..remove(reaction), + ); + posts = posts + .map( + (p) => p.id == post.id ? updatedPost : p, + ) + .toList(); + + notifyListeners(); + return updatedPost; + } + return post; + } + + @override + Future fetchPostDetails(TimelinePost post) async { + var reactions = post.reactions ?? []; + var updatedReactions = []; + for (var reaction in reactions) { + updatedReactions.add(reaction.copyWith( + creator: const TimelinePosterUserModel(userId: 'test_user'))); + } + var updatedPost = post.copyWith(reactions: updatedReactions); + posts = posts.map((p) => (p.id == post.id) ? updatedPost : p).toList(); + notifyListeners(); + return updatedPost; + } + + @override + Future> fetchPosts(String? category) async { + posts = getMockedPosts(); + notifyListeners(); + return posts; + } + + @override + Future> fetchPostsPaginated( + String? category, + int limit, + ) async { + notifyListeners(); + return posts; + } + + @override + Future fetchPost(TimelinePost post) async { + notifyListeners(); + return post; + } + + @override + Future> refreshPosts(String? category) async { + var newPosts = []; + + posts = [...posts, ...newPosts]; + notifyListeners(); + return posts; + } + + @override + TimelinePost? getPost(String postId) => + (posts.any((element) => element.id == postId)) + ? posts.firstWhere((element) => element.id == postId) + : null; + + @override + List getPosts(String? category) => posts + .where((element) => category == null || element.category == category) + .toList(); + + @override + Future likePost(String userId, TimelinePost post) async { + var updatedPost = post.copyWith( + likes: post.likes + 1, + likedBy: (post.likedBy ?? [])..add(userId), + ); + posts = posts + .map( + (p) => p.id == post.id ? updatedPost : p, + ) + .toList(); + + notifyListeners(); + return updatedPost; + } + + @override + Future unlikePost(String userId, TimelinePost post) async { + var updatedPost = post.copyWith( + likes: post.likes - 1, + likedBy: post.likedBy?..remove(userId), + ); + posts = posts + .map( + (p) => p.id == post.id ? updatedPost : p, + ) + .toList(); + + notifyListeners(); + return updatedPost; + } + + @override + Future reactToPost( + TimelinePost post, + TimelinePostReaction reaction, { + Uint8List? image, + }) async { + var reactionId = const Uuid().v4(); + var updatedReaction = reaction.copyWith( + id: reactionId, + creator: const TimelinePosterUserModel(userId: 'test_user')); + + var updatedPost = post.copyWith( + reaction: post.reaction + 1, + reactions: post.reactions?..add(updatedReaction), + ); + + posts = posts + .map( + (p) => p.id == post.id ? updatedPost : p, + ) + .toList(); + notifyListeners(); + return updatedPost; + } + + List getMockedPosts() { + return [ + TimelinePost( + id: 'Post0', + creatorId: 'test_user', + title: 'Post 0', + category: null, + content: "Post 0 content", + likes: 0, + reaction: 0, + createdAt: DateTime.now(), + reactionEnabled: false, + ) + ]; + } +} diff --git a/packages/flutter_timeline/example/pubspec.yaml b/packages/flutter_timeline/example/pubspec.yaml new file mode 100644 index 0000000..9008d45 --- /dev/null +++ b/packages/flutter_timeline/example/pubspec.yaml @@ -0,0 +1,93 @@ +name: example +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: '>=3.2.3 <4.0.0' + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.2 + flutter_timeline: + path: ../ + intl: ^0.19.0 + go_router: ^13.0.1 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^2.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - assets/ + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/packages/flutter_timeline/lib/flutter_timeline.dart b/packages/flutter_timeline/lib/flutter_timeline.dart index b4af6ca..9c68eb6 100644 --- a/packages/flutter_timeline/lib/flutter_timeline.dart +++ b/packages/flutter_timeline/lib/flutter_timeline.dart @@ -5,6 +5,7 @@ /// Flutter Timeline library library flutter_timeline; +export 'package:flutter_timeline/src/flutter_timeline_navigator_userstory.dart'; export 'package:flutter_timeline/src/flutter_timeline_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_navigator_userstory.dart b/packages/flutter_timeline/lib/src/flutter_timeline_navigator_userstory.dart new file mode 100644 index 0000000..468b521 --- /dev/null +++ b/packages/flutter_timeline/lib/src/flutter_timeline_navigator_userstory.dart @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2024 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; +import 'package:flutter_timeline/flutter_timeline.dart'; + +Widget timeLineNavigatorUserStory( + TimelineUserStoryConfiguration configuration, + BuildContext context, +) => + _timelineScreenRoute(configuration, context); + +Widget _timelineScreenRoute( + TimelineUserStoryConfiguration configuration, + BuildContext context, +) => + TimelineScreen( + service: configuration.service, + options: configuration.optionsBuilder(context), + userId: configuration.userId, + onPostTap: (post) async => + configuration.onPostTap?.call(context, post) ?? + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + _postDetailScreenRoute(configuration, context, post), + ), + ), + onUserTap: (userId) { + configuration.onUserTap?.call(context, userId); + }, + filterEnabled: configuration.filterEnabled, + postWidgetBuilder: configuration.postWidgetBuilder, + ); + +Widget _postDetailScreenRoute( + TimelineUserStoryConfiguration configuration, + BuildContext context, + TimelinePost post, +) => + TimelinePostScreen( + userId: configuration.userId, + service: configuration.service, + options: configuration.optionsBuilder(context), + post: post, + onPostDelete: () async { + configuration.onPostDelete?.call(context, post) ?? + await configuration.service.deletePost(post); + }, + ); diff --git a/packages/flutter_timeline/lib/src/flutter_timeline_userstory.dart b/packages/flutter_timeline/lib/src/flutter_timeline_userstory.dart index 87d0dcc..cdd4ba5 100644 --- a/packages/flutter_timeline/lib/src/flutter_timeline_userstory.dart +++ b/packages/flutter_timeline/lib/src/flutter_timeline_userstory.dart @@ -16,23 +16,25 @@ List getTimelineStoryRoutes( GoRoute( path: TimelineUserStoryRoutes.timelineHome, pageBuilder: (context, state) { - var timelineFilter = - Container(); // TODO(anyone): create a filter widget var timelineScreen = TimelineScreen( userId: configuration.userId, onUserTap: (user) => configuration.onUserTap?.call(context, user), service: configuration.service, options: configuration.optionsBuilder(context), onPostTap: (post) async => - TimelineUserStoryRoutes.timelineViewPath(post.id), - timelineCategoryFilter: null, + configuration.onPostTap?.call(context, post) ?? + await context.push( + TimelineUserStoryRoutes.timelineViewPath(post.id), + ), + filterEnabled: configuration.filterEnabled, + postWidgetBuilder: configuration.postWidgetBuilder, ); + return buildScreenWithoutTransition( context: context, state: state, - child: configuration.mainPageBuilder?.call( + child: configuration.openPageBuilder?.call( context, - timelineFilter, timelineScreen, ) ?? Scaffold( @@ -41,74 +43,27 @@ List getTimelineStoryRoutes( ); }, ), - GoRoute( - path: TimelineUserStoryRoutes.timelineSelect, - pageBuilder: (context, state) { - var timelineSelectionWidget = TimelineSelectionScreen( - options: configuration.optionsBuilder(context), - categories: configuration.categoriesBuilder(context), - onCategorySelected: (category) async => context.push( - TimelineUserStoryRoutes.timelineCreatePath(category.name), - ), - ); - return buildScreenWithoutTransition( - context: context, - state: state, - child: configuration.postSelectionScreenBuilder?.call( - context, - timelineSelectionWidget, - ) ?? - Scaffold( - body: timelineSelectionWidget, - ), - ); - }, - ), - GoRoute( - path: TimelineUserStoryRoutes.timelineCreate, - pageBuilder: (context, state) { - var timelineCreateWidget = TimelinePostCreationScreen( - userId: configuration.userId, - options: configuration.optionsBuilder(context), - postCategory: state.pathParameters['category'] ?? '', - service: configuration.service, - onPostCreated: (post) => context.go( - TimelineUserStoryRoutes.timelineViewPath(post.id), - ), - ); - return buildScreenWithoutTransition( - context: context, - state: state, - child: configuration.postCreationScreenBuilder?.call( - context, - timelineCreateWidget, - ) ?? - Scaffold( - body: timelineCreateWidget, - ), - ); - }, - ), GoRoute( path: TimelineUserStoryRoutes.timelineView, pageBuilder: (context, state) { + var post = + configuration.service.getPost(state.pathParameters['post']!)!; + var timelinePostWidget = TimelinePostScreen( userId: configuration.userId, options: configuration.optionsBuilder(context), service: configuration.service, - userService: configuration.userService, - post: configuration.service.getPost(state.pathParameters['post']!)!, - onPostDelete: () => context.pop(), + post: post, + onPostDelete: () => configuration.onPostDelete?.call(context, post), onUserTap: (user) => configuration.onUserTap?.call(context, user), ); - var category = configuration.categoriesBuilder(context).first; + return buildScreenWithoutTransition( context: context, state: state, - child: configuration.postScreenBuilder?.call( + child: configuration.openPageBuilder?.call( context, timelinePostWidget, - category, ) ?? Scaffold( body: timelinePostWidget, diff --git a/packages/flutter_timeline/lib/src/models/timeline_configuration.dart b/packages/flutter_timeline/lib/src/models/timeline_configuration.dart index 748f641..8708d3b 100644 --- a/packages/flutter_timeline/lib/src/models/timeline_configuration.dart +++ b/packages/flutter_timeline/lib/src/models/timeline_configuration.dart @@ -9,42 +9,35 @@ import 'package:flutter_timeline_view/flutter_timeline_view.dart'; @immutable class TimelineUserStoryConfiguration { const TimelineUserStoryConfiguration({ - required this.categoriesBuilder, - required this.optionsBuilder, required this.userId, required this.service, required this.userService, - this.mainPageBuilder, - this.postScreenBuilder, - this.postCreationScreenBuilder, - this.postSelectionScreenBuilder, + required this.optionsBuilder, + this.openPageBuilder, + this.onPostTap, this.onUserTap, + this.onPostDelete, + this.filterEnabled = false, + this.postWidgetBuilder, }); final String userId; - final Function(BuildContext context, String userId)? onUserTap; - - final Widget Function(BuildContext context, Widget filterBar, Widget child)? - mainPageBuilder; - - final Widget Function( - BuildContext context, - Widget child, - TimelineCategory category, - )? postScreenBuilder; - - final Widget Function(BuildContext context, Widget child)? - postCreationScreenBuilder; - - final Widget Function(BuildContext context, Widget child)? - postSelectionScreenBuilder; - final TimelineService service; final TimelineUserService userService; final TimelineOptions Function(BuildContext context) optionsBuilder; - final List Function(BuildContext context) categoriesBuilder; + final Function(BuildContext context, String userId)? onUserTap; + + final Function(BuildContext context, Widget child)? openPageBuilder; + + final Function(BuildContext context, TimelinePost post)? onPostTap; + + final Widget Function(BuildContext context, TimelinePost post)? onPostDelete; + + final bool filterEnabled; + + final Widget Function(TimelinePost post)? postWidgetBuilder; } diff --git a/packages/flutter_timeline/lib/src/routes.dart b/packages/flutter_timeline/lib/src/routes.dart index 3196bea..b6c70e5 100644 --- a/packages/flutter_timeline/lib/src/routes.dart +++ b/packages/flutter_timeline/lib/src/routes.dart @@ -4,10 +4,6 @@ mixin TimelineUserStoryRoutes { static const String timelineHome = '/timeline'; - static const String timelineCreate = '/timeline-create/:category'; - static String timelineCreatePath(String category) => - '/timeline-create/$category'; - static const String timelineSelect = '/timeline-select'; static const String timelineView = '/timeline-view/:post'; static String timelineViewPath(String postId) => '/timeline-view/$postId'; } diff --git a/packages/flutter_timeline/pubspec.yaml b/packages/flutter_timeline/pubspec.yaml index 69e1e87..83e93f5 100644 --- a/packages/flutter_timeline/pubspec.yaml +++ b/packages/flutter_timeline/pubspec.yaml @@ -3,7 +3,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later name: flutter_timeline description: Visual elements and interface combined into one package -version: 1.0.0 +version: 2.0.0 publish_to: none @@ -14,16 +14,18 @@ dependencies: flutter: sdk: flutter go_router: any + flutter_timeline_view: git: url: https://github.com/Iconica-Development/flutter_timeline path: packages/flutter_timeline_view - ref: 1.0.0 + ref: 2.0.0 + flutter_timeline_interface: git: url: https://github.com/Iconica-Development/flutter_timeline path: packages/flutter_timeline_interface - ref: 1.0.0 + ref: 2.0.0 dev_dependencies: flutter_lints: ^2.0.0 diff --git a/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart b/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart index ed95443..b74ff5d 100644 --- a/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart +++ b/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart @@ -9,10 +9,11 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_storage/firebase_storage.dart'; import 'package:flutter/material.dart'; import 'package:flutter_timeline_firebase/src/config/firebase_timeline_options.dart'; +import 'package:flutter_timeline_firebase/src/models/firebase_user_document.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; import 'package:uuid/uuid.dart'; -class FirebaseTimelineService with ChangeNotifier implements TimelineService { +class FirebaseTimelineService extends TimelineService with TimelineUserService { FirebaseTimelineService({ required TimelineUserService userService, FirebaseApp? app, @@ -30,7 +31,7 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService { late TimelineUserService _userService; late FirebaseTimelineOptions _options; - List _posts = []; + final Map _users = {}; @override Future createPost(TimelinePost post) async { @@ -47,14 +48,14 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService { var postRef = _db.collection(_options.timelineCollectionName).doc(updatedPost.id); await postRef.set(updatedPost.toJson()); - _posts.add(updatedPost); + posts.add(updatedPost); notifyListeners(); return updatedPost; } @override Future deletePost(TimelinePost post) async { - _posts = _posts.where((element) => element.id != post.id).toList(); + posts = posts.where((element) => element.id != post.id).toList(); var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); await postRef.delete(); notifyListeners(); @@ -72,7 +73,7 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService { reaction: post.reaction - 1, reactions: (post.reactions ?? [])..remove(reaction), ); - _posts = _posts + posts = posts .map( (p) => p.id == post.id ? updatedPost : p, ) @@ -102,7 +103,7 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService { } } var updatedPost = post.copyWith(reactions: updatedReactions); - _posts = _posts.map((p) => (p.id == post.id) ? updatedPost : p).toList(); + posts = posts.map((p) => (p.id == post.id) ? updatedPost : p).toList(); notifyListeners(); return updatedPost; } @@ -124,7 +125,7 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService { var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user); posts.add(post); } - _posts = posts; + notifyListeners(); return posts; } @@ -135,12 +136,12 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService { int limit, ) async { // only take posts that are in our category - var oldestPost = _posts + var oldestPost = posts .where( (element) => category == null || element.category == category, ) .fold( - _posts.first, + posts.first, (previousValue, element) => (previousValue.createdAt.isBefore(element.createdAt)) ? previousValue @@ -161,16 +162,16 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService { .limit(limit) .get(); // add the new posts to the list - var posts = []; + var newPosts = []; for (var doc in snapshot.docs) { var data = doc.data(); var user = await _userService.getUser(data['creator_id']); var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user); - posts.add(post); + newPosts.add(post); } - _posts = [..._posts, ...posts]; + posts = [...posts, ...newPosts]; notifyListeners(); - return posts; + return newPosts; } @override @@ -185,7 +186,7 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService { var updatedPost = TimelinePost.fromJson(doc.id, data).copyWith( creator: user, ); - _posts = _posts.map((p) => (p.id == post.id) ? updatedPost : p).toList(); + posts = posts.map((p) => (p.id == post.id) ? updatedPost : p).toList(); notifyListeners(); return updatedPost; } @@ -193,12 +194,12 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService { @override Future> refreshPosts(String? category) async { // fetch all posts between now and the newest posts we have - var newestPostWeHave = _posts + var newestPostWeHave = posts .where( (element) => category == null || element.category == category, ) .fold( - _posts.first, + posts.first, (previousValue, element) => (previousValue.createdAt.isAfter(element.createdAt)) ? previousValue @@ -215,26 +216,26 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService { .orderBy('created_at', descending: true) .endBefore([newestPostWeHave.createdAt]).get(); // add the new posts to the list - var posts = []; + var newPosts = []; for (var doc in snapshot.docs) { var data = doc.data(); var user = await _userService.getUser(data['creator_id']); var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user); - posts.add(post); + newPosts.add(post); } - _posts = [...posts, ..._posts]; + posts = [...posts, ...newPosts]; notifyListeners(); - return posts; + return newPosts; } @override TimelinePost? getPost(String postId) => - (_posts.any((element) => element.id == postId)) - ? _posts.firstWhere((element) => element.id == postId) + (posts.any((element) => element.id == postId)) + ? posts.firstWhere((element) => element.id == postId) : null; @override - List getPosts(String? category) => _posts + List getPosts(String? category) => posts .where((element) => category == null || element.category == category) .toList(); @@ -245,7 +246,7 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService { likes: post.likes + 1, likedBy: post.likedBy?..add(userId), ); - _posts = _posts + posts = posts .map( (p) => p.id == post.id ? updatedPost : p, ) @@ -266,7 +267,7 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService { likes: post.likes - 1, likedBy: post.likedBy?..remove(userId), ); - _posts = _posts + posts = posts .map( (p) => p.id == post.id ? updatedPost : p, ) @@ -309,7 +310,7 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService { 'reaction': FieldValue.increment(1), 'reactions': FieldValue.arrayUnion([updatedReaction.toJson()]), }); - _posts = _posts + posts = posts .map( (p) => p.id == post.id ? updatedPost : p, ) @@ -317,4 +318,34 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService { notifyListeners(); return updatedPost; } + + CollectionReference get _userCollection => _db + .collection(_options.usersCollectionName) + .withConverter( + fromFirestore: (snapshot, _) => FirebaseUserDocument.fromJson( + snapshot.data()!, + snapshot.id, + ), + toFirestore: (user, _) => user.toJson(), + ); + @override + Future getUser(String userId) async { + if (_users.containsKey(userId)) { + return _users[userId]!; + } + var data = (await _userCollection.doc(userId).get()).data(); + + var user = data == null + ? TimelinePosterUserModel(userId: userId) + : TimelinePosterUserModel( + userId: userId, + firstName: data.firstName, + lastName: data.lastName, + imageUrl: data.imageUrl, + ); + + _users[userId] = user; + + return user; + } } diff --git a/packages/flutter_timeline_firebase/pubspec.yaml b/packages/flutter_timeline_firebase/pubspec.yaml index 39d238d..cefbac5 100644 --- a/packages/flutter_timeline_firebase/pubspec.yaml +++ b/packages/flutter_timeline_firebase/pubspec.yaml @@ -4,7 +4,7 @@ name: flutter_timeline_firebase description: Implementation of the Flutter Timeline interface for Firebase. -version: 1.0.0 +version: 2.0.0 publish_to: none @@ -23,7 +23,7 @@ dependencies: git: url: https://github.com/Iconica-Development/flutter_timeline path: packages/flutter_timeline_interface - ref: 1.0.0 + ref: 2.0.0 dev_dependencies: flutter_lints: ^2.0.0 diff --git a/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart b/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart index 9676ad0..d0da25d 100644 --- a/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart +++ b/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart @@ -8,5 +8,6 @@ export 'src/model/timeline_category.dart'; export 'src/model/timeline_post.dart'; export 'src/model/timeline_poster.dart'; export 'src/model/timeline_reaction.dart'; +export 'src/services/filter_service.dart'; export 'src/services/timeline_service.dart'; export 'src/services/user_service.dart'; 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 430e14c..b88d1d8 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_category.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_category.dart @@ -3,13 +3,13 @@ import 'package:flutter/material.dart'; @immutable class TimelineCategory { const TimelineCategory({ - required this.name, + required this.key, required this.title, required this.icon, this.canCreate = true, this.canView = true, }); - final String name; + final String? key; final String title; final Widget icon; final bool canCreate; diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart index 1e08d71..df7b7e9 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart @@ -15,12 +15,12 @@ class TimelinePost { required this.id, required this.creatorId, required this.title, - required this.category, required this.content, required this.likes, required this.reaction, required this.createdAt, required this.reactionEnabled, + this.category, this.creator, this.likedBy, this.reactions, @@ -67,7 +67,7 @@ class TimelinePost { final String title; /// The category of the post on which can be filtered. - final String category; + final String? category; /// The url of the image of the post. final String? imageUrl; diff --git a/packages/flutter_timeline_interface/lib/src/services/filter_service.dart b/packages/flutter_timeline_interface/lib/src/services/filter_service.dart new file mode 100644 index 0000000..dc3441f --- /dev/null +++ b/packages/flutter_timeline_interface/lib/src/services/filter_service.dart @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2024 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; + +mixin TimelineFilterService on TimelineService { + List filterPosts( + String filterWord, + Map options, + ) { + var filteredPosts = posts + .where( + (post) => post.title.toLowerCase().contains( + filterWord.toLowerCase(), + ), + ) + .toList(); + + return filteredPosts; + } +} diff --git a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart index d872eb2..6f4907d 100644 --- a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart +++ b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart @@ -9,6 +9,8 @@ import 'package:flutter_timeline_interface/src/model/timeline_post.dart'; import 'package:flutter_timeline_interface/src/model/timeline_reaction.dart'; abstract class TimelineService with ChangeNotifier { + List posts = []; + Future deletePost(TimelinePost post); Future deletePostReaction(TimelinePost post, String reactionId); Future createPost(TimelinePost post); diff --git a/packages/flutter_timeline_interface/pubspec.yaml b/packages/flutter_timeline_interface/pubspec.yaml index 051b746..0530663 100644 --- a/packages/flutter_timeline_interface/pubspec.yaml +++ b/packages/flutter_timeline_interface/pubspec.yaml @@ -4,7 +4,7 @@ name: flutter_timeline_interface description: Interface for the service of the Flutter Timeline component -version: 1.0.0 +version: 2.0.0 publish_to: none diff --git a/packages/flutter_timeline_view/example/lib/main.dart b/packages/flutter_timeline_view/example/lib/main.dart deleted file mode 100644 index 568731b..0000000 --- a/packages/flutter_timeline_view/example/lib/main.dart +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -import 'package:flutter/material.dart'; - -void main() { - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Timeline Example', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - useMaterial3: true, - ), - home: const MyHomePage(), - ); - } -} - -class MyHomePage extends StatelessWidget { - const MyHomePage({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - ), - body: const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'You have pushed the button this many times:', - ), - ], - ), - ), - ); - } -} diff --git a/packages/flutter_timeline_view/example/pubspec.yaml b/packages/flutter_timeline_view/example/pubspec.yaml deleted file mode 100644 index b8097d1..0000000 --- a/packages/flutter_timeline_view/example/pubspec.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: example -description: Flutter timeline example -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -version: 1.0.0+1 - -environment: - sdk: '>=3.1.3 <4.0.0' - -dependencies: - flutter: - sdk: flutter - cupertino_icons: ^1.0.2 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -flutter: - uses-material-design: true diff --git a/packages/flutter_timeline_view/example/test/widget_test.dart b/packages/flutter_timeline_view/example/test/widget_test.dart deleted file mode 100644 index 73b773e..0000000 --- a/packages/flutter_timeline_view/example/test/widget_test.dart +++ /dev/null @@ -1,14 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('blank test', () { - expect(true, isTrue); - }); -} diff --git a/packages/flutter_timeline_view/lib/flutter_timeline_view.dart b/packages/flutter_timeline_view/lib/flutter_timeline_view.dart index d0b2f63..88cdb20 100644 --- a/packages/flutter_timeline_view/lib/flutter_timeline_view.dart +++ b/packages/flutter_timeline_view/lib/flutter_timeline_view.dart @@ -12,4 +12,6 @@ export 'src/screens/timeline_post_creation_screen.dart'; export 'src/screens/timeline_post_screen.dart'; export 'src/screens/timeline_screen.dart'; export 'src/screens/timeline_selection_screen.dart'; +export 'src/widgets/category_selector.dart'; +export 'src/widgets/category_selector_button.dart'; export 'src/widgets/timeline_post_widget.dart'; diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart index 42e390a..18715b4 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2023 Iconica // // SPDX-License-Identifier: BSD-3-Clause +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_image_picker/flutter_image_picker.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; @@ -8,7 +9,6 @@ import 'package:flutter_timeline_view/src/config/timeline_theme.dart'; import 'package:flutter_timeline_view/src/config/timeline_translations.dart'; import 'package:intl/intl.dart'; -@immutable class TimelineOptions { const TimelineOptions({ this.theme = const TimelineTheme(), @@ -18,21 +18,38 @@ class TimelineOptions { this.timelinePostHeight, this.allowAllDeletion = false, this.sortCommentsAscending = true, - this.sortPostsAscending = false, - this.dateformat, + this.sortPostsAscending, + this.doubleTapTolike = false, + this.iconsWithValues = false, + this.likeAndDislikeIconsForDoubleTap = const ( + Icon( + Icons.favorite_rounded, + color: Color(0xFFC3007A), + ), + null, + ), + this.itemInfoBuilder, + this.dateFormat, this.timeFormat, this.buttonBuilder, this.textInputBuilder, + this.dividerBuilder, this.userAvatarBuilder, this.anonymousAvatarBuilder, this.nameBuilder, + this.padding = const EdgeInsets.symmetric(vertical: 12.0), + this.iconSize = 26, + this.postWidgetHeight, + this.postPadding = const EdgeInsets.all(12.0), + this.filterOptions = const FilterOptions(), + this.categoriesOptions = const CategoriesOptions(), }); /// Theming options for the timeline final TimelineTheme theme; /// The format to display the post date in - final DateFormat? dateformat; + final DateFormat? dateFormat; /// The format to display the post time in final DateFormat? timeFormat; @@ -41,7 +58,7 @@ class TimelineOptions { final bool sortCommentsAscending; /// Whether to sort posts ascending or descending - final bool sortPostsAscending; + final bool? sortPostsAscending; /// Allow all posts to be deleted instead of /// only the posts of the current user @@ -71,6 +88,97 @@ class TimelineOptions { /// ImagePickerConfig can be used to define the /// size and quality for the uploaded image. final ImagePickerConfig imagePickerConfig; + + /// Whether to allow double tap to like + final bool doubleTapTolike; + + /// The icons to display when double tap to like is enabled + final (Icon?, Icon?) likeAndDislikeIconsForDoubleTap; + + /// Whether to display the icons with values + final bool iconsWithValues; + + /// The builder for the item info, all below the like and comment buttons + final Widget Function({required TimelinePost post})? itemInfoBuilder; + + /// The builder for the divider + final Widget Function()? dividerBuilder; + + /// The padding between posts in the timeline + final EdgeInsets padding; + + /// Size of icons like the comment and like icons. Dafualts to 26 + final double iconSize; + + /// Sets a predefined height for the postWidget. + final double? postWidgetHeight; + + /// Padding of each post + final EdgeInsets postPadding; + + /// Options for filtering + final FilterOptions filterOptions; + + /// Options for using the category selector. + final CategoriesOptions categoriesOptions; +} + +class CategoriesOptions { + const CategoriesOptions({ + this.categoriesBuilder, + 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({ + required String? categoryKey, + required String categoryName, + required Function onTap, + required bool selected, + })? categoryButtonBuilder; + + /// Overides the standard horizontal padding of the whole category selector. + final double? categorySelectorHorizontalPadding; + + TimelineCategory? getCategoryByKey( + BuildContext context, + String? key, + ) { + if (categoriesBuilder == null) { + return null; + } + + return categoriesBuilder! + .call(context) + .firstWhereOrNull((category) => category.key == key); + } +} + +class FilterOptions { + const FilterOptions({ + this.initialFilterWord, + this.searchBarBuilder, + this.onFilterEnabledChange, + }); + + /// Set a value to search through posts. When set the searchbar is shown. + /// If null no searchbar is shown. + final String? initialFilterWord; + + // Possibilty to override the standard search bar. + final Widget Function( + Future> Function( + String filterWord, + ) search, + )? searchBarBuilder; + + final void Function({required bool filterEnabled})? onFilterEnabledChange; } typedef ButtonBuilder = Widget Function( 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 f01b1bc..f43a3a4 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart @@ -28,6 +28,7 @@ class TimelineTranslations { required this.postAt, required this.postLoadingError, required this.timelineSelectionDescription, + required this.searchHint, }); const TimelineTranslations.empty() @@ -52,7 +53,8 @@ class TimelineTranslations { writeComment = 'Write your comment here...', postAt = 'at', postLoadingError = 'Something went wrong while loading the post', - timelineSelectionDescription = 'Choose a category'; + timelineSelectionDescription = 'Choose a category', + searchHint = 'Search...'; final String noPosts; final String noPostsWithFilter; @@ -79,6 +81,8 @@ class TimelineTranslations { final String timelineSelectionDescription; + final String searchHint; + TimelineTranslations copyWith({ String? noPosts, String? noPostsWithFilter, @@ -101,6 +105,7 @@ class TimelineTranslations { String? firstComment, String? postLoadingError, String? timelineSelectionDescription, + String? searchHint, }) => TimelineTranslations( noPosts: noPosts ?? this.noPosts, @@ -127,5 +132,6 @@ class TimelineTranslations { postLoadingError: postLoadingError ?? this.postLoadingError, timelineSelectionDescription: timelineSelectionDescription ?? this.timelineSelectionDescription, + searchHint: searchHint ?? this.searchHint, ); } 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 ae01025..2238f4e 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 @@ -13,17 +13,16 @@ import 'package:flutter_timeline_view/src/config/timeline_options.dart'; class TimelinePostCreationScreen extends StatefulWidget { const TimelinePostCreationScreen({ required this.userId, - required this.postCategory, required this.onPostCreated, required this.service, required this.options, - this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + this.postCategory, super.key, }); final String userId; - final String postCategory; + final String? postCategory; /// called when the post is created final Function(TimelinePost) onPostCreated; @@ -34,9 +33,6 @@ class TimelinePostCreationScreen extends StatefulWidget { /// The options for the timeline final TimelineOptions options; - /// The padding around the screen - final EdgeInsets padding; - @override State createState() => _TimelinePostCreationScreenState(); @@ -92,173 +88,176 @@ class _TimelinePostCreationScreenState var theme = Theme.of(context); return Padding( - padding: widget.padding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.options.translations.title, - style: theme.textTheme.displaySmall, - ), - widget.options.textInputBuilder?.call( - titleController, - null, - '', - ) ?? - TextField( - controller: titleController, - ), - const SizedBox(height: 16), - Text( - widget.options.translations.content, - style: theme.textTheme.displaySmall, - ), - const SizedBox(height: 4), - Text( - widget.options.translations.contentDescription, - style: theme.textTheme.bodyMedium, - ), - // input field for the content - SizedBox( - height: 100, - child: TextField( - controller: contentController, - textCapitalization: TextCapitalization.sentences, - expands: true, - maxLines: null, - minLines: null, + padding: widget.options.padding, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.options.translations.title, + style: theme.textTheme.displaySmall, ), - ), - const SizedBox( - height: 16, - ), - // input field for the content - Text( - widget.options.translations.uploadImage, - style: theme.textTheme.displaySmall, - ), - 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: Colors.black, - child: ImagePicker( - imagePickerConfig: widget.options.imagePickerConfig, - imagePickerTheme: widget.options.imagePickerTheme, - ), - ), - ); - if (result != null) { - setState(() { - image = result; - }); - } - checkIfEditingDone(); - }, - child: image != null - ? ClipRRect( - borderRadius: BorderRadius.circular(8.0), - child: Image.memory( - image!, - width: double.infinity, - height: 150.0, - fit: BoxFit.cover, - // give it a rounded border + widget.options.textInputBuilder?.call( + titleController, + null, + '', + ) ?? + TextField( + controller: titleController, + ), + const SizedBox(height: 16), + Text( + widget.options.translations.content, + style: theme.textTheme.displaySmall, + ), + const SizedBox(height: 4), + Text( + widget.options.translations.contentDescription, + style: theme.textTheme.bodyMedium, + ), + // input field for the content + SizedBox( + height: 100, + child: TextField( + controller: contentController, + textCapitalization: TextCapitalization.sentences, + expands: true, + maxLines: null, + minLines: null, + ), + ), + const SizedBox( + height: 16, + ), + // input field for the content + Text( + widget.options.translations.uploadImage, + style: theme.textTheme.displaySmall, + ), + 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.background, + child: ImagePicker( + imagePickerConfig: widget.options.imagePickerConfig, + imagePickerTheme: widget.options.imagePickerTheme, ), - ) - : DottedBorder( - 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: 32, + ), + ); + if (result != null) { + setState(() { + image = result; + }); + } + checkIfEditingDone(); + }, + child: image != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: Image.memory( + image!, + width: double.infinity, + height: 150.0, + fit: BoxFit.cover, + // give it a rounded border + ), + ) + : DottedBorder( + 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: 32, + ), ), ), - ), - ), - // 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, - ), - ), - ), ), - ], - ], - ), - - const SizedBox(height: 16), - - Text( - widget.options.translations.commentsTitle, - style: theme.textTheme.displaySmall, - ), - Text( - widget.options.translations.allowCommentsDescription, - style: theme.textTheme.bodyMedium, - ), - // radio buttons for yes or no - Switch( - value: allowComments, - onChanged: (newValue) { - setState(() { - allowComments = newValue; - }); - }, - ), - const Spacer(), - Align( - alignment: Alignment.bottomCenter, - child: (widget.options.buttonBuilder != null) - ? widget.options.buttonBuilder!( - context, - onPostCreated, - widget.options.translations.checkPost, - enabled: editingDone, - ) - : ElevatedButton( - onPressed: editingDone ? onPostCreated : null, - child: Text( - widget.options.translations.checkPost, - style: theme.textTheme.bodyMedium, + // 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, + ), + ), ), ), - ), - ], + ], + ], + ), + + const SizedBox(height: 16), + + Text( + widget.options.translations.commentsTitle, + style: theme.textTheme.displaySmall, + ), + Text( + widget.options.translations.allowCommentsDescription, + style: theme.textTheme.bodyMedium, + ), + // radio buttons for yes or no + Switch( + value: allowComments, + onChanged: (newValue) { + setState(() { + allowComments = newValue; + }); + }, + ), + // const Spacer(), + Align( + alignment: Alignment.bottomCenter, + child: (widget.options.buttonBuilder != null) + ? widget.options.buttonBuilder!( + context, + onPostCreated, + widget.options.translations.checkPost, + enabled: editingDone, + ) + : ElevatedButton( + onPressed: editingDone ? onPostCreated : null, + child: Text( + widget.options.translations.checkPost, + style: theme.textTheme.bodyMedium, + ), + ), + ), + ], + ), ), ); } 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 6082096..467da65 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 @@ -12,18 +12,17 @@ import 'package:flutter_image_picker/flutter_image_picker.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; import 'package:flutter_timeline_view/src/config/timeline_options.dart'; import 'package:flutter_timeline_view/src/widgets/reaction_bottom.dart'; +import 'package:flutter_timeline_view/src/widgets/tappable_image.dart'; import 'package:intl/intl.dart'; -class TimelinePostScreen extends StatefulWidget { +class TimelinePostScreen extends StatelessWidget { const TimelinePostScreen({ required this.userId, required this.service, - required this.userService, required this.options, required this.post, required this.onPostDelete, this.onUserTap, - this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 16), super.key, }); @@ -33,31 +32,70 @@ class TimelinePostScreen extends StatefulWidget { /// The timeline service to fetch the post details final TimelineService service; - /// The user service to fetch the profile picture of the user - final TimelineUserService userService; - /// Options to configure the timeline screens final TimelineOptions options; /// The post to show final TimelinePost post; - /// The padding around the screen - final EdgeInsets padding; - /// If this is not null, the user can tap on the user avatar or name final Function(String userId)? onUserTap; final VoidCallback onPostDelete; @override - State createState() => _TimelinePostScreenState(); + Widget build(BuildContext context) => Scaffold( + body: _TimelinePostScreen( + userId: userId, + service: service, + options: options, + post: post, + onPostDelete: onPostDelete, + onUserTap: onUserTap, + ), + ); } -class _TimelinePostScreenState extends State { +class _TimelinePostScreen extends StatefulWidget { + const _TimelinePostScreen({ + required this.userId, + required this.service, + required this.options, + required this.post, + required this.onPostDelete, + this.onUserTap, + }); + + final String userId; + + final TimelineService service; + + final TimelineOptions options; + + final TimelinePost post; + + final Function(String userId)? onUserTap; + + final VoidCallback onPostDelete; + + @override + State<_TimelinePostScreen> createState() => _TimelinePostScreenState(); +} + +class _TimelinePostScreenState extends State<_TimelinePostScreen> { 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, + ), + ); + @override void initState() { super.initState(); @@ -90,11 +128,12 @@ class _TimelinePostScreenState extends State { @override Widget build(BuildContext context) { var theme = Theme.of(context); - var dateFormat = widget.options.dateformat ?? + var dateFormat = widget.options.dateFormat ?? DateFormat('dd/MM/yyyy', Localizations.localeOf(context).languageCode); var timeFormat = widget.options.timeFormat ?? DateFormat('HH:mm'); + if (isLoading) { - return const Center( + const Center( child: CircularProgressIndicator(), ); } @@ -127,7 +166,7 @@ class _TimelinePostScreenState extends State { }, child: SingleChildScrollView( child: Padding( - padding: widget.padding, + padding: widget.options.padding, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -182,12 +221,7 @@ class _TimelinePostScreenState extends State { if (widget.options.allowAllDeletion || post.creator?.userId == widget.userId) PopupMenuButton( - onSelected: (value) async { - if (value == 'delete') { - await widget.service.deletePost(post); - widget.onPostDelete(); - } - }, + onSelected: (value) => widget.onPostDelete(), itemBuilder: (BuildContext context) => >[ PopupMenuItem( @@ -223,11 +257,40 @@ class _TimelinePostScreenState extends State { const SizedBox(height: 8), ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(8)), - child: CachedNetworkImage( - width: double.infinity, - imageUrl: post.imageUrl!, - fit: BoxFit.fitHeight, - ), + child: widget.options.doubleTapTolike + ? TappableImage( + likeAndDislikeIcon: widget + .options.likeAndDislikeIconsForDoubleTap, + post: post, + userId: widget.userId, + onLike: ({required bool liked}) async { + var userId = widget.userId; + + late TimelinePost result; + + if (!liked) { + result = await widget.service.likePost( + userId, + post, + ); + } else { + result = await widget.service.unlikePost( + userId, + post, + ); + } + + await loadPostDetails(); + + return result.likedBy?.contains(userId) ?? + false; + }, + ) + : CachedNetworkImage( + width: double.infinity, + imageUrl: post.imageUrl!, + fit: BoxFit.fitHeight, + ), ), ], const SizedBox( @@ -246,11 +309,14 @@ class _TimelinePostScreenState extends State { ), ); }, - child: widget.options.theme.likedIcon ?? - Icon( - Icons.thumb_up_rounded, - color: widget.options.theme.iconColor, - ), + child: Container( + color: Colors.transparent, + child: widget.options.theme.likedIcon ?? + Icon( + Icons.thumb_up_rounded, + color: widget.options.theme.iconColor, + ), + ), ), ] else ...[ InkWell( @@ -262,11 +328,15 @@ class _TimelinePostScreenState extends State { ), ); }, - child: widget.options.theme.likeIcon ?? - Icon( - Icons.thumb_up_alt_outlined, - color: widget.options.theme.iconColor, - ), + child: Container( + color: Colors.transparent, + child: widget.options.theme.likeIcon ?? + Icon( + Icons.thumb_up_alt_outlined, + color: widget.options.theme.iconColor, + size: widget.options.iconSize, + ), + ), ), ], const SizedBox(width: 8), @@ -275,6 +345,7 @@ class _TimelinePostScreenState extends State { Icon( Icons.chat_bubble_outline_rounded, color: widget.options.theme.iconColor, + size: widget.options.iconSize, ), ], ), @@ -481,14 +552,14 @@ class _TimelinePostScreenState extends State { Align( alignment: Alignment.bottomCenter, child: ReactionBottom( - messageInputBuilder: widget.options.textInputBuilder!, + messageInputBuilder: textInputBuilder, onPressSelectImage: () async { // open the image picker var result = await showModalBottomSheet( context: context, builder: (context) => Container( padding: const EdgeInsets.all(8.0), - color: Colors.black, + color: theme.colorScheme.background, child: ImagePicker( imagePickerConfig: widget.options.imagePickerConfig, imagePickerTheme: widget.options.imagePickerTheme, 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 cf0f214..d367d87 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart @@ -6,20 +6,20 @@ import 'dart:async'; 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/timeline_post_widget.dart'; +import 'package:flutter_timeline_view/flutter_timeline_view.dart'; class TimelineScreen extends StatefulWidget { const TimelineScreen({ required this.userId, + required this.service, required this.options, required this.onPostTap, - required this.service, + this.scrollController, this.onUserTap, this.posts, - this.controller, - this.timelineCategoryFilter, - this.padding = const EdgeInsets.symmetric(vertical: 12.0), + this.timelineCategory, + this.postWidgetBuilder, + this.filterEnabled = false, super.key, }); @@ -33,10 +33,10 @@ class TimelineScreen extends StatefulWidget { final TimelineOptions options; /// The controller for the scroll view - final ScrollController? controller; + final ScrollController? scrollController; /// The string to filter the timeline by category - final String? timelineCategoryFilter; + final String? timelineCategory; /// This is used if you want to pass in a list of posts instead /// of fetching them from the service @@ -48,8 +48,11 @@ class TimelineScreen extends StatefulWidget { /// If this is not null, the user can tap on the user avatar or name final Function(String userId)? onUserTap; - /// The padding between posts in the timeline - final EdgeInsets padding; + /// Override the standard postwidget + final Widget Function(TimelinePost post)? postWidgetBuilder; + + /// if true the filter textfield is enabled. + final bool filterEnabled; @override State createState() => _TimelineScreenState(); @@ -57,12 +60,21 @@ class TimelineScreen extends StatefulWidget { class _TimelineScreenState extends State { late ScrollController controller; + late var textFieldController = TextEditingController( + text: widget.options.filterOptions.initialFilterWord, + ); + late var service = widget.service; + bool isLoading = true; + late var category = widget.timelineCategory; + + late var filterWord = widget.options.filterOptions.initialFilterWord; + @override void initState() { super.initState(); - controller = widget.controller ?? ScrollController(); + controller = widget.scrollController ?? ScrollController(); unawaited(loadPosts()); } @@ -74,60 +86,158 @@ class _TimelineScreenState extends State { // Build the list of posts return ListenableBuilder( - listenable: widget.service, + listenable: service, builder: (context, _) { - var posts = widget.posts ?? - widget.service.getPosts(widget.timelineCategoryFilter); + var posts = widget.posts ?? service.getPosts(category); + + if (widget.filterEnabled && filterWord != null) { + if (service is TimelineFilterService?) { + posts = + (service as TimelineFilterService).filterPosts(filterWord!, {}); + } else { + debugPrint('Timeline service needs to mixin' + ' with TimelineFilterService'); + } + } + posts = posts .where( - (p) => - widget.timelineCategoryFilter == null || - p.category == widget.timelineCategoryFilter, + (p) => category == null || p.category == category, ) .toList(); // sort posts by date - posts.sort( - (a, b) => widget.options.sortPostsAscending - ? a.createdAt.compareTo(b.createdAt) - : b.createdAt.compareTo(a.createdAt), - ); - return SingleChildScrollView( - controller: controller, - child: Column( - children: [ - ...posts.map( - (post) => Padding( - padding: widget.padding, - child: TimelinePostWidget( - userId: widget.userId, - options: widget.options, - post: post, - height: widget.options.timelinePostHeight, - onTap: () => widget.onPostTap.call(post), - onTapLike: () async => - widget.service.likePost(widget.userId, post), - onTapUnlike: () async => - widget.service.unlikePost(widget.userId, post), - onPostDelete: () async => widget.service.deletePost(post), - onUserTap: widget.onUserTap, - ), + if (widget.options.sortPostsAscending != null) { + posts.sort( + (a, b) => widget.options.sortPostsAscending! + ? a.createdAt.compareTo(b.createdAt) + : b.createdAt.compareTo(a.createdAt), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: widget.options.padding.top, + ), + if (widget.filterEnabled) ...[ + Padding( + padding: EdgeInsets.symmetric( + horizontal: widget.options.padding.horizontal, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: textFieldController, + onChanged: (value) { + setState(() { + filterWord = value; + }); + }, + decoration: InputDecoration( + hintText: widget.options.translations.searchHint, + suffixIconConstraints: + const BoxConstraints(maxHeight: 14), + contentPadding: const EdgeInsets.only( + left: 12, + right: 12, + bottom: -10, + ), + suffixIcon: const Padding( + padding: EdgeInsets.only(right: 12), + child: Icon(Icons.search), + ), + ), + ), + ), + const SizedBox( + width: 8, + ), + InkWell( + onTap: () { + setState(() { + textFieldController.clear(); + filterWord = null; + widget.options.filterOptions.onFilterEnabledChange + ?.call(filterEnabled: false); + }); + }, + child: const Padding( + padding: EdgeInsets.all(8), + child: Icon( + Icons.close, + color: Color(0xFF000000), + ), + ), + ), + ], ), ), - if (posts.isEmpty) - Center( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - widget.timelineCategoryFilter == null - ? widget.options.translations.noPosts - : widget.options.translations.noPostsWithFilter, - style: widget.options.theme.textStyles.noPostsStyle, - ), - ), - ), + const SizedBox( + height: 24, + ), ], - ), + CategorySelector( + filter: category, + options: widget.options, + onTapCategory: (categoryKey) { + setState(() { + category = categoryKey; + }); + }, + ), + const SizedBox( + height: 12, + ), + Expanded( + child: SingleChildScrollView( + controller: controller, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...posts.map( + (post) => Padding( + padding: widget.options.postPadding, + child: widget.postWidgetBuilder?.call(post) ?? + TimelinePostWidget( + service: widget.service, + userId: widget.userId, + options: widget.options, + post: post, + onTap: () => widget.onPostTap(post), + onTapLike: () async => + service.likePost(widget.userId, post), + onTapUnlike: () async => + service.unlikePost(widget.userId, post), + onPostDelete: () async => + service.deletePost(post), + onUserTap: widget.onUserTap, + ), + ), + ), + if (posts.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + category == null + ? widget.options.translations.noPosts + : widget.options.translations.noPostsWithFilter, + style: widget.options.theme.textStyles.noPostsStyle, + ), + ), + ), + ], + ), + ), + ), + SizedBox( + height: widget.options.padding.bottom, + ), + ], ); }, ); @@ -136,7 +246,7 @@ class _TimelineScreenState extends State { Future loadPosts() async { if (widget.posts != null) return; try { - await widget.service.fetchPosts(widget.timelineCategoryFilter); + await service.fetchPosts(category); setState(() { isLoading = false; }); diff --git a/packages/flutter_timeline_view/lib/src/widgets/category_selector.dart b/packages/flutter_timeline_view/lib/src/widgets/category_selector.dart new file mode 100644 index 0000000..858d0b9 --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/widgets/category_selector.dart @@ -0,0 +1,60 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_timeline_view/flutter_timeline_view.dart'; + +class CategorySelector extends StatelessWidget { + const CategorySelector({ + required this.filter, + required this.options, + required this.onTapCategory, + super.key, + }); + + final String? filter; + final TimelineOptions options; + final void Function(String? categoryKey) onTapCategory; + + @override + Widget build(BuildContext context) { + if (options.categoriesOptions.categoriesBuilder == null) { + return const SizedBox.shrink(); + } + + var categories = options.categoriesOptions.categoriesBuilder!(context); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + SizedBox( + width: + options.categoriesOptions.categorySelectorHorizontalPadding ?? + max(options.padding.horizontal - 4, 0), + ), + for (var category in categories) ...[ + options.categoriesOptions.categoryButtonBuilder?.call( + categoryKey: category.key, + categoryName: category.title, + onTap: () => onTapCategory(category.key), + selected: filter == category.key, + ) ?? + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: CategorySelectorButton( + category: category, + selected: filter == category.key, + onTap: () => onTapCategory(category.key), + ), + ), + ], + SizedBox( + width: + options.categoriesOptions.categorySelectorHorizontalPadding ?? + max(options.padding.horizontal - 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 new file mode 100644 index 0000000..3245303 --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/widgets/category_selector_button.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; + +class CategorySelectorButton extends StatelessWidget { + const CategorySelectorButton({ + required this.category, + required this.selected, + required this.onTap, + super.key, + }); + + final TimelineCategory category; + final bool selected; + final void Function() onTap; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + return TextButton( + onPressed: onTap, + style: ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const MaterialStatePropertyAll( + EdgeInsets.symmetric( + vertical: 5, + horizontal: 12, + ), + ), + minimumSize: const MaterialStatePropertyAll(Size.zero), + backgroundColor: MaterialStatePropertyAll( + selected ? theme.colorScheme.primary : theme.colorScheme.surface, + ), + shape: const MaterialStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(45), + ), + ), + ), + ), + child: Text( + category.title, + style: theme.textTheme.labelMedium?.copyWith( + color: selected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + ), + ), + ); + } +} diff --git a/packages/flutter_timeline_view/lib/src/widgets/tappable_image.dart b/packages/flutter_timeline_view/lib/src/widgets/tappable_image.dart new file mode 100644 index 0000000..24fe999 --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/widgets/tappable_image.dart @@ -0,0 +1,168 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; + +class TappableImage extends StatefulWidget { + const TappableImage({ + required this.post, + required this.onLike, + required this.userId, + required this.likeAndDislikeIcon, + super.key, + }); + + final TimelinePost post; + final String userId; + final Future Function({required bool liked}) onLike; + final (Icon?, Icon?) likeAndDislikeIcon; + + @override + State createState() => _TappableImageState(); +} + +class _TappableImageState extends State + with SingleTickerProviderStateMixin { + late AnimationController animationController; + late Animation animation; + bool loading = false; + + @override + void initState() { + super.initState(); + + animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), + ); + + animation = CurvedAnimation( + parent: animationController, + curve: Curves.ease, + ); + + animationController.addListener(listener); + } + + void listener() { + setState(() {}); + } + + @override + void dispose() { + animationController.removeListener(listener); + animationController.dispose(); + super.dispose(); + } + + void startAnimation() { + animationController.forward(); + } + + void reverseAnimation() { + animationController.reverse(); + } + + @override + Widget build(BuildContext context) => InkWell( + onDoubleTap: () async { + if (loading) { + return; + } + loading = true; + await animationController.forward(); + + var liked = await widget.onLike( + liked: widget.post.likedBy?.contains( + widget.userId, + ) ?? + false, + ); + + if (context.mounted) { + await showDialog( + barrierDismissible: false, + barrierColor: Colors.transparent, + context: context, + builder: (context) => HeartAnimation( + duration: const Duration(milliseconds: 200), + liked: liked, + likeAndDislikeIcon: widget.likeAndDislikeIcon, + ), + ); + } + await animationController.reverse(); + loading = false; + }, + child: Transform.translate( + offset: Offset(0, animation.value * -32), + child: Transform.scale( + scale: 1 + animation.value * 0.1, + child: CachedNetworkImage( + imageUrl: widget.post.imageUrl ?? '', + width: double.infinity, + fit: BoxFit.fitHeight, + ), + ), + ), + ); +} + +class HeartAnimation extends StatefulWidget { + const HeartAnimation({ + required this.duration, + required this.liked, + required this.likeAndDislikeIcon, + super.key, + }); + + final Duration duration; + final bool liked; + final (Icon?, Icon?) likeAndDislikeIcon; + + @override + State createState() => _HeartAnimationState(); +} + +class _HeartAnimationState extends State { + late bool active; + + @override + void initState() { + super.initState(); + active = widget.liked; + unawaited( + Future.delayed(const Duration(milliseconds: 100)).then((value) async { + active = widget.liked; + var navigator = Navigator.of(context); + await Future.delayed(widget.duration); + navigator.pop(); + }), + ); + } + + @override + Widget build(BuildContext context) => AnimatedOpacity( + opacity: widget.likeAndDislikeIcon.$1 != null && + widget.likeAndDislikeIcon.$2 != null + ? 1 + : active + ? 1 + : 0, + duration: widget.duration, + curve: Curves.decelerate, + child: AnimatedScale( + scale: widget.likeAndDislikeIcon.$1 != null && + widget.likeAndDislikeIcon.$2 != null + ? 10 + : active + ? 10 + : 1, + duration: widget.duration, + child: active + ? widget.likeAndDislikeIcon.$1 + : widget.likeAndDislikeIcon.$2, + ), + ); +} 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 88a21e6..513fd89 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 @@ -6,17 +6,18 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; import 'package:flutter_timeline_view/src/config/timeline_options.dart'; +import 'package:flutter_timeline_view/src/widgets/tappable_image.dart'; -class TimelinePostWidget extends StatelessWidget { +class TimelinePostWidget extends StatefulWidget { const TimelinePostWidget({ required this.userId, required this.options, required this.post, - required this.height, required this.onTap, required this.onTapLike, required this.onTapUnlike, required this.onPostDelete, + required this.service, this.onUserTap, super.key, }); @@ -28,49 +29,57 @@ class TimelinePostWidget extends StatelessWidget { final TimelinePost post; /// Optional max height of the post - final double? height; final VoidCallback onTap; final VoidCallback onTapLike; final VoidCallback onTapUnlike; final VoidCallback onPostDelete; + final TimelineService service; /// If this is not null, the user can tap on the user avatar or name final Function(String userId)? onUserTap; + @override + State createState() => _TimelinePostWidgetState(); +} + +class _TimelinePostWidgetState extends State { @override Widget build(BuildContext context) { var theme = Theme.of(context); return InkWell( - onTap: onTap, + onTap: widget.onTap, child: SizedBox( - height: post.imageUrl != null ? height : null, + height: widget.post.imageUrl != null + ? widget.options.postWidgetHeight + : null, width: double.infinity, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - if (post.creator != null) + if (widget.post.creator != null) InkWell( - onTap: onUserTap != null - ? () => onUserTap?.call(post.creator!.userId) + onTap: widget.onUserTap != null + ? () => + widget.onUserTap?.call(widget.post.creator!.userId) : null, child: Row( children: [ - if (post.creator!.imageUrl != null) ...[ - options.userAvatarBuilder?.call( - post.creator!, + if (widget.post.creator!.imageUrl != null) ...[ + widget.options.userAvatarBuilder?.call( + widget.post.creator!, 40, ) ?? CircleAvatar( radius: 20, backgroundImage: CachedNetworkImageProvider( - post.creator!.imageUrl!, + widget.post.creator!.imageUrl!, ), ), ] else ...[ - options.anonymousAvatarBuilder?.call( - post.creator!, + widget.options.anonymousAvatarBuilder?.call( + widget.post.creator!, 40, ) ?? const CircleAvatar( @@ -82,22 +91,24 @@ class TimelinePostWidget extends StatelessWidget { ], const SizedBox(width: 10), Text( - options.nameBuilder?.call(post.creator) ?? - post.creator?.fullName ?? - options.translations.anonymousUser, - style: - options.theme.textStyles.postCreatorTitleStyle ?? - theme.textTheme.titleMedium, + 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 (options.allowAllDeletion || post.creator?.userId == userId) + if (widget.options.allowAllDeletion || + widget.post.creator?.userId == widget.userId) PopupMenuButton( onSelected: (value) { if (value == 'delete') { - onPostDelete(); + widget.onPostDelete(); } }, itemBuilder: (BuildContext context) => @@ -107,40 +118,67 @@ class TimelinePostWidget extends StatelessWidget { child: Row( children: [ Text( - options.translations.deletePost, - style: options.theme.textStyles.deletePostStyle ?? + widget.options.translations.deletePost, + style: widget.options.theme.textStyles + .deletePostStyle ?? theme.textTheme.bodyMedium, ), const SizedBox(width: 8), - options.theme.deleteIcon ?? + widget.options.theme.deleteIcon ?? Icon( Icons.delete, - color: options.theme.iconColor, + color: widget.options.theme.iconColor, ), ], ), ), ], - child: options.theme.moreIcon ?? + child: widget.options.theme.moreIcon ?? Icon( Icons.more_horiz_rounded, - color: options.theme.iconColor, + color: widget.options.theme.iconColor, ), ), ], ), // image of the post - if (post.imageUrl != null) ...[ + if (widget.post.imageUrl != null) ...[ const SizedBox(height: 8), Flexible( - flex: height != null ? 1 : 0, + flex: widget.options.postWidgetHeight != null ? 1 : 0, child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(8)), - child: CachedNetworkImage( - width: double.infinity, - imageUrl: post.imageUrl!, - fit: BoxFit.fitWidth, - ), + 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.likePost( + userId, + widget.post, + ); + } else { + result = await widget.service.unlikePost( + userId, + widget.post, + ); + } + + return result.likedBy?.contains(userId) ?? false; + }, + ) + : CachedNetworkImage( + width: double.infinity, + imageUrl: widget.post.imageUrl!, + fit: BoxFit.fitWidth, + ), ), ), ], @@ -148,69 +186,137 @@ class TimelinePostWidget extends StatelessWidget { height: 8, ), // post information - Row( - children: [ - if (post.likedBy?.contains(userId) ?? false) ...[ - InkWell( - onTap: onTapUnlike, - child: options.theme.likedIcon ?? + 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.likePost( + userId, + widget.post, + ); + } else { + await widget.service.unlikePost( + userId, + widget.post, + ); + } + }, + icon: widget.options.theme.likeIcon ?? Icon( - Icons.thumb_up_rounded, - color: options.theme.iconColor, - ), - ), - ] else ...[ - InkWell( - onTap: onTapLike, - child: options.theme.likeIcon ?? - Icon( - Icons.thumb_up_alt_outlined, - color: options.theme.iconColor, + widget.post.likedBy?.contains(widget.userId) ?? false + ? Icons.favorite + : Icons.favorite_outline_outlined, ), + 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}'), + ), ], - const SizedBox(width: 8), - if (post.reactionEnabled) - options.theme.commentIcon ?? - Icon( - Icons.chat_bubble_outline_rounded, - color: options.theme.iconColor, + ) + 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.thumb_up_rounded, + color: widget.options.theme.iconColor, + size: widget.options.iconSize, + ), ), - ], - ), + ), + ] else ...[ + InkWell( + onTap: widget.onTapLike, + child: Container( + color: Colors.transparent, + child: widget.options.theme.likedIcon ?? + Icon( + Icons.thumb_up_rounded, + 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, ), - Text( - '${post.likes} ${options.translations.likesTitle}', - style: options.theme.textStyles.listPostLikeTitleAndAmount ?? - theme.textTheme.titleSmall, - ), - const SizedBox(height: 4), - Text.rich( - TextSpan( - text: options.nameBuilder?.call(post.creator) ?? - post.creator?.fullName ?? - options.translations.anonymousUser, - style: options.theme.textStyles.listCreatorNameStyle ?? - theme.textTheme.titleSmall, - children: [ - const TextSpan(text: ' '), - TextSpan( - text: post.title, - style: options.theme.textStyles.listPostTitleStyle ?? - theme.textTheme.bodyMedium, - ), - ], + if (widget.options.itemInfoBuilder != null) ...[ + widget.options.itemInfoBuilder!( + post: widget.post, ), - ), - const SizedBox(height: 4), - Text( - options.translations.viewPost, - style: options.theme.textStyles.viewPostStyle ?? - theme.textTheme.bodySmall, - ), + ] 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: 4), + Text( + widget.options.translations.viewPost, + style: widget.options.theme.textStyles.viewPostStyle ?? + theme.textTheme.bodySmall, + ), + ], + if (widget.options.dividerBuilder != null) + widget.options.dividerBuilder!(), ], ), ), diff --git a/packages/flutter_timeline_view/pubspec.yaml b/packages/flutter_timeline_view/pubspec.yaml index 96ade42..3b1cbce 100644 --- a/packages/flutter_timeline_view/pubspec.yaml +++ b/packages/flutter_timeline_view/pubspec.yaml @@ -4,7 +4,7 @@ name: flutter_timeline_view description: Visual elements of the Flutter Timeline Component -version: 1.0.0 +version: 2.0.0 publish_to: none @@ -23,11 +23,12 @@ dependencies: git: url: https://github.com/Iconica-Development/flutter_timeline path: packages/flutter_timeline_interface - ref: 1.0.0 + ref: 2.0.0 flutter_image_picker: git: url: https://github.com/Iconica-Development/flutter_image_picker ref: 1.0.4 + collection: any dev_dependencies: flutter_lints: ^2.0.0