Merge pull request #9 from Iconica-Development/bugfix/feedback_fixes

Bugfix/feedback fixes
This commit is contained in:
Gorter-dev 2024-01-25 15:50:51 +01:00 committed by GitHub
commit 5c475d4de5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1973 additions and 565 deletions

133
README.md
View file

@ -1,5 +1,6 @@
# Flutter Timeline # 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) ![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 ## 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<GoRoute> 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: <RouteBase>[
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 ## Issues

View file

@ -27,7 +27,6 @@ migrate_working_dir/
.dart_tool/ .dart_tool/
.flutter-plugins .flutter-plugins
.flutter-plugins-dependencies .flutter-plugins-dependencies
.packages
.pub-cache/ .pub-cache/
.pub/ .pub/
/build/ /build/

View file

@ -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<GoRoute> 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,
),
);
}
}

View file

@ -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<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
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),
),
);
}
}

View file

@ -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<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
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();
},
),
),
),
);
},
),
),
);
}
}

View file

@ -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<PostScreen> createState() => _PostScreenState();
}
class _PostScreenState extends State<PostScreen> {
@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<String, TimelinePosterUserModel> _users = {
'test_user': const TimelinePosterUserModel(userId: 'test_user')
};
@override
Future<TimelinePosterUserModel?> getUser(String userId) async {
if (_users.containsKey(userId)) {
return _users[userId]!;
}
_users[userId] = TimelinePosterUserModel(userId: userId);
return TimelinePosterUserModel(userId: userId);
}
}

View file

@ -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,
),
);
}

View file

@ -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());
}

View file

@ -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<TimelinePost> posts = [];
@override
Future<TimelinePost> createPost(TimelinePost post) async {
posts.add(
post.copyWith(
creator: const TimelinePosterUserModel(userId: 'test_user'),
),
);
notifyListeners();
return post;
}
@override
Future<void> deletePost(TimelinePost post) async {
posts = posts.where((element) => element.id != post.id).toList();
notifyListeners();
}
@override
Future<TimelinePost> 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<TimelinePost> fetchPostDetails(TimelinePost post) async {
var reactions = post.reactions ?? [];
var updatedReactions = <TimelinePostReaction>[];
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<List<TimelinePost>> fetchPosts(String? category) async {
posts = getMockedPosts();
notifyListeners();
return posts;
}
@override
Future<List<TimelinePost>> fetchPostsPaginated(
String? category,
int limit,
) async {
notifyListeners();
return posts;
}
@override
Future<TimelinePost> fetchPost(TimelinePost post) async {
notifyListeners();
return post;
}
@override
Future<List<TimelinePost>> refreshPosts(String? category) async {
var newPosts = <TimelinePost>[];
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<TimelinePost> getPosts(String? category) => posts
.where((element) => category == null || element.category == category)
.toList();
@override
Future<TimelinePost> 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<TimelinePost> 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<TimelinePost> 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<TimelinePost> 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,
)
];
}
}

View file

@ -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

View file

@ -5,6 +5,7 @@
/// Flutter Timeline library /// Flutter Timeline library
library flutter_timeline; 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/flutter_timeline_userstory.dart';
export 'package:flutter_timeline/src/models/timeline_configuration.dart'; export 'package:flutter_timeline/src/models/timeline_configuration.dart';
export 'package:flutter_timeline/src/routes.dart'; export 'package:flutter_timeline/src/routes.dart';

View file

@ -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);
},
);

View file

@ -16,23 +16,25 @@ List<GoRoute> getTimelineStoryRoutes(
GoRoute( GoRoute(
path: TimelineUserStoryRoutes.timelineHome, path: TimelineUserStoryRoutes.timelineHome,
pageBuilder: (context, state) { pageBuilder: (context, state) {
var timelineFilter =
Container(); // TODO(anyone): create a filter widget
var timelineScreen = TimelineScreen( var timelineScreen = TimelineScreen(
userId: configuration.userId, userId: configuration.userId,
onUserTap: (user) => configuration.onUserTap?.call(context, user), onUserTap: (user) => configuration.onUserTap?.call(context, user),
service: configuration.service, service: configuration.service,
options: configuration.optionsBuilder(context), options: configuration.optionsBuilder(context),
onPostTap: (post) async => onPostTap: (post) async =>
TimelineUserStoryRoutes.timelineViewPath(post.id), configuration.onPostTap?.call(context, post) ??
timelineCategoryFilter: null, await context.push(
TimelineUserStoryRoutes.timelineViewPath(post.id),
),
filterEnabled: configuration.filterEnabled,
postWidgetBuilder: configuration.postWidgetBuilder,
); );
return buildScreenWithoutTransition( return buildScreenWithoutTransition(
context: context, context: context,
state: state, state: state,
child: configuration.mainPageBuilder?.call( child: configuration.openPageBuilder?.call(
context, context,
timelineFilter,
timelineScreen, timelineScreen,
) ?? ) ??
Scaffold( Scaffold(
@ -41,74 +43,27 @@ List<GoRoute> 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( GoRoute(
path: TimelineUserStoryRoutes.timelineView, path: TimelineUserStoryRoutes.timelineView,
pageBuilder: (context, state) { pageBuilder: (context, state) {
var post =
configuration.service.getPost(state.pathParameters['post']!)!;
var timelinePostWidget = TimelinePostScreen( var timelinePostWidget = TimelinePostScreen(
userId: configuration.userId, userId: configuration.userId,
options: configuration.optionsBuilder(context), options: configuration.optionsBuilder(context),
service: configuration.service, service: configuration.service,
userService: configuration.userService, post: post,
post: configuration.service.getPost(state.pathParameters['post']!)!, onPostDelete: () => configuration.onPostDelete?.call(context, post),
onPostDelete: () => context.pop(),
onUserTap: (user) => configuration.onUserTap?.call(context, user), onUserTap: (user) => configuration.onUserTap?.call(context, user),
); );
var category = configuration.categoriesBuilder(context).first;
return buildScreenWithoutTransition( return buildScreenWithoutTransition(
context: context, context: context,
state: state, state: state,
child: configuration.postScreenBuilder?.call( child: configuration.openPageBuilder?.call(
context, context,
timelinePostWidget, timelinePostWidget,
category,
) ?? ) ??
Scaffold( Scaffold(
body: timelinePostWidget, body: timelinePostWidget,

View file

@ -9,42 +9,35 @@ import 'package:flutter_timeline_view/flutter_timeline_view.dart';
@immutable @immutable
class TimelineUserStoryConfiguration { class TimelineUserStoryConfiguration {
const TimelineUserStoryConfiguration({ const TimelineUserStoryConfiguration({
required this.categoriesBuilder,
required this.optionsBuilder,
required this.userId, required this.userId,
required this.service, required this.service,
required this.userService, required this.userService,
this.mainPageBuilder, required this.optionsBuilder,
this.postScreenBuilder, this.openPageBuilder,
this.postCreationScreenBuilder, this.onPostTap,
this.postSelectionScreenBuilder,
this.onUserTap, this.onUserTap,
this.onPostDelete,
this.filterEnabled = false,
this.postWidgetBuilder,
}); });
final String userId; 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 TimelineService service;
final TimelineUserService userService; final TimelineUserService userService;
final TimelineOptions Function(BuildContext context) optionsBuilder; final TimelineOptions Function(BuildContext context) optionsBuilder;
final List<TimelineCategory> 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;
} }

View file

@ -4,10 +4,6 @@
mixin TimelineUserStoryRoutes { mixin TimelineUserStoryRoutes {
static const String timelineHome = '/timeline'; 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 const String timelineView = '/timeline-view/:post';
static String timelineViewPath(String postId) => '/timeline-view/$postId'; static String timelineViewPath(String postId) => '/timeline-view/$postId';
} }

View file

@ -3,7 +3,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
name: flutter_timeline name: flutter_timeline
description: Visual elements and interface combined into one package description: Visual elements and interface combined into one package
version: 1.0.0 version: 2.0.0
publish_to: none publish_to: none
@ -14,16 +14,18 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
go_router: any go_router: any
flutter_timeline_view: flutter_timeline_view:
git: git:
url: https://github.com/Iconica-Development/flutter_timeline url: https://github.com/Iconica-Development/flutter_timeline
path: packages/flutter_timeline_view path: packages/flutter_timeline_view
ref: 1.0.0 ref: 2.0.0
flutter_timeline_interface: flutter_timeline_interface:
git: git:
url: https://github.com/Iconica-Development/flutter_timeline url: https://github.com/Iconica-Development/flutter_timeline
path: packages/flutter_timeline_interface path: packages/flutter_timeline_interface
ref: 1.0.0 ref: 2.0.0
dev_dependencies: dev_dependencies:
flutter_lints: ^2.0.0 flutter_lints: ^2.0.0

View file

@ -9,10 +9,11 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_storage/firebase_storage.dart'; import 'package:firebase_storage/firebase_storage.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_timeline_firebase/src/config/firebase_timeline_options.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:flutter_timeline_interface/flutter_timeline_interface.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class FirebaseTimelineService with ChangeNotifier implements TimelineService { class FirebaseTimelineService extends TimelineService with TimelineUserService {
FirebaseTimelineService({ FirebaseTimelineService({
required TimelineUserService userService, required TimelineUserService userService,
FirebaseApp? app, FirebaseApp? app,
@ -30,7 +31,7 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService {
late TimelineUserService _userService; late TimelineUserService _userService;
late FirebaseTimelineOptions _options; late FirebaseTimelineOptions _options;
List<TimelinePost> _posts = []; final Map<String, TimelinePosterUserModel> _users = {};
@override @override
Future<TimelinePost> createPost(TimelinePost post) async { Future<TimelinePost> createPost(TimelinePost post) async {
@ -47,14 +48,14 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService {
var postRef = var postRef =
_db.collection(_options.timelineCollectionName).doc(updatedPost.id); _db.collection(_options.timelineCollectionName).doc(updatedPost.id);
await postRef.set(updatedPost.toJson()); await postRef.set(updatedPost.toJson());
_posts.add(updatedPost); posts.add(updatedPost);
notifyListeners(); notifyListeners();
return updatedPost; return updatedPost;
} }
@override @override
Future<void> deletePost(TimelinePost post) async { Future<void> 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); var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
await postRef.delete(); await postRef.delete();
notifyListeners(); notifyListeners();
@ -72,7 +73,7 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService {
reaction: post.reaction - 1, reaction: post.reaction - 1,
reactions: (post.reactions ?? [])..remove(reaction), reactions: (post.reactions ?? [])..remove(reaction),
); );
_posts = _posts posts = posts
.map( .map(
(p) => p.id == post.id ? updatedPost : p, (p) => p.id == post.id ? updatedPost : p,
) )
@ -102,7 +103,7 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService {
} }
} }
var updatedPost = post.copyWith(reactions: updatedReactions); 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(); notifyListeners();
return updatedPost; return updatedPost;
} }
@ -124,7 +125,7 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService {
var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user); var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user);
posts.add(post); posts.add(post);
} }
_posts = posts;
notifyListeners(); notifyListeners();
return posts; return posts;
} }
@ -135,12 +136,12 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService {
int limit, int limit,
) async { ) async {
// only take posts that are in our category // only take posts that are in our category
var oldestPost = _posts var oldestPost = posts
.where( .where(
(element) => category == null || element.category == category, (element) => category == null || element.category == category,
) )
.fold( .fold(
_posts.first, posts.first,
(previousValue, element) => (previousValue, element) =>
(previousValue.createdAt.isBefore(element.createdAt)) (previousValue.createdAt.isBefore(element.createdAt))
? previousValue ? previousValue
@ -161,16 +162,16 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService {
.limit(limit) .limit(limit)
.get(); .get();
// add the new posts to the list // add the new posts to the list
var posts = <TimelinePost>[]; var newPosts = <TimelinePost>[];
for (var doc in snapshot.docs) { for (var doc in snapshot.docs) {
var data = doc.data(); var data = doc.data();
var user = await _userService.getUser(data['creator_id']); var user = await _userService.getUser(data['creator_id']);
var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user); var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user);
posts.add(post); newPosts.add(post);
} }
_posts = [..._posts, ...posts]; posts = [...posts, ...newPosts];
notifyListeners(); notifyListeners();
return posts; return newPosts;
} }
@override @override
@ -185,7 +186,7 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService {
var updatedPost = TimelinePost.fromJson(doc.id, data).copyWith( var updatedPost = TimelinePost.fromJson(doc.id, data).copyWith(
creator: user, 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(); notifyListeners();
return updatedPost; return updatedPost;
} }
@ -193,12 +194,12 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService {
@override @override
Future<List<TimelinePost>> refreshPosts(String? category) async { Future<List<TimelinePost>> refreshPosts(String? category) async {
// fetch all posts between now and the newest posts we have // fetch all posts between now and the newest posts we have
var newestPostWeHave = _posts var newestPostWeHave = posts
.where( .where(
(element) => category == null || element.category == category, (element) => category == null || element.category == category,
) )
.fold( .fold(
_posts.first, posts.first,
(previousValue, element) => (previousValue, element) =>
(previousValue.createdAt.isAfter(element.createdAt)) (previousValue.createdAt.isAfter(element.createdAt))
? previousValue ? previousValue
@ -215,26 +216,26 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService {
.orderBy('created_at', descending: true) .orderBy('created_at', descending: true)
.endBefore([newestPostWeHave.createdAt]).get(); .endBefore([newestPostWeHave.createdAt]).get();
// add the new posts to the list // add the new posts to the list
var posts = <TimelinePost>[]; var newPosts = <TimelinePost>[];
for (var doc in snapshot.docs) { for (var doc in snapshot.docs) {
var data = doc.data(); var data = doc.data();
var user = await _userService.getUser(data['creator_id']); var user = await _userService.getUser(data['creator_id']);
var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user); var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user);
posts.add(post); newPosts.add(post);
} }
_posts = [...posts, ..._posts]; posts = [...posts, ...newPosts];
notifyListeners(); notifyListeners();
return posts; return newPosts;
} }
@override @override
TimelinePost? getPost(String postId) => TimelinePost? getPost(String postId) =>
(_posts.any((element) => element.id == postId)) (posts.any((element) => element.id == postId))
? _posts.firstWhere((element) => element.id == postId) ? posts.firstWhere((element) => element.id == postId)
: null; : null;
@override @override
List<TimelinePost> getPosts(String? category) => _posts List<TimelinePost> getPosts(String? category) => posts
.where((element) => category == null || element.category == category) .where((element) => category == null || element.category == category)
.toList(); .toList();
@ -245,7 +246,7 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService {
likes: post.likes + 1, likes: post.likes + 1,
likedBy: post.likedBy?..add(userId), likedBy: post.likedBy?..add(userId),
); );
_posts = _posts posts = posts
.map( .map(
(p) => p.id == post.id ? updatedPost : p, (p) => p.id == post.id ? updatedPost : p,
) )
@ -266,7 +267,7 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService {
likes: post.likes - 1, likes: post.likes - 1,
likedBy: post.likedBy?..remove(userId), likedBy: post.likedBy?..remove(userId),
); );
_posts = _posts posts = posts
.map( .map(
(p) => p.id == post.id ? updatedPost : p, (p) => p.id == post.id ? updatedPost : p,
) )
@ -309,7 +310,7 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService {
'reaction': FieldValue.increment(1), 'reaction': FieldValue.increment(1),
'reactions': FieldValue.arrayUnion([updatedReaction.toJson()]), 'reactions': FieldValue.arrayUnion([updatedReaction.toJson()]),
}); });
_posts = _posts posts = posts
.map( .map(
(p) => p.id == post.id ? updatedPost : p, (p) => p.id == post.id ? updatedPost : p,
) )
@ -317,4 +318,34 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService {
notifyListeners(); notifyListeners();
return updatedPost; return updatedPost;
} }
CollectionReference<FirebaseUserDocument> get _userCollection => _db
.collection(_options.usersCollectionName)
.withConverter<FirebaseUserDocument>(
fromFirestore: (snapshot, _) => FirebaseUserDocument.fromJson(
snapshot.data()!,
snapshot.id,
),
toFirestore: (user, _) => user.toJson(),
);
@override
Future<TimelinePosterUserModel?> 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;
}
} }

View file

@ -4,7 +4,7 @@
name: flutter_timeline_firebase name: flutter_timeline_firebase
description: Implementation of the Flutter Timeline interface for Firebase. description: Implementation of the Flutter Timeline interface for Firebase.
version: 1.0.0 version: 2.0.0
publish_to: none publish_to: none
@ -23,7 +23,7 @@ dependencies:
git: git:
url: https://github.com/Iconica-Development/flutter_timeline url: https://github.com/Iconica-Development/flutter_timeline
path: packages/flutter_timeline_interface path: packages/flutter_timeline_interface
ref: 1.0.0 ref: 2.0.0
dev_dependencies: dev_dependencies:
flutter_lints: ^2.0.0 flutter_lints: ^2.0.0

View file

@ -8,5 +8,6 @@ export 'src/model/timeline_category.dart';
export 'src/model/timeline_post.dart'; export 'src/model/timeline_post.dart';
export 'src/model/timeline_poster.dart'; export 'src/model/timeline_poster.dart';
export 'src/model/timeline_reaction.dart'; export 'src/model/timeline_reaction.dart';
export 'src/services/filter_service.dart';
export 'src/services/timeline_service.dart'; export 'src/services/timeline_service.dart';
export 'src/services/user_service.dart'; export 'src/services/user_service.dart';

View file

@ -3,13 +3,13 @@ import 'package:flutter/material.dart';
@immutable @immutable
class TimelineCategory { class TimelineCategory {
const TimelineCategory({ const TimelineCategory({
required this.name, required this.key,
required this.title, required this.title,
required this.icon, required this.icon,
this.canCreate = true, this.canCreate = true,
this.canView = true, this.canView = true,
}); });
final String name; final String? key;
final String title; final String title;
final Widget icon; final Widget icon;
final bool canCreate; final bool canCreate;

View file

@ -15,12 +15,12 @@ class TimelinePost {
required this.id, required this.id,
required this.creatorId, required this.creatorId,
required this.title, required this.title,
required this.category,
required this.content, required this.content,
required this.likes, required this.likes,
required this.reaction, required this.reaction,
required this.createdAt, required this.createdAt,
required this.reactionEnabled, required this.reactionEnabled,
this.category,
this.creator, this.creator,
this.likedBy, this.likedBy,
this.reactions, this.reactions,
@ -67,7 +67,7 @@ class TimelinePost {
final String title; final String title;
/// The category of the post on which can be filtered. /// The category of the post on which can be filtered.
final String category; final String? category;
/// The url of the image of the post. /// The url of the image of the post.
final String? imageUrl; final String? imageUrl;

View file

@ -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<TimelinePost> filterPosts(
String filterWord,
Map<String, dynamic> options,
) {
var filteredPosts = posts
.where(
(post) => post.title.toLowerCase().contains(
filterWord.toLowerCase(),
),
)
.toList();
return filteredPosts;
}
}

View file

@ -9,6 +9,8 @@ import 'package:flutter_timeline_interface/src/model/timeline_post.dart';
import 'package:flutter_timeline_interface/src/model/timeline_reaction.dart'; import 'package:flutter_timeline_interface/src/model/timeline_reaction.dart';
abstract class TimelineService with ChangeNotifier { abstract class TimelineService with ChangeNotifier {
List<TimelinePost> posts = [];
Future<void> deletePost(TimelinePost post); Future<void> deletePost(TimelinePost post);
Future<TimelinePost> deletePostReaction(TimelinePost post, String reactionId); Future<TimelinePost> deletePostReaction(TimelinePost post, String reactionId);
Future<TimelinePost> createPost(TimelinePost post); Future<TimelinePost> createPost(TimelinePost post);

View file

@ -4,7 +4,7 @@
name: flutter_timeline_interface name: flutter_timeline_interface
description: Interface for the service of the Flutter Timeline component description: Interface for the service of the Flutter Timeline component
version: 1.0.0 version: 2.0.0
publish_to: none publish_to: none

View file

@ -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: <Widget>[
Text(
'You have pushed the button this many times:',
),
],
),
),
);
}
}

View file

@ -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

View file

@ -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);
});
}

View file

@ -12,4 +12,6 @@ export 'src/screens/timeline_post_creation_screen.dart';
export 'src/screens/timeline_post_screen.dart'; export 'src/screens/timeline_post_screen.dart';
export 'src/screens/timeline_screen.dart'; export 'src/screens/timeline_screen.dart';
export 'src/screens/timeline_selection_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'; export 'src/widgets/timeline_post_widget.dart';

View file

@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: 2023 Iconica // SPDX-FileCopyrightText: 2023 Iconica
// //
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_image_picker/flutter_image_picker.dart'; import 'package:flutter_image_picker/flutter_image_picker.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.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:flutter_timeline_view/src/config/timeline_translations.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@immutable
class TimelineOptions { class TimelineOptions {
const TimelineOptions({ const TimelineOptions({
this.theme = const TimelineTheme(), this.theme = const TimelineTheme(),
@ -18,21 +18,38 @@ class TimelineOptions {
this.timelinePostHeight, this.timelinePostHeight,
this.allowAllDeletion = false, this.allowAllDeletion = false,
this.sortCommentsAscending = true, this.sortCommentsAscending = true,
this.sortPostsAscending = false, this.sortPostsAscending,
this.dateformat, this.doubleTapTolike = false,
this.iconsWithValues = false,
this.likeAndDislikeIconsForDoubleTap = const (
Icon(
Icons.favorite_rounded,
color: Color(0xFFC3007A),
),
null,
),
this.itemInfoBuilder,
this.dateFormat,
this.timeFormat, this.timeFormat,
this.buttonBuilder, this.buttonBuilder,
this.textInputBuilder, this.textInputBuilder,
this.dividerBuilder,
this.userAvatarBuilder, this.userAvatarBuilder,
this.anonymousAvatarBuilder, this.anonymousAvatarBuilder,
this.nameBuilder, 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 /// Theming options for the timeline
final TimelineTheme theme; final TimelineTheme theme;
/// The format to display the post date in /// The format to display the post date in
final DateFormat? dateformat; final DateFormat? dateFormat;
/// The format to display the post time in /// The format to display the post time in
final DateFormat? timeFormat; final DateFormat? timeFormat;
@ -41,7 +58,7 @@ class TimelineOptions {
final bool sortCommentsAscending; final bool sortCommentsAscending;
/// Whether to sort posts ascending or descending /// Whether to sort posts ascending or descending
final bool sortPostsAscending; final bool? sortPostsAscending;
/// Allow all posts to be deleted instead of /// Allow all posts to be deleted instead of
/// only the posts of the current user /// only the posts of the current user
@ -71,6 +88,97 @@ class TimelineOptions {
/// ImagePickerConfig can be used to define the /// ImagePickerConfig can be used to define the
/// size and quality for the uploaded image. /// size and quality for the uploaded image.
final ImagePickerConfig imagePickerConfig; 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<TimelineCategory> 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<List<TimelinePost>> Function(
String filterWord,
) search,
)? searchBarBuilder;
final void Function({required bool filterEnabled})? onFilterEnabledChange;
} }
typedef ButtonBuilder = Widget Function( typedef ButtonBuilder = Widget Function(

View file

@ -28,6 +28,7 @@ class TimelineTranslations {
required this.postAt, required this.postAt,
required this.postLoadingError, required this.postLoadingError,
required this.timelineSelectionDescription, required this.timelineSelectionDescription,
required this.searchHint,
}); });
const TimelineTranslations.empty() const TimelineTranslations.empty()
@ -52,7 +53,8 @@ class TimelineTranslations {
writeComment = 'Write your comment here...', writeComment = 'Write your comment here...',
postAt = 'at', postAt = 'at',
postLoadingError = 'Something went wrong while loading the post', postLoadingError = 'Something went wrong while loading the post',
timelineSelectionDescription = 'Choose a category'; timelineSelectionDescription = 'Choose a category',
searchHint = 'Search...';
final String noPosts; final String noPosts;
final String noPostsWithFilter; final String noPostsWithFilter;
@ -79,6 +81,8 @@ class TimelineTranslations {
final String timelineSelectionDescription; final String timelineSelectionDescription;
final String searchHint;
TimelineTranslations copyWith({ TimelineTranslations copyWith({
String? noPosts, String? noPosts,
String? noPostsWithFilter, String? noPostsWithFilter,
@ -101,6 +105,7 @@ class TimelineTranslations {
String? firstComment, String? firstComment,
String? postLoadingError, String? postLoadingError,
String? timelineSelectionDescription, String? timelineSelectionDescription,
String? searchHint,
}) => }) =>
TimelineTranslations( TimelineTranslations(
noPosts: noPosts ?? this.noPosts, noPosts: noPosts ?? this.noPosts,
@ -127,5 +132,6 @@ class TimelineTranslations {
postLoadingError: postLoadingError ?? this.postLoadingError, postLoadingError: postLoadingError ?? this.postLoadingError,
timelineSelectionDescription: timelineSelectionDescription:
timelineSelectionDescription ?? this.timelineSelectionDescription, timelineSelectionDescription ?? this.timelineSelectionDescription,
searchHint: searchHint ?? this.searchHint,
); );
} }

View file

@ -13,17 +13,16 @@ import 'package:flutter_timeline_view/src/config/timeline_options.dart';
class TimelinePostCreationScreen extends StatefulWidget { class TimelinePostCreationScreen extends StatefulWidget {
const TimelinePostCreationScreen({ const TimelinePostCreationScreen({
required this.userId, required this.userId,
required this.postCategory,
required this.onPostCreated, required this.onPostCreated,
required this.service, required this.service,
required this.options, required this.options,
this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 16), this.postCategory,
super.key, super.key,
}); });
final String userId; final String userId;
final String postCategory; final String? postCategory;
/// called when the post is created /// called when the post is created
final Function(TimelinePost) onPostCreated; final Function(TimelinePost) onPostCreated;
@ -34,9 +33,6 @@ class TimelinePostCreationScreen extends StatefulWidget {
/// The options for the timeline /// The options for the timeline
final TimelineOptions options; final TimelineOptions options;
/// The padding around the screen
final EdgeInsets padding;
@override @override
State<TimelinePostCreationScreen> createState() => State<TimelinePostCreationScreen> createState() =>
_TimelinePostCreationScreenState(); _TimelinePostCreationScreenState();
@ -92,173 +88,176 @@ class _TimelinePostCreationScreenState
var theme = Theme.of(context); var theme = Theme.of(context);
return Padding( return Padding(
padding: widget.padding, padding: widget.options.padding,
child: Column( child: SingleChildScrollView(
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ mainAxisSize: MainAxisSize.min,
Text( crossAxisAlignment: CrossAxisAlignment.start,
widget.options.translations.title, children: [
style: theme.textTheme.displaySmall, Text(
), widget.options.translations.title,
widget.options.textInputBuilder?.call( style: theme.textTheme.displaySmall,
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,
), ),
), widget.options.textInputBuilder?.call(
const SizedBox( titleController,
height: 16, null,
), '',
// input field for the content ) ??
Text( TextField(
widget.options.translations.uploadImage, controller: titleController,
style: theme.textTheme.displaySmall, ),
), const SizedBox(height: 16),
Text( Text(
widget.options.translations.uploadImageDescription, widget.options.translations.content,
style: theme.textTheme.bodyMedium, style: theme.textTheme.displaySmall,
), ),
// image picker field const SizedBox(height: 4),
const SizedBox( Text(
height: 8, widget.options.translations.contentDescription,
), style: theme.textTheme.bodyMedium,
Stack( ),
children: [ // input field for the content
GestureDetector( SizedBox(
onTap: () async { height: 100,
// open a dialog to choose between camera and gallery child: TextField(
var result = await showModalBottomSheet<Uint8List?>( controller: contentController,
context: context, textCapitalization: TextCapitalization.sentences,
builder: (context) => Container( expands: true,
padding: const EdgeInsets.all(8.0), maxLines: null,
color: Colors.black, minLines: null,
child: ImagePicker( ),
imagePickerConfig: widget.options.imagePickerConfig, ),
imagePickerTheme: widget.options.imagePickerTheme, const SizedBox(
), height: 16,
), ),
); // input field for the content
if (result != null) { Text(
setState(() { widget.options.translations.uploadImage,
image = result; style: theme.textTheme.displaySmall,
}); ),
} Text(
checkIfEditingDone(); widget.options.translations.uploadImageDescription,
}, style: theme.textTheme.bodyMedium,
child: image != null ),
? ClipRRect( // image picker field
borderRadius: BorderRadius.circular(8.0), const SizedBox(
child: Image.memory( height: 8,
image!, ),
width: double.infinity, Stack(
height: 150.0, children: [
fit: BoxFit.cover, GestureDetector(
// give it a rounded border onTap: () async {
// open a dialog to choose between camera and gallery
var result = await showModalBottomSheet<Uint8List?>(
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), if (result != null) {
color: theme.textTheme.displayMedium?.color ?? setState(() {
Colors.white, image = result;
child: const SizedBox( });
width: double.infinity, }
height: 150.0, checkIfEditingDone();
child: Icon( },
Icons.image, child: image != null
size: 32, ? 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,
),
),
),
), ),
], // if an image is selected, show a delete button
], if (image != null) ...[
), Positioned(
top: 8,
const SizedBox(height: 16), right: 8,
child: GestureDetector(
Text( onTap: () {
widget.options.translations.commentsTitle, setState(() {
style: theme.textTheme.displaySmall, image = null;
), });
Text( checkIfEditingDone();
widget.options.translations.allowCommentsDescription, },
style: theme.textTheme.bodyMedium, child: Container(
), decoration: BoxDecoration(
// radio buttons for yes or no color: Colors.black.withOpacity(0.5),
Switch( borderRadius: BorderRadius.circular(8.0),
value: allowComments, ),
onChanged: (newValue) { child: const Icon(
setState(() { Icons.delete,
allowComments = newValue; color: Colors.white,
}); ),
}, ),
),
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,
), ),
), ),
), ],
], ],
),
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,
),
),
),
],
),
), ),
); );
} }

View file

@ -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_interface/flutter_timeline_interface.dart';
import 'package:flutter_timeline_view/src/config/timeline_options.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/reaction_bottom.dart';
import 'package:flutter_timeline_view/src/widgets/tappable_image.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
class TimelinePostScreen extends StatefulWidget { class TimelinePostScreen extends StatelessWidget {
const TimelinePostScreen({ const TimelinePostScreen({
required this.userId, required this.userId,
required this.service, required this.service,
required this.userService,
required this.options, required this.options,
required this.post, required this.post,
required this.onPostDelete, required this.onPostDelete,
this.onUserTap, this.onUserTap,
this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
super.key, super.key,
}); });
@ -33,31 +32,70 @@ class TimelinePostScreen extends StatefulWidget {
/// The timeline service to fetch the post details /// The timeline service to fetch the post details
final TimelineService service; final TimelineService service;
/// The user service to fetch the profile picture of the user
final TimelineUserService userService;
/// Options to configure the timeline screens /// Options to configure the timeline screens
final TimelineOptions options; final TimelineOptions options;
/// The post to show /// The post to show
final TimelinePost post; 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 /// If this is not null, the user can tap on the user avatar or name
final Function(String userId)? onUserTap; final Function(String userId)? onUserTap;
final VoidCallback onPostDelete; final VoidCallback onPostDelete;
@override @override
State<TimelinePostScreen> 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<TimelinePostScreen> { 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; TimelinePost? post;
bool isLoading = true; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
@ -90,11 +128,12 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var theme = Theme.of(context); var theme = Theme.of(context);
var dateFormat = widget.options.dateformat ?? var dateFormat = widget.options.dateFormat ??
DateFormat('dd/MM/yyyy', Localizations.localeOf(context).languageCode); DateFormat('dd/MM/yyyy', Localizations.localeOf(context).languageCode);
var timeFormat = widget.options.timeFormat ?? DateFormat('HH:mm'); var timeFormat = widget.options.timeFormat ?? DateFormat('HH:mm');
if (isLoading) { if (isLoading) {
return const Center( const Center(
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
); );
} }
@ -127,7 +166,7 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
}, },
child: SingleChildScrollView( child: SingleChildScrollView(
child: Padding( child: Padding(
padding: widget.padding, padding: widget.options.padding,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -182,12 +221,7 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
if (widget.options.allowAllDeletion || if (widget.options.allowAllDeletion ||
post.creator?.userId == widget.userId) post.creator?.userId == widget.userId)
PopupMenuButton( PopupMenuButton(
onSelected: (value) async { onSelected: (value) => widget.onPostDelete(),
if (value == 'delete') {
await widget.service.deletePost(post);
widget.onPostDelete();
}
},
itemBuilder: (BuildContext context) => itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[ <PopupMenuEntry<String>>[
PopupMenuItem<String>( PopupMenuItem<String>(
@ -223,11 +257,40 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
ClipRRect( ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: CachedNetworkImage( child: widget.options.doubleTapTolike
width: double.infinity, ? TappableImage(
imageUrl: post.imageUrl!, likeAndDislikeIcon: widget
fit: BoxFit.fitHeight, .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( const SizedBox(
@ -246,11 +309,14 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
), ),
); );
}, },
child: widget.options.theme.likedIcon ?? child: Container(
Icon( color: Colors.transparent,
Icons.thumb_up_rounded, child: widget.options.theme.likedIcon ??
color: widget.options.theme.iconColor, Icon(
), Icons.thumb_up_rounded,
color: widget.options.theme.iconColor,
),
),
), ),
] else ...[ ] else ...[
InkWell( InkWell(
@ -262,11 +328,15 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
), ),
); );
}, },
child: widget.options.theme.likeIcon ?? child: Container(
Icon( color: Colors.transparent,
Icons.thumb_up_alt_outlined, child: widget.options.theme.likeIcon ??
color: widget.options.theme.iconColor, Icon(
), Icons.thumb_up_alt_outlined,
color: widget.options.theme.iconColor,
size: widget.options.iconSize,
),
),
), ),
], ],
const SizedBox(width: 8), const SizedBox(width: 8),
@ -275,6 +345,7 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
Icon( Icon(
Icons.chat_bubble_outline_rounded, Icons.chat_bubble_outline_rounded,
color: widget.options.theme.iconColor, color: widget.options.theme.iconColor,
size: widget.options.iconSize,
), ),
], ],
), ),
@ -481,14 +552,14 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
Align( Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: ReactionBottom( child: ReactionBottom(
messageInputBuilder: widget.options.textInputBuilder!, messageInputBuilder: textInputBuilder,
onPressSelectImage: () async { onPressSelectImage: () async {
// open the image picker // open the image picker
var result = await showModalBottomSheet<Uint8List?>( var result = await showModalBottomSheet<Uint8List?>(
context: context, context: context,
builder: (context) => Container( builder: (context) => Container(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
color: Colors.black, color: theme.colorScheme.background,
child: ImagePicker( child: ImagePicker(
imagePickerConfig: widget.options.imagePickerConfig, imagePickerConfig: widget.options.imagePickerConfig,
imagePickerTheme: widget.options.imagePickerTheme, imagePickerTheme: widget.options.imagePickerTheme,

View file

@ -6,20 +6,20 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.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/flutter_timeline_view.dart';
import 'package:flutter_timeline_view/src/widgets/timeline_post_widget.dart';
class TimelineScreen extends StatefulWidget { class TimelineScreen extends StatefulWidget {
const TimelineScreen({ const TimelineScreen({
required this.userId, required this.userId,
required this.service,
required this.options, required this.options,
required this.onPostTap, required this.onPostTap,
required this.service, this.scrollController,
this.onUserTap, this.onUserTap,
this.posts, this.posts,
this.controller, this.timelineCategory,
this.timelineCategoryFilter, this.postWidgetBuilder,
this.padding = const EdgeInsets.symmetric(vertical: 12.0), this.filterEnabled = false,
super.key, super.key,
}); });
@ -33,10 +33,10 @@ class TimelineScreen extends StatefulWidget {
final TimelineOptions options; final TimelineOptions options;
/// The controller for the scroll view /// The controller for the scroll view
final ScrollController? controller; final ScrollController? scrollController;
/// The string to filter the timeline by category /// 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 /// This is used if you want to pass in a list of posts instead
/// of fetching them from the service /// 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 /// If this is not null, the user can tap on the user avatar or name
final Function(String userId)? onUserTap; final Function(String userId)? onUserTap;
/// The padding between posts in the timeline /// Override the standard postwidget
final EdgeInsets padding; final Widget Function(TimelinePost post)? postWidgetBuilder;
/// if true the filter textfield is enabled.
final bool filterEnabled;
@override @override
State<TimelineScreen> createState() => _TimelineScreenState(); State<TimelineScreen> createState() => _TimelineScreenState();
@ -57,12 +60,21 @@ class TimelineScreen extends StatefulWidget {
class _TimelineScreenState extends State<TimelineScreen> { class _TimelineScreenState extends State<TimelineScreen> {
late ScrollController controller; late ScrollController controller;
late var textFieldController = TextEditingController(
text: widget.options.filterOptions.initialFilterWord,
);
late var service = widget.service;
bool isLoading = true; bool isLoading = true;
late var category = widget.timelineCategory;
late var filterWord = widget.options.filterOptions.initialFilterWord;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
controller = widget.controller ?? ScrollController(); controller = widget.scrollController ?? ScrollController();
unawaited(loadPosts()); unawaited(loadPosts());
} }
@ -74,60 +86,158 @@ class _TimelineScreenState extends State<TimelineScreen> {
// Build the list of posts // Build the list of posts
return ListenableBuilder( return ListenableBuilder(
listenable: widget.service, listenable: service,
builder: (context, _) { builder: (context, _) {
var posts = widget.posts ?? var posts = widget.posts ?? service.getPosts(category);
widget.service.getPosts(widget.timelineCategoryFilter);
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 posts = posts
.where( .where(
(p) => (p) => category == null || p.category == category,
widget.timelineCategoryFilter == null ||
p.category == widget.timelineCategoryFilter,
) )
.toList(); .toList();
// sort posts by date // sort posts by date
posts.sort( if (widget.options.sortPostsAscending != null) {
(a, b) => widget.options.sortPostsAscending posts.sort(
? a.createdAt.compareTo(b.createdAt) (a, b) => widget.options.sortPostsAscending!
: b.createdAt.compareTo(a.createdAt), ? a.createdAt.compareTo(b.createdAt)
); : b.createdAt.compareTo(a.createdAt),
return SingleChildScrollView( );
controller: controller, }
child: Column(
children: [ return Column(
...posts.map( crossAxisAlignment: CrossAxisAlignment.start,
(post) => Padding( children: [
padding: widget.padding, SizedBox(
child: TimelinePostWidget( height: widget.options.padding.top,
userId: widget.userId, ),
options: widget.options, if (widget.filterEnabled) ...[
post: post, Padding(
height: widget.options.timelinePostHeight, padding: EdgeInsets.symmetric(
onTap: () => widget.onPostTap.call(post), horizontal: widget.options.padding.horizontal,
onTapLike: () async => ),
widget.service.likePost(widget.userId, post), child: Row(
onTapUnlike: () async => crossAxisAlignment: CrossAxisAlignment.end,
widget.service.unlikePost(widget.userId, post), children: [
onPostDelete: () async => widget.service.deletePost(post), Expanded(
onUserTap: widget.onUserTap, 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) const SizedBox(
Center( height: 24,
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,
),
),
),
], ],
), 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<TimelineScreen> {
Future<void> loadPosts() async { Future<void> loadPosts() async {
if (widget.posts != null) return; if (widget.posts != null) return;
try { try {
await widget.service.fetchPosts(widget.timelineCategoryFilter); await service.fetchPosts(category);
setState(() { setState(() {
isLoading = false; isLoading = false;
}); });

View file

@ -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),
),
],
),
);
}
}

View file

@ -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,
),
),
);
}
}

View file

@ -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<bool> Function({required bool liked}) onLike;
final (Icon?, Icon?) likeAndDislikeIcon;
@override
State<TappableImage> createState() => _TappableImageState();
}
class _TappableImageState extends State<TappableImage>
with SingleTickerProviderStateMixin {
late AnimationController animationController;
late Animation<double> 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<HeartAnimation> createState() => _HeartAnimationState();
}
class _HeartAnimationState extends State<HeartAnimation> {
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,
),
);
}

View file

@ -6,17 +6,18 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.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/config/timeline_options.dart';
import 'package:flutter_timeline_view/src/widgets/tappable_image.dart';
class TimelinePostWidget extends StatelessWidget { class TimelinePostWidget extends StatefulWidget {
const TimelinePostWidget({ const TimelinePostWidget({
required this.userId, required this.userId,
required this.options, required this.options,
required this.post, required this.post,
required this.height,
required this.onTap, required this.onTap,
required this.onTapLike, required this.onTapLike,
required this.onTapUnlike, required this.onTapUnlike,
required this.onPostDelete, required this.onPostDelete,
required this.service,
this.onUserTap, this.onUserTap,
super.key, super.key,
}); });
@ -28,49 +29,57 @@ class TimelinePostWidget extends StatelessWidget {
final TimelinePost post; final TimelinePost post;
/// Optional max height of the post /// Optional max height of the post
final double? height;
final VoidCallback onTap; final VoidCallback onTap;
final VoidCallback onTapLike; final VoidCallback onTapLike;
final VoidCallback onTapUnlike; final VoidCallback onTapUnlike;
final VoidCallback onPostDelete; final VoidCallback onPostDelete;
final TimelineService service;
/// If this is not null, the user can tap on the user avatar or name /// If this is not null, the user can tap on the user avatar or name
final Function(String userId)? onUserTap; final Function(String userId)? onUserTap;
@override
State<TimelinePostWidget> createState() => _TimelinePostWidgetState();
}
class _TimelinePostWidgetState extends State<TimelinePostWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var theme = Theme.of(context); var theme = Theme.of(context);
return InkWell( return InkWell(
onTap: onTap, onTap: widget.onTap,
child: SizedBox( child: SizedBox(
height: post.imageUrl != null ? height : null, height: widget.post.imageUrl != null
? widget.options.postWidgetHeight
: null,
width: double.infinity, width: double.infinity,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
if (post.creator != null) if (widget.post.creator != null)
InkWell( InkWell(
onTap: onUserTap != null onTap: widget.onUserTap != null
? () => onUserTap?.call(post.creator!.userId) ? () =>
widget.onUserTap?.call(widget.post.creator!.userId)
: null, : null,
child: Row( child: Row(
children: [ children: [
if (post.creator!.imageUrl != null) ...[ if (widget.post.creator!.imageUrl != null) ...[
options.userAvatarBuilder?.call( widget.options.userAvatarBuilder?.call(
post.creator!, widget.post.creator!,
40, 40,
) ?? ) ??
CircleAvatar( CircleAvatar(
radius: 20, radius: 20,
backgroundImage: CachedNetworkImageProvider( backgroundImage: CachedNetworkImageProvider(
post.creator!.imageUrl!, widget.post.creator!.imageUrl!,
), ),
), ),
] else ...[ ] else ...[
options.anonymousAvatarBuilder?.call( widget.options.anonymousAvatarBuilder?.call(
post.creator!, widget.post.creator!,
40, 40,
) ?? ) ??
const CircleAvatar( const CircleAvatar(
@ -82,22 +91,24 @@ class TimelinePostWidget extends StatelessWidget {
], ],
const SizedBox(width: 10), const SizedBox(width: 10),
Text( Text(
options.nameBuilder?.call(post.creator) ?? widget.options.nameBuilder
post.creator?.fullName ?? ?.call(widget.post.creator) ??
options.translations.anonymousUser, widget.post.creator?.fullName ??
style: widget.options.translations.anonymousUser,
options.theme.textStyles.postCreatorTitleStyle ?? style: widget.options.theme.textStyles
theme.textTheme.titleMedium, .postCreatorTitleStyle ??
theme.textTheme.titleMedium,
), ),
], ],
), ),
), ),
const Spacer(), const Spacer(),
if (options.allowAllDeletion || post.creator?.userId == userId) if (widget.options.allowAllDeletion ||
widget.post.creator?.userId == widget.userId)
PopupMenuButton( PopupMenuButton(
onSelected: (value) { onSelected: (value) {
if (value == 'delete') { if (value == 'delete') {
onPostDelete(); widget.onPostDelete();
} }
}, },
itemBuilder: (BuildContext context) => itemBuilder: (BuildContext context) =>
@ -107,40 +118,67 @@ class TimelinePostWidget extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Text( Text(
options.translations.deletePost, widget.options.translations.deletePost,
style: options.theme.textStyles.deletePostStyle ?? style: widget.options.theme.textStyles
.deletePostStyle ??
theme.textTheme.bodyMedium, theme.textTheme.bodyMedium,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
options.theme.deleteIcon ?? widget.options.theme.deleteIcon ??
Icon( Icon(
Icons.delete, Icons.delete,
color: options.theme.iconColor, color: widget.options.theme.iconColor,
), ),
], ],
), ),
), ),
], ],
child: options.theme.moreIcon ?? child: widget.options.theme.moreIcon ??
Icon( Icon(
Icons.more_horiz_rounded, Icons.more_horiz_rounded,
color: options.theme.iconColor, color: widget.options.theme.iconColor,
), ),
), ),
], ],
), ),
// image of the post // image of the post
if (post.imageUrl != null) ...[ if (widget.post.imageUrl != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Flexible( Flexible(
flex: height != null ? 1 : 0, flex: widget.options.postWidgetHeight != null ? 1 : 0,
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: CachedNetworkImage( child: widget.options.doubleTapTolike
width: double.infinity, ? TappableImage(
imageUrl: post.imageUrl!, likeAndDislikeIcon:
fit: BoxFit.fitWidth, 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, height: 8,
), ),
// post information // post information
Row( if (widget.options.iconsWithValues)
children: [ Row(
if (post.likedBy?.contains(userId) ?? false) ...[ children: [
InkWell( TextButton.icon(
onTap: onTapUnlike, onPressed: () async {
child: options.theme.likedIcon ?? 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( Icon(
Icons.thumb_up_rounded, widget.post.likedBy?.contains(widget.userId) ?? false
color: options.theme.iconColor, ? Icons.favorite
), : Icons.favorite_outline_outlined,
),
] else ...[
InkWell(
onTap: onTapLike,
child: options.theme.likeIcon ??
Icon(
Icons.thumb_up_alt_outlined,
color: options.theme.iconColor,
), ),
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) else
options.theme.commentIcon ?? Row(
Icon( children: [
Icons.chat_bubble_outline_rounded, if (widget.post.likedBy?.contains(widget.userId) ??
color: options.theme.iconColor, 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( const SizedBox(
height: 8, height: 8,
), ),
Text( if (widget.options.itemInfoBuilder != null) ...[
'${post.likes} ${options.translations.likesTitle}', widget.options.itemInfoBuilder!(
style: options.theme.textStyles.listPostLikeTitleAndAmount ?? post: widget.post,
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,
),
],
), ),
), ] else ...[
const SizedBox(height: 4), Text(
Text( '${widget.post.likes} '
options.translations.viewPost, '${widget.options.translations.likesTitle}',
style: options.theme.textStyles.viewPostStyle ?? style: widget
theme.textTheme.bodySmall, .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!(),
], ],
), ),
), ),

View file

@ -4,7 +4,7 @@
name: flutter_timeline_view name: flutter_timeline_view
description: Visual elements of the Flutter Timeline Component description: Visual elements of the Flutter Timeline Component
version: 1.0.0 version: 2.0.0
publish_to: none publish_to: none
@ -23,11 +23,12 @@ dependencies:
git: git:
url: https://github.com/Iconica-Development/flutter_timeline url: https://github.com/Iconica-Development/flutter_timeline
path: packages/flutter_timeline_interface path: packages/flutter_timeline_interface
ref: 1.0.0 ref: 2.0.0
flutter_image_picker: flutter_image_picker:
git: git:
url: https://github.com/Iconica-Development/flutter_image_picker url: https://github.com/Iconica-Development/flutter_image_picker
ref: 1.0.4 ref: 1.0.4
collection: any
dev_dependencies: dev_dependencies:
flutter_lints: ^2.0.0 flutter_lints: ^2.0.0