mirror of
https://github.com/Iconica-Development/flutter_timeline.git
synced 2025-05-19 10:33:44 +02:00
Compare commits
126 commits
Author | SHA1 | Date | |
---|---|---|---|
65d27ce4a0 | |||
3615342c64 | |||
13ba6ada07 | |||
4f0c36a1cc | |||
a62935eb60 | |||
|
554c4526da | ||
|
c41f43bb2a | ||
|
09b11dbbc7 | ||
|
f9525c60b5 | ||
|
38bb41ce10 | ||
|
02c136d7ea | ||
|
7aef9d9617 | ||
|
8188c179fb | ||
|
49f0853cca | ||
|
1dc79b8d74 | ||
|
3bd7b0951f | ||
|
a8897242e7 | ||
|
eb953ede0d | ||
|
1f629ddf1f | ||
|
c572e6cd8b | ||
|
a1024dac3d | ||
|
971a030b5c | ||
|
32fe08a7af | ||
|
a7fff5ae91 | ||
|
13ae371191 | ||
|
38dd43ab39 | ||
|
c99ecffa64 | ||
|
d089cec5a4 | ||
|
d77136edd4 | ||
|
567765f856 | ||
|
9d476129fd | ||
|
71645eee3b | ||
|
aa22e1305b | ||
|
1c46fbea4b | ||
|
31f93704b6 | ||
|
23449ec57d | ||
|
35028b9bb9 | ||
|
6a522f3209 | ||
|
013e82e61d | ||
|
25264ba44b | ||
|
767215a53e | ||
|
504a7de343 | ||
|
5f6bb26404 | ||
|
ab0f96121e | ||
|
ad5390fff8 | ||
|
bca15b6307 | ||
|
035f795130 | ||
|
7fa33cdfb4 | ||
|
24c779d43b | ||
|
2c8d523f32 | ||
|
8d13e4af27 | ||
|
13960c4f1c | ||
|
deaca5b126 | ||
|
933386623a | ||
|
4f7fa834e4 | ||
|
93a74fb904 | ||
|
af1f7c2d78 | ||
|
423f4ce03a | ||
|
e695b7020b | ||
|
024f267ae5 | ||
|
09b66e9921 | ||
|
d4f7ec3768 | ||
|
a964830cc1 | ||
|
7b76a8d956 | ||
|
f4990dbf5c | ||
|
3ec780ea0a | ||
|
8eaf40b0c0 | ||
|
1ce80135f5 | ||
|
7fd8179a07 | ||
|
27e3c34b96 | ||
|
0378ba0eb3 | ||
|
4569f25f4b | ||
|
90e35e657e | ||
|
654b621ba7 | ||
|
910c36241a | ||
|
07f5872ec6 | ||
|
898583d1d1 | ||
|
2225a2a2c2 | ||
|
eca34a9502 | ||
|
27d1ef5fac | ||
|
ce093f86d4 | ||
|
d6d12ab312 | ||
|
c5c394348e | ||
|
97e50a32a3 | ||
|
1e1241bdf9 | ||
|
604c082827 | ||
|
115884a0f4 | ||
|
98c0c8d45b | ||
|
d16cd74a33 | ||
|
525d8d3be6 | ||
|
7e7f74a02b | ||
|
4aa4c1e291 | ||
|
6a27f26fc9 | ||
|
1fc7c8d2de | ||
|
179841f930 | ||
|
d0b4db1eb0 | ||
|
03901aaa2b | ||
|
93a184802d | ||
|
80df20c323 | ||
|
0f0c90ef56 | ||
|
5c475d4de5 | ||
|
d075e35d74 | ||
|
dea258f5a5 | ||
|
ac18e77da4 | ||
|
73ac508622 | ||
|
e2d09040dd | ||
|
e99e81c907 | ||
|
9125c47ac4 | ||
|
e5e2eb5c22 | ||
|
14dced5ef4 | ||
|
06ea5f0281 | ||
|
e61da6873d | ||
01fd6ea05a | |||
60747d30d8 | |||
|
e9b2daed68 | ||
|
7a2561ba2e | ||
|
c8cc325a95 | ||
|
d60244fa3e | ||
|
03b14924a2 | ||
|
06df2b7649 | ||
|
cf129c05c0 | ||
|
7e89ba9c85 | ||
|
e523f52118 | ||
|
0035e1f4fb | ||
|
a1ceee391a | ||
|
6329ce4d61 |
73 changed files with 5070 additions and 1009 deletions
3
.fvmrc
Normal file
3
.fvmrc
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"flutter": "3.22.2"
|
||||
}
|
14
.github/workflows/component-documentation.yml
vendored
Normal file
14
.github/workflows/component-documentation.yml
vendored
Normal file
|
@ -0,0 +1,14 @@
|
|||
name: Iconica Standard Component Documentation Workflow
|
||||
# Workflow Caller version: 1.0.0
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
call-iconica-component-documentation-workflow:
|
||||
uses: Iconica-Development/.github/.github/workflows/component-documentation.yml@master
|
||||
secrets: inherit
|
||||
permissions: write-all
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -51,3 +51,6 @@ pubspec_overrides.yaml
|
|||
**/example/windows
|
||||
**/example/web
|
||||
**/example/README.md
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
78
CHANGELOG.md
78
CHANGELOG.md
|
@ -1,3 +1,79 @@
|
|||
## 0.0.1 - October 9th 2023
|
||||
## 5.1.1
|
||||
|
||||
- Be honest about which Dart and Flutter versions we support
|
||||
- Relax our Firebase version constraint, we also support the current newest versions
|
||||
|
||||
## 5.1.0
|
||||
|
||||
* Added `routeToPostDetail` to the `TimelineUserStory` to allow for navigation to the post detail screen.
|
||||
* Fixed design issues.
|
||||
|
||||
## 4.1.0
|
||||
- Migrate to flutter 3.22 which deprecates the background and onBackground properties in the ThemeData and also removes MaterialStatePropertyAll
|
||||
- Add categorySelectionButtonSelectedTextColor and categorySelectionButtonUnselectedTextColor to the timeline theme to allow for the customization of the text color of the category selection buttons
|
||||
- Show loading indicator when loading a post in the post screen
|
||||
|
||||
## 4.0.0
|
||||
|
||||
- Add a serviceBuilder to the userstory configuration
|
||||
- Add a listHeaderBuilder for showing a header at the top of the list of posts in the timeline
|
||||
- Add a getUserId function to retrieve the userId when needed in the userstory configuration
|
||||
- Fix the timelinecategory selection by removing the categories with key null
|
||||
- Set an optional max length on the default post title input field
|
||||
- Add a postCreationFloatingActionButtonColor to the timeline theme to set the color of the floating action button
|
||||
- Add a post and a category to the postViewOpenPageBuilder function
|
||||
- Add a refresh functionality to the timeline with a pull to refresh callback to allow additional functionality when refreshing the timeline
|
||||
- Use the adaptive variants of the material elements in the timeline
|
||||
- Change the default blue color to the primary color of the Theme.of(context) in the timeline
|
||||
- Change the TimelineTranslations constructor to require all translations or use the TimelineTranslations.empty constructor if you don't want to specify all translations
|
||||
- Add a TimelinePaddingOptions class to store the padding options for the timeline
|
||||
- fix the avatar size to match the new design
|
||||
- Add the iconbutton for image uploading back to the ReactionBottom
|
||||
- Fix category key is correctly used for saving timeline posts and category title is shown everywhere
|
||||
- Fix when clicking on post delete in the post screen of the userstory it will now navigate back to the timeline and delete the post
|
||||
- Fix like icon being used for both like and unliked posts
|
||||
- Fix post creator can only like the post once and after it is actually created
|
||||
- Change the CategorySelectorButton to use more styling options and allow for an icon to be shown
|
||||
- Fix incorrect timeline reaction name
|
||||
- Add a dialog for post deletion confirmation
|
||||
- Add a callback method to determine if a user can delete posts that gets called when needed
|
||||
|
||||
## 3.0.1
|
||||
|
||||
- Fixed postOverviewScreen not displaying the creators name.
|
||||
|
||||
## 3.0.0
|
||||
- Add default styling and default flow
|
||||
|
||||
## 2.3.1
|
||||
|
||||
- Updated readme.
|
||||
- fixed bug in `localTimelinePostService` where it was not possible to make a post.
|
||||
|
||||
## 2.3.0
|
||||
|
||||
- Added separate open page builders for timeline screens
|
||||
- Fixed afterPostCreationGoHome routing in gorouter and navigater user stories
|
||||
|
||||
## 2.2.0
|
||||
|
||||
- Add all routes to gorouter and navigator user stories
|
||||
- Added enablePostOverviewScreen to config
|
||||
- Update flutter_image_picker to 1.0.5
|
||||
|
||||
## 2.1.0
|
||||
|
||||
- Fixed multiline textfield not being dismissible.
|
||||
- Fixed liking a new post you created.
|
||||
- Added options to require image and enforce content length in post creation
|
||||
- Added post overview screen before creating post
|
||||
|
||||
## 1.0.0 - November 27 2023
|
||||
|
||||
- Improved TimelineService with support for pagination
|
||||
- Extra screens and configuration
|
||||
- TimelineUserStory in the flutter_timeline package which uses go_router
|
||||
|
||||
## 0.0.1 - November 22 2023
|
||||
|
||||
- Initial release
|
131
README.md
131
README.md
|
@ -1,5 +1,6 @@
|
|||
# Flutter Timeline
|
||||
|
||||
Flutter Timeline is a package which shows a list posts by a user. This package also has additional features like liking a post and leaving comments. Default this package adds support for a Firebase back-end.
|
||||
|
||||

|
||||
|
||||
|
@ -7,10 +8,10 @@
|
|||
To use this package, add flutter_timeline as a dependency in your pubspec.yaml file:
|
||||
|
||||
```
|
||||
flutter_timeline
|
||||
flutter_timeline:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_timeline.git
|
||||
path: packages/flutter_timeline
|
||||
url: https://github.com/Iconica-Development/flutter_timeline.git
|
||||
path: packages/flutter_timeline
|
||||
```
|
||||
|
||||
If you are going to use Firebase as the back-end of the Timeline, you should also add the following package as a dependency to your pubspec.yaml file:
|
||||
|
@ -18,12 +19,130 @@ If you are going to use Firebase as the back-end of the Timeline, you should als
|
|||
```
|
||||
flutter_timeline_firebase:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_timeline.git
|
||||
path: packages/flutter_timeline_firebase
|
||||
url: https://github.com/Iconica-Development/flutter_timeline.git
|
||||
path: packages/flutter_timeline_firebase
|
||||
```
|
||||
|
||||
In firebase add firestore and storage to your project.
|
||||
In firestore add a collection named `timeline` and a collection named `users`.
|
||||
In the `timeline` collection all posts will be stored. In the `users` collection all users will be stored.
|
||||
In the `users` collection you should add your users data.
|
||||
|
||||
Add the following code in your `main` function, before the runApp().
|
||||
And import this package: import 'package:intl/date_symbol_data_local.dart';
|
||||
```
|
||||
initializeDateFormatting();
|
||||
```
|
||||
|
||||
## 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 userstory 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,
|
||||
),
|
||||
````
|
||||
|
||||
`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 defining `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();
|
||||
},
|
||||
),
|
||||
```
|
||||
|
||||
You can choose to show an overview of the post before actually posting it using the `enablePostOverviewScreen` config setting.
|
||||
|
||||
|
||||
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. |
|
||||
| afterPostCreationGoHome | Boolean to enable redirect to home after post creation. If false, it will redirect to created post screen. |
|
||||
| enablePostOverviewScreen | Boolean to enable timeline post overview screen before submitting. |
|
||||
| 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. |
|
||||
| homeOpenPageBuilder | Open page builder function for the home page. |
|
||||
| postCreationOpenPageBuilder | Open page builder function for the post creation page. |
|
||||
| postViewOpenPageBuilder | Open page builder function for the post view page. |
|
||||
| postOverviewOpenPageBuilder | Open page builder function for the post overview page. |
|
||||
| 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. |
|
||||
| minTitleLength | Minimum length of the title. |
|
||||
| maxTitleLength | Maximum length of the title. |
|
||||
| minContentLength | Minimum length of the post content. |
|
||||
| maxContentLength | Maximum length of the post content. |
|
||||
| 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` and `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
|
||||
|
|
1
packages/flutter_timeline/CHANGELOG.md
Symbolic link
1
packages/flutter_timeline/CHANGELOG.md
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../CHANGELOG.md
|
1
packages/flutter_timeline/LICENSE
Symbolic link
1
packages/flutter_timeline/LICENSE
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../LICENSE
|
1
packages/flutter_timeline/README.md
Symbolic link
1
packages/flutter_timeline/README.md
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../README.md
|
|
@ -27,7 +27,6 @@ migrate_working_dir/
|
|||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
|
@ -0,0 +1,45 @@
|
|||
import 'package:example/config/config.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline/flutter_timeline.dart';
|
||||
|
||||
class NavigatorApp extends StatelessWidget {
|
||||
const NavigatorApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flutter Timeline',
|
||||
theme: ThemeData(
|
||||
colorScheme:
|
||||
ColorScheme.fromSeed(seedColor: Colors.deepPurple).copyWith(
|
||||
surface: const Color(0xFFB8E2E8),
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const MyHomePage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MyHomePage> createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
var timelineService =
|
||||
TimelineService(postService: LocalTimelinePostService());
|
||||
var timelineOptions = options;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return timeLineNavigatorUserStory(
|
||||
context: context,
|
||||
configuration: getConfig(timelineService),
|
||||
);
|
||||
}
|
||||
}
|
80
packages/flutter_timeline/example/lib/apps/widgets/app.dart
Normal file
80
packages/flutter_timeline/example/lib/apps/widgets/app.dart
Normal file
|
@ -0,0 +1,80 @@
|
|||
import 'package:example/config/config.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline/flutter_timeline.dart';
|
||||
|
||||
class WidgetApp extends StatelessWidget {
|
||||
const WidgetApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flutter Timeline',
|
||||
theme: ThemeData(
|
||||
colorScheme:
|
||||
ColorScheme.fromSeed(seedColor: Colors.deepPurple).copyWith(
|
||||
surface: const Color(0xFFB8E2E8),
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const MyHomePage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MyHomePage> createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
var timelineService =
|
||||
TimelineService(postService: LocalTimelinePostService());
|
||||
var timelineOptions = options;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
floatingActionButton: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
FloatingActionButton(
|
||||
heroTag: 'btn1',
|
||||
onPressed: () {
|
||||
createPost(
|
||||
context,
|
||||
timelineService,
|
||||
timelineOptions,
|
||||
getConfig(timelineService),
|
||||
);
|
||||
},
|
||||
child: const Icon(
|
||||
Icons.edit,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
FloatingActionButton(
|
||||
heroTag: 'btn2',
|
||||
onPressed: () {
|
||||
generatePost(timelineService);
|
||||
},
|
||||
child: const Icon(
|
||||
Icons.add,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: const SafeArea(
|
||||
child: TimelineScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
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: const 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',
|
||||
imageUrl:
|
||||
'https://cdn.britannica.com/68/143568-050-5246474F/Donkey.jpg?w=400&h=300&c=crop',
|
||||
firstName: 'Dirk',
|
||||
lastName: 'lukassen',
|
||||
)
|
||||
};
|
||||
|
||||
@override
|
||||
Future<TimelinePosterUserModel?> getUser(String userId) async {
|
||||
if (_users.containsKey(userId)) {
|
||||
return _users[userId]!;
|
||||
}
|
||||
|
||||
_users[userId] = TimelinePosterUserModel(userId: userId);
|
||||
|
||||
return TimelinePosterUserModel(userId: userId);
|
||||
}
|
||||
}
|
101
packages/flutter_timeline/example/lib/config/config.dart
Normal file
101
packages/flutter_timeline/example/lib/config/config.dart
Normal file
|
@ -0,0 +1,101 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline/flutter_timeline.dart';
|
||||
|
||||
TimelineUserStoryConfiguration getConfig(TimelineService service) {
|
||||
return TimelineUserStoryConfiguration(
|
||||
service: service,
|
||||
userId: 'test_user',
|
||||
optionsBuilder: (context) => options,
|
||||
enablePostOverviewScreen: false,
|
||||
canDeleteAllPosts: (_) => true,
|
||||
);
|
||||
}
|
||||
|
||||
var options = TimelineOptions(
|
||||
textInputBuilder: null,
|
||||
paddings: TimelinePaddingOptions(
|
||||
mainPadding: const EdgeInsets.all(20).copyWith(top: 28),
|
||||
),
|
||||
);
|
||||
|
||||
void navigateToOverview(
|
||||
BuildContext context,
|
||||
TimelineService service,
|
||||
TimelineOptions options,
|
||||
TimelinePost post,
|
||||
) {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TimelinePostOverviewScreen(
|
||||
timelinePost: post,
|
||||
options: options,
|
||||
service: service,
|
||||
onPostSubmit: (post) {
|
||||
service.postService.createPost(post);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void createPost(
|
||||
BuildContext context,
|
||||
TimelineService service,
|
||||
TimelineOptions options,
|
||||
TimelineUserStoryConfiguration configuration) async {
|
||||
await Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => Scaffold(
|
||||
body: TimelinePostCreationScreen(
|
||||
postCategory: 'category1',
|
||||
userId: 'test_user',
|
||||
service: service,
|
||||
options: options,
|
||||
onPostCreated: (post) {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
onPostOverview: (post) {
|
||||
navigateToOverview(context, service, options, post);
|
||||
},
|
||||
enablePostOverviewScreen: configuration.enablePostOverviewScreen,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void generatePost(TimelineService service) {
|
||||
var amountOfPosts = service.postService.getPosts(null).length;
|
||||
|
||||
service.postService.createPost(
|
||||
TimelinePost(
|
||||
id: 'Post$amountOfPosts',
|
||||
creatorId: 'test_user',
|
||||
title: 'Post $amountOfPosts',
|
||||
category: amountOfPosts % 2 == 0 ? 'category1' : 'category2',
|
||||
content: "Post $amountOfPosts content",
|
||||
likes: 0,
|
||||
reaction: 0,
|
||||
creator: const TimelinePosterUserModel(
|
||||
userId: 'test_user',
|
||||
imageUrl:
|
||||
'https://cdn.britannica.com/68/143568-050-5246474F/Donkey.jpg?w=400&h=300&c=crop',
|
||||
firstName: 'Dirk',
|
||||
lastName: 'lukassen',
|
||||
),
|
||||
createdAt: DateTime.now(),
|
||||
reactionEnabled: amountOfPosts % 2 == 0 ? false : true,
|
||||
imageUrl: amountOfPosts % 3 != 0
|
||||
? 'https://s3-eu-west-1.amazonaws.com/sortlist-core-api/6qpvvqjtmniirpkvp8eg83bicnc2'
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
9
packages/flutter_timeline/example/lib/main.dart
Normal file
9
packages/flutter_timeline/example/lib/main.dart
Normal file
|
@ -0,0 +1,9 @@
|
|||
import 'package:example/apps/navigator/app.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
|
||||
void main() {
|
||||
initializeDateFormatting();
|
||||
|
||||
runApp(const NavigatorApp());
|
||||
}
|
92
packages/flutter_timeline/example/pubspec.yaml
Normal file
92
packages/flutter_timeline/example/pubspec.yaml
Normal file
|
@ -0,0 +1,92 @@
|
|||
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
|
||||
|
||||
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
|
29
packages/flutter_timeline/example/test/widget_test.dart
Normal file
29
packages/flutter_timeline/example/test/widget_test.dart
Normal file
|
@ -0,0 +1,29 @@
|
|||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:example/apps/widgets/app.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const WidgetApp());
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
});
|
||||
}
|
|
@ -1,8 +1,12 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
/// Flutter Timeline library
|
||||
library flutter_timeline;
|
||||
|
||||
export 'package:flutter_timeline/src/flutter_timeline_navigator_userstory.dart';
|
||||
export 'package:flutter_timeline/src/models/timeline_configuration.dart';
|
||||
export 'package:flutter_timeline/src/routes.dart';
|
||||
export 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
export 'package:flutter_timeline_view/flutter_timeline_view.dart';
|
||||
|
|
|
@ -0,0 +1,374 @@
|
|||
// SPDX-FileCopyrightText: 2024 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline/flutter_timeline.dart';
|
||||
|
||||
/// A widget function that creates a timeline navigator for user stories.
|
||||
///
|
||||
/// This function creates a navigator for displaying user stories on a timeline.
|
||||
/// It takes a [BuildContext] and an optional [TimelineUserStoryConfiguration]
|
||||
/// as parameters. If no configuration is provided, default values will be used.
|
||||
late TimelineUserStoryConfiguration timelineUserStoryConfiguration;
|
||||
|
||||
Widget timeLineNavigatorUserStory({
|
||||
required BuildContext context,
|
||||
TimelineUserStoryConfiguration? configuration,
|
||||
}) {
|
||||
timelineUserStoryConfiguration = configuration ??
|
||||
TimelineUserStoryConfiguration(
|
||||
userId: 'test_user',
|
||||
service: TimelineService(
|
||||
postService: LocalTimelinePostService(),
|
||||
),
|
||||
optionsBuilder: (context) => const TimelineOptions(),
|
||||
);
|
||||
|
||||
return _timelineScreenRoute(
|
||||
config: timelineUserStoryConfiguration,
|
||||
context: context,
|
||||
);
|
||||
}
|
||||
|
||||
/// A widget function that creates a timeline screen route.
|
||||
///
|
||||
/// This function creates a route for displaying a timeline screen. It takes
|
||||
/// a [BuildContext] and an optional [TimelineUserStoryConfiguration] as
|
||||
/// parameters. If no configuration is provided, default values will be used.
|
||||
Widget _timelineScreenRoute({
|
||||
required BuildContext context,
|
||||
required TimelineUserStoryConfiguration config,
|
||||
String? initalCategory,
|
||||
}) {
|
||||
var timelineScreen = TimelineScreen(
|
||||
timelineCategory: initalCategory,
|
||||
userId: config.getUserId?.call(context) ?? config.userId,
|
||||
allowAllDeletion: config.canDeleteAllPosts?.call(context) ?? false,
|
||||
onUserTap: (user) => config.onUserTap?.call(context, user),
|
||||
service: config.service,
|
||||
options: config.optionsBuilder(context),
|
||||
onPostTap: (post) async =>
|
||||
config.onPostTap?.call(context, post) ??
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _postDetailScreenRoute(
|
||||
config: config,
|
||||
context: context,
|
||||
post: post,
|
||||
),
|
||||
),
|
||||
),
|
||||
onRefresh: config.onRefresh,
|
||||
filterEnabled: config.filterEnabled,
|
||||
postWidgetBuilder: config.postWidgetBuilder,
|
||||
);
|
||||
var theme = Theme.of(context);
|
||||
var button = FloatingActionButton(
|
||||
backgroundColor: config
|
||||
.optionsBuilder(context)
|
||||
.theme
|
||||
.postCreationFloatingActionButtonColor ??
|
||||
theme.colorScheme.primary,
|
||||
onPressed: () async {
|
||||
var selectedCategory = config.service.postService.selectedCategory;
|
||||
if (selectedCategory != null && selectedCategory.key != null) {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _postCreationScreenRoute(
|
||||
config: config,
|
||||
context: context,
|
||||
category: selectedCategory,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _postCategorySelectionScreen(
|
||||
config: config,
|
||||
context: context,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
shape: const CircleBorder(),
|
||||
child: const Icon(
|
||||
Icons.add,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
);
|
||||
|
||||
return config.homeOpenPageBuilder?.call(context, timelineScreen, button) ??
|
||||
Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
config.optionsBuilder(context).translations.timeLineScreenTitle,
|
||||
style: theme.textTheme.headlineLarge,
|
||||
),
|
||||
),
|
||||
body: timelineScreen,
|
||||
floatingActionButton: button,
|
||||
);
|
||||
}
|
||||
|
||||
/// A widget function that creates a post detail screen route.
|
||||
///
|
||||
/// This function creates a route for displaying a post detail screen. It takes
|
||||
/// a [BuildContext], a [TimelinePost], and an optional
|
||||
/// [TimelineUserStoryConfiguration] as parameters. If no configuration is
|
||||
/// provided, default values will be used.
|
||||
Widget _postDetailScreenRoute({
|
||||
required BuildContext context,
|
||||
required TimelinePost post,
|
||||
required TimelineUserStoryConfiguration config,
|
||||
}) {
|
||||
var timelinePostScreen = TimelinePostScreen(
|
||||
userId: config.getUserId?.call(context) ?? config.userId,
|
||||
allowAllDeletion: config.canDeleteAllPosts?.call(context) ?? false,
|
||||
options: config.optionsBuilder(context),
|
||||
service: config.service,
|
||||
post: post,
|
||||
onPostDelete: () async =>
|
||||
config.onPostDelete?.call(context, post) ??
|
||||
() async {
|
||||
await config.service.postService.deletePost(post);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}.call(),
|
||||
onUserTap: (user) => config.onUserTap?.call(context, user),
|
||||
);
|
||||
|
||||
var category = config.service.postService.categories
|
||||
.firstWhere((element) => element.key == post.category);
|
||||
|
||||
var backButton = IconButton(
|
||||
color: Colors.white,
|
||||
icon: const Icon(Icons.arrow_back_ios),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
);
|
||||
|
||||
return config.postViewOpenPageBuilder
|
||||
?.call(context, timelinePostScreen, backButton, post, category) ??
|
||||
Scaffold(
|
||||
appBar: AppBar(
|
||||
iconTheme: Theme.of(context).appBarTheme.iconTheme,
|
||||
title: Text(
|
||||
category.title.toLowerCase(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: timelinePostScreen,
|
||||
);
|
||||
}
|
||||
|
||||
/// A widget function that creates a post creation screen route.
|
||||
///
|
||||
/// This function creates a route for displaying a post creation screen.
|
||||
/// It takes a [BuildContext] and an optional [TimelineUserStoryConfiguration]
|
||||
/// as parameters. If no configuration is provided, default values will be used.
|
||||
Widget _postCreationScreenRoute({
|
||||
required BuildContext context,
|
||||
required TimelineCategory category,
|
||||
required TimelineUserStoryConfiguration config,
|
||||
}) {
|
||||
var timelinePostCreationScreen = TimelinePostCreationScreen(
|
||||
userId: config.getUserId?.call(context) ?? config.userId,
|
||||
options: config.optionsBuilder(context),
|
||||
service: config.service,
|
||||
onPostCreated: (post) async {
|
||||
var newPost = await config.service.postService.createPost(post);
|
||||
|
||||
if (!context.mounted) return;
|
||||
if (config.afterPostCreationGoHome) {
|
||||
await Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _timelineScreenRoute(
|
||||
config: config,
|
||||
context: context,
|
||||
initalCategory: category.title,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _postOverviewScreenRoute(
|
||||
config: config,
|
||||
context: context,
|
||||
post: newPost,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onPostOverview: (post) async => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _postOverviewScreenRoute(
|
||||
config: config,
|
||||
context: context,
|
||||
post: post,
|
||||
),
|
||||
),
|
||||
),
|
||||
enablePostOverviewScreen: config.enablePostOverviewScreen,
|
||||
postCategory: category.key,
|
||||
);
|
||||
|
||||
var backButton = IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_ios,
|
||||
color: Colors.white,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
);
|
||||
|
||||
return config.postCreationOpenPageBuilder
|
||||
?.call(context, timelinePostCreationScreen, backButton) ??
|
||||
Scaffold(
|
||||
appBar: AppBar(
|
||||
iconTheme: Theme.of(context).appBarTheme.iconTheme,
|
||||
leading: backButton,
|
||||
title: Text(
|
||||
config.optionsBuilder(context).translations.postCreation,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: timelinePostCreationScreen,
|
||||
);
|
||||
}
|
||||
|
||||
/// A widget function that creates a post overview screen route.
|
||||
///
|
||||
/// This function creates a route for displaying a post overview screen.
|
||||
/// It takes a [BuildContext], a [TimelinePost], and an optional
|
||||
/// [TimelineUserStoryConfiguration] as parameters. If no configuration is
|
||||
/// provided, default values will be used.
|
||||
Widget _postOverviewScreenRoute({
|
||||
required BuildContext context,
|
||||
required TimelinePost post,
|
||||
required TimelineUserStoryConfiguration config,
|
||||
}) {
|
||||
var timelinePostOverviewWidget = TimelinePostOverviewScreen(
|
||||
options: config.optionsBuilder(context),
|
||||
service: config.service,
|
||||
timelinePost: post,
|
||||
onPostSubmit: (post) async {
|
||||
var createdPost = await config.service.postService.createPost(post);
|
||||
config.onPostCreate?.call(createdPost);
|
||||
if (context.mounted) {
|
||||
await Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _timelineScreenRoute(
|
||||
config: config,
|
||||
context: context,
|
||||
initalCategory: post.category,
|
||||
),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
var backButton = IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_ios,
|
||||
color: Colors.white,
|
||||
),
|
||||
onPressed: () async => Navigator.of(context).pop(),
|
||||
);
|
||||
|
||||
return config.postOverviewOpenPageBuilder?.call(
|
||||
context,
|
||||
timelinePostOverviewWidget,
|
||||
) ??
|
||||
Scaffold(
|
||||
appBar: AppBar(
|
||||
iconTheme: Theme.of(context).appBarTheme.iconTheme,
|
||||
leading: backButton,
|
||||
title: Text(
|
||||
config.optionsBuilder(context).translations.postCreation,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: timelinePostOverviewWidget,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _postCategorySelectionScreen({
|
||||
required BuildContext context,
|
||||
required TimelineUserStoryConfiguration config,
|
||||
}) {
|
||||
var timelineSelectionScreen = TimelineSelectionScreen(
|
||||
postService: config.service.postService,
|
||||
options: config.optionsBuilder(context),
|
||||
categories: config.service.postService.categories,
|
||||
onCategorySelected: (category) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _postCreationScreenRoute(
|
||||
config: config,
|
||||
context: context,
|
||||
category: category,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
var backButton = IconButton(
|
||||
color: Colors.white,
|
||||
icon: const Icon(Icons.arrow_back_ios),
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
);
|
||||
|
||||
return config.categorySelectionOpenPageBuilder
|
||||
?.call(context, timelineSelectionScreen) ??
|
||||
Scaffold(
|
||||
appBar: AppBar(
|
||||
iconTheme: Theme.of(context).appBarTheme.iconTheme,
|
||||
leading: backButton,
|
||||
title: Text(
|
||||
config.optionsBuilder(context).translations.postCreation,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: timelineSelectionScreen,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> routeToPostDetail(BuildContext context, TimelinePost post) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _postDetailScreenRoute(
|
||||
config: timelineUserStoryConfiguration,
|
||||
context: context,
|
||||
post: post,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
|
@ -0,0 +1,165 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
import 'package:flutter_timeline_view/flutter_timeline_view.dart';
|
||||
|
||||
/// Configuration class for defining user-specific settings and callbacks for a
|
||||
/// timeline user story.
|
||||
///
|
||||
/// This class holds various parameters to customize the behavior and appearance
|
||||
/// of a user story timeline.
|
||||
@immutable
|
||||
class TimelineUserStoryConfiguration {
|
||||
/// Constructs a [TimelineUserStoryConfiguration] with the specified
|
||||
/// parameters.
|
||||
///
|
||||
/// [service] is the TimelineService responsible for fetching user story data.
|
||||
///
|
||||
/// [optionsBuilder] is a function that builds TimelineOptions based on the
|
||||
/// given [BuildContext].
|
||||
///
|
||||
/// [userId] is the ID of the user associated with this user story
|
||||
/// configuration. Default is 'test_user'.
|
||||
///
|
||||
/// [openPageBuilder] is a function that defines the behavior when a page
|
||||
/// needs to be opened. This function should accept a [BuildContext] and a
|
||||
/// child widget.
|
||||
///
|
||||
/// [onPostTap] is a callback function invoked when a timeline post is
|
||||
/// tapped. It should accept a [BuildContext] and the tapped post.
|
||||
///
|
||||
/// [onUserTap] is a callback function invoked when the user's profile is
|
||||
/// tapped. It should accept a [BuildContext] and the user ID of the tapped
|
||||
/// user.
|
||||
///
|
||||
/// [onPostDelete] is a callback function invoked when a post deletion is
|
||||
/// requested. It should accept a [BuildContext] and the post widget. This
|
||||
/// function can return a widget to be displayed after the post is deleted.
|
||||
///
|
||||
/// [filterEnabled] determines whether filtering functionality is enabled for
|
||||
/// this user story timeline. Default is false.
|
||||
///
|
||||
/// [postWidgetBuilder] is a function that builds a widget for a timeline
|
||||
/// post. It should accept a [TimelinePost] and return a widget representing
|
||||
/// that post.
|
||||
const TimelineUserStoryConfiguration({
|
||||
required this.service,
|
||||
required this.optionsBuilder,
|
||||
this.getUserId,
|
||||
this.serviceBuilder,
|
||||
this.canDeleteAllPosts,
|
||||
this.userId = 'test_user',
|
||||
this.homeOpenPageBuilder,
|
||||
this.postCreationOpenPageBuilder,
|
||||
this.postViewOpenPageBuilder,
|
||||
this.postOverviewOpenPageBuilder,
|
||||
this.onPostTap,
|
||||
this.onUserTap,
|
||||
this.onRefresh,
|
||||
this.onPostDelete,
|
||||
this.filterEnabled = false,
|
||||
this.postWidgetBuilder,
|
||||
this.afterPostCreationGoHome = false,
|
||||
this.enablePostOverviewScreen = true,
|
||||
this.categorySelectionOpenPageBuilder,
|
||||
this.onPostCreate,
|
||||
});
|
||||
|
||||
/// The ID of the user associated with this user story configuration.
|
||||
final String userId;
|
||||
|
||||
/// A function to get the userId only when needed and with a context
|
||||
final String Function(BuildContext context)? getUserId;
|
||||
|
||||
/// A function to determine if a user can delete posts that is called
|
||||
/// when needed
|
||||
final bool Function(BuildContext context)? canDeleteAllPosts;
|
||||
|
||||
/// The TimelineService responsible for fetching user story data.
|
||||
final TimelineService service;
|
||||
|
||||
/// A function to get the timeline service only when needed and with a context
|
||||
final TimelineService Function(BuildContext context)? serviceBuilder;
|
||||
|
||||
/// A function that builds TimelineOptions based on the given BuildContext.
|
||||
final TimelineOptions Function(BuildContext context) optionsBuilder;
|
||||
|
||||
/// Open page builder function for the home page. This function accepts
|
||||
/// a [BuildContext], a child widget, and a FloatingActionButton which can
|
||||
/// route to the post creation page.
|
||||
|
||||
final Function(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
FloatingActionButton? button,
|
||||
)? homeOpenPageBuilder;
|
||||
|
||||
/// Open page builder function for the post creation page. This function
|
||||
/// accepts a [BuildContext], a child widget, and an IconButton which can
|
||||
/// route to the home page.
|
||||
|
||||
final Function(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
IconButton? button,
|
||||
)? postCreationOpenPageBuilder;
|
||||
|
||||
/// Open page builder function for the post view page. This function accepts
|
||||
/// a [BuildContext], a child widget, and an IconButton which can route to the
|
||||
/// home page.
|
||||
|
||||
final Function(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
IconButton? button,
|
||||
TimelinePost post,
|
||||
TimelineCategory? category,
|
||||
)? postViewOpenPageBuilder;
|
||||
|
||||
/// Open page builder function for the post overview page. This function
|
||||
/// accepts a [BuildContext], a child widget, and an IconButton which can
|
||||
/// route to the home page.
|
||||
|
||||
final Function(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
)? postOverviewOpenPageBuilder;
|
||||
|
||||
/// A callback function invoked when a timeline post is tapped.
|
||||
final Function(BuildContext context, TimelinePost post)? onPostTap;
|
||||
|
||||
/// A callback function invoked when the user's profile is tapped.
|
||||
final Function(BuildContext context, String userId)? onUserTap;
|
||||
|
||||
/// A callback function invoked when the timeline is refreshed by pulling down
|
||||
final Function(BuildContext context, String? category)? onRefresh;
|
||||
|
||||
/// A callback function invoked when a post deletion is requested.
|
||||
final Widget Function(BuildContext context, TimelinePost post)? onPostDelete;
|
||||
|
||||
/// Determines whether filtering functionality is enabled for this user story
|
||||
/// timeline.
|
||||
final bool filterEnabled;
|
||||
|
||||
/// A function that builds a widget for a timeline post.
|
||||
final Widget Function(TimelinePost post)? postWidgetBuilder;
|
||||
|
||||
/// Boolean to enable timeline post overview screen before submitting
|
||||
final bool enablePostOverviewScreen;
|
||||
|
||||
/// Boolean to enable redirect to home after post creation.
|
||||
/// If false, it will redirect to created post screen
|
||||
final bool afterPostCreationGoHome;
|
||||
|
||||
/// Open page builder function for the category selection page. This function
|
||||
/// accepts a [BuildContext] and a child widget.
|
||||
final Function(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
)? categorySelectionOpenPageBuilder;
|
||||
|
||||
final Function(TimelinePost post)? onPostCreate;
|
||||
}
|
16
packages/flutter_timeline/lib/src/routes.dart
Normal file
16
packages/flutter_timeline/lib/src/routes.dart
Normal file
|
@ -0,0 +1,16 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
mixin TimelineUserStoryRoutes {
|
||||
static const String timelineHome = '/timeline';
|
||||
static const String timelineView = '/timeline-view/:post';
|
||||
static String timelineViewPath(String postId) => '/timeline-view/$postId';
|
||||
static String timelinepostCreation(String category) =>
|
||||
'/timeline-post-creation/$category';
|
||||
|
||||
static const String timelinePostCreation =
|
||||
'/timeline-post-creation/:category';
|
||||
static String timelinePostOverview = '/timeline-post-overview';
|
||||
static String timelineCategorySelection = '/timeline-category-selection';
|
||||
}
|
|
@ -3,33 +3,30 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
name: flutter_timeline
|
||||
description: Visual elements and interface combined into one package
|
||||
version: 0.0.1
|
||||
|
||||
publish_to: none
|
||||
version: 5.1.1
|
||||
homepage: https://github.com/Iconica-Development/flutter_timeline
|
||||
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
|
||||
environment:
|
||||
sdk: '>=3.1.3 <4.0.0'
|
||||
sdk: ">=3.4.3 <4.0.0"
|
||||
flutter: '>=3.22.2'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_timeline_view:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_timeline.git
|
||||
path: packages/flutter_timeline_view
|
||||
ref: 0.0.1
|
||||
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
version: ^5.1.1
|
||||
|
||||
flutter_timeline_interface:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_timeline.git
|
||||
path: packages/flutter_timeline_interface
|
||||
ref: 0.0.1
|
||||
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
version: ^5.1.1
|
||||
collection: ^1.18.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^2.0.0
|
||||
flutter_iconica_analysis:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_iconica_analysis
|
||||
ref: 6.0.0
|
||||
|
||||
flutter:
|
||||
|
||||
|
|
1
packages/flutter_timeline_firebase/CHANGELOG.md
Symbolic link
1
packages/flutter_timeline_firebase/CHANGELOG.md
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../CHANGELOG.md
|
1
packages/flutter_timeline_firebase/LICENSE
Symbolic link
1
packages/flutter_timeline_firebase/LICENSE
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../LICENSE
|
1
packages/flutter_timeline_firebase/README.md
Symbolic link
1
packages/flutter_timeline_firebase/README.md
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../README.md
|
|
@ -6,5 +6,6 @@
|
|||
library flutter_timeline_firebase;
|
||||
|
||||
export 'src/config/firebase_timeline_options.dart';
|
||||
export 'src/service/firebase_post_service.dart';
|
||||
export 'src/service/firebase_timeline_service.dart';
|
||||
export 'src/service/firebase_user_service.dart';
|
||||
|
|
|
@ -2,17 +2,14 @@
|
|||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@immutable
|
||||
class FirebaseTimelineOptions {
|
||||
const FirebaseTimelineOptions({
|
||||
this.usersCollectionName = 'users',
|
||||
this.timelineCollectionName = 'timeline',
|
||||
this.allTimelineCategories = const [],
|
||||
this.timelineCategoryCollectionName = 'timeline_categories',
|
||||
});
|
||||
|
||||
final String usersCollectionName;
|
||||
final String timelineCollectionName;
|
||||
final List<String> allTimelineCategories;
|
||||
final String timelineCategoryCollectionName;
|
||||
}
|
||||
|
|
|
@ -2,9 +2,6 @@
|
|||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@immutable
|
||||
class FirebaseUserDocument {
|
||||
const FirebaseUserDocument({
|
||||
this.firstName,
|
||||
|
|
|
@ -0,0 +1,495 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_firebase/src/config/firebase_timeline_options.dart';
|
||||
import 'package:flutter_timeline_firebase/src/models/firebase_user_document.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class FirebaseTimelinePostService
|
||||
with TimelineUserService, ChangeNotifier
|
||||
implements TimelinePostService {
|
||||
FirebaseTimelinePostService({
|
||||
required TimelineUserService userService,
|
||||
FirebaseApp? app,
|
||||
FirebaseTimelineOptions? options,
|
||||
}) {
|
||||
var appInstance = app ?? Firebase.app();
|
||||
_db = FirebaseFirestore.instanceFor(app: appInstance);
|
||||
_storage = FirebaseStorage.instanceFor(app: appInstance);
|
||||
_userService = userService;
|
||||
_options = options ?? const FirebaseTimelineOptions();
|
||||
}
|
||||
|
||||
late FirebaseFirestore _db;
|
||||
late FirebaseStorage _storage;
|
||||
late TimelineUserService _userService;
|
||||
late FirebaseTimelineOptions _options;
|
||||
|
||||
final Map<String, TimelinePosterUserModel> _users = {};
|
||||
|
||||
@override
|
||||
List<TimelinePost> posts = [];
|
||||
|
||||
@override
|
||||
List<TimelineCategory> categories = [];
|
||||
|
||||
@override
|
||||
TimelineCategory? selectedCategory;
|
||||
|
||||
@override
|
||||
Future<TimelinePost> createPost(TimelinePost post) async {
|
||||
var postId = const Uuid().v4();
|
||||
var user = await _userService.getUser(post.creatorId);
|
||||
var updatedPost = post.copyWith(id: postId, creator: user);
|
||||
if (post.image != null) {
|
||||
var imageRef =
|
||||
_storage.ref().child('${_options.timelineCollectionName}/$postId');
|
||||
var result = await imageRef.putData(post.image!);
|
||||
var imageUrl = await result.ref.getDownloadURL();
|
||||
updatedPost = updatedPost.copyWith(imageUrl: imageUrl);
|
||||
}
|
||||
var postRef =
|
||||
_db.collection(_options.timelineCollectionName).doc(updatedPost.id);
|
||||
await postRef.set(updatedPost.toJson());
|
||||
posts.add(updatedPost);
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deletePost(TimelinePost post) async {
|
||||
posts = posts.where((element) => element.id != post.id).toList();
|
||||
var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
|
||||
await postRef.delete();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<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();
|
||||
var postRef =
|
||||
_db.collection(_options.timelineCollectionName).doc(post.id);
|
||||
await postRef.update({
|
||||
'reaction': FieldValue.increment(-1),
|
||||
'reactions': FieldValue.arrayRemove(
|
||||
[reaction.toJsonWithMicroseconds()],
|
||||
),
|
||||
});
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
return post;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> fetchPostDetails(TimelinePost post) async {
|
||||
var reactions = post.reactions ?? [];
|
||||
var updatedReactions = <TimelinePostReaction>[];
|
||||
for (var reaction in reactions) {
|
||||
var user = await _userService.getUser(reaction.creatorId);
|
||||
if (user != null) {
|
||||
updatedReactions.add(reaction.copyWith(creator: user));
|
||||
}
|
||||
}
|
||||
var updatedPost = post.copyWith(
|
||||
reactions: updatedReactions,
|
||||
creator: await _userService.getUser(post.creatorId),
|
||||
);
|
||||
posts = posts.map((p) => (p.id == post.id) ? updatedPost : p).toList();
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TimelinePost>> fetchPosts(String? category) async {
|
||||
var snapshot = (category != null)
|
||||
? await _db
|
||||
.collection(_options.timelineCollectionName)
|
||||
.where('category', isEqualTo: category)
|
||||
.get()
|
||||
: await _db.collection(_options.timelineCollectionName).get();
|
||||
|
||||
var fetchedPosts = <TimelinePost>[];
|
||||
for (var doc in snapshot.docs) {
|
||||
var data = doc.data();
|
||||
var user = await _userService.getUser(data['creator_id']);
|
||||
var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user);
|
||||
fetchedPosts.add(post);
|
||||
}
|
||||
|
||||
posts = fetchedPosts;
|
||||
|
||||
notifyListeners();
|
||||
return posts;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TimelinePost>> fetchPostsPaginated(
|
||||
String? category,
|
||||
int limit,
|
||||
) async {
|
||||
// only take posts that are in our category
|
||||
var oldestPost = posts
|
||||
.where(
|
||||
(element) => category == null || element.category == category,
|
||||
)
|
||||
.fold(
|
||||
posts.first,
|
||||
(previousValue, element) =>
|
||||
(previousValue.createdAt.isBefore(element.createdAt))
|
||||
? previousValue
|
||||
: element,
|
||||
);
|
||||
var snapshot = (category != null)
|
||||
? await _db
|
||||
.collection(_options.timelineCollectionName)
|
||||
.where('category', isEqualTo: category)
|
||||
.orderBy('created_at', descending: true)
|
||||
.startAfter([oldestPost])
|
||||
.limit(limit)
|
||||
.get()
|
||||
: await _db
|
||||
.collection(_options.timelineCollectionName)
|
||||
.orderBy('created_at', descending: true)
|
||||
.startAfter([oldestPost.createdAt])
|
||||
.limit(limit)
|
||||
.get();
|
||||
// add the new posts to the list
|
||||
var newPosts = <TimelinePost>[];
|
||||
for (var doc in snapshot.docs) {
|
||||
var data = doc.data();
|
||||
var user = await _userService.getUser(data['creator_id']);
|
||||
var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user);
|
||||
newPosts.add(post);
|
||||
}
|
||||
posts = [...posts, ...newPosts];
|
||||
notifyListeners();
|
||||
return newPosts;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> fetchPost(TimelinePost post) async {
|
||||
var doc = await _db
|
||||
.collection(_options.timelineCollectionName)
|
||||
.doc(post.id)
|
||||
.get();
|
||||
var data = doc.data();
|
||||
if (data == null) return post;
|
||||
var user = await _userService.getUser(data['creator_id']);
|
||||
var updatedPost = TimelinePost.fromJson(doc.id, data).copyWith(
|
||||
creator: user,
|
||||
);
|
||||
posts = posts.map((p) => (p.id == post.id) ? updatedPost : p).toList();
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TimelinePost>> refreshPosts(String? category) async {
|
||||
// fetch all posts between now and the newest posts we have
|
||||
var newestPostWeHave = posts
|
||||
.where(
|
||||
(element) => category == null || element.category == category,
|
||||
)
|
||||
.fold(
|
||||
posts.first,
|
||||
(previousValue, element) =>
|
||||
(previousValue.createdAt.isAfter(element.createdAt))
|
||||
? previousValue
|
||||
: element,
|
||||
);
|
||||
var snapshot = (category != null)
|
||||
? await _db
|
||||
.collection(_options.timelineCollectionName)
|
||||
.where('category', isEqualTo: category)
|
||||
.orderBy('created_at', descending: true)
|
||||
.endBefore([newestPostWeHave.createdAt]).get()
|
||||
: await _db
|
||||
.collection(_options.timelineCollectionName)
|
||||
.orderBy('created_at', descending: true)
|
||||
.endBefore([newestPostWeHave.createdAt]).get();
|
||||
// add the new posts to the list
|
||||
var newPosts = <TimelinePost>[];
|
||||
for (var doc in snapshot.docs) {
|
||||
var data = doc.data();
|
||||
var user = await _userService.getUser(data['creator_id']);
|
||||
var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user);
|
||||
newPosts.add(post);
|
||||
}
|
||||
posts = [...posts, ...newPosts];
|
||||
notifyListeners();
|
||||
return newPosts;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost?> getPost(String postId) async {
|
||||
var post = await _db
|
||||
.collection(_options.timelineCollectionName)
|
||||
.doc(postId)
|
||||
.withConverter<TimelinePost>(
|
||||
fromFirestore: (snapshot, _) => TimelinePost.fromJson(
|
||||
snapshot.id,
|
||||
snapshot.data()!,
|
||||
),
|
||||
toFirestore: (user, _) => user.toJson(),
|
||||
)
|
||||
.get();
|
||||
return post.data();
|
||||
}
|
||||
|
||||
@override
|
||||
List<TimelinePost> getPosts(String? category) => posts
|
||||
.where((element) => category == null || element.category == category)
|
||||
.toList();
|
||||
|
||||
@override
|
||||
Future<TimelinePost> likePost(String userId, TimelinePost post) async {
|
||||
// update the post with the new like
|
||||
var updatedPost = post.copyWith(
|
||||
likes: post.likes + 1,
|
||||
likedBy: [...post.likedBy ?? [], userId],
|
||||
);
|
||||
posts = posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
|
||||
await postRef.update({
|
||||
'likes': FieldValue.increment(1),
|
||||
'liked_by': FieldValue.arrayUnion([userId]),
|
||||
});
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> unlikePost(String userId, TimelinePost post) async {
|
||||
// update the post with the new like
|
||||
var updatedPost = post.copyWith(
|
||||
likes: post.likes - 1,
|
||||
likedBy: post.likedBy?..remove(userId),
|
||||
);
|
||||
posts = posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
|
||||
await postRef.update({
|
||||
'likes': FieldValue.increment(-1),
|
||||
'liked_by': FieldValue.arrayRemove([userId]),
|
||||
});
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> reactToPost(
|
||||
TimelinePost post,
|
||||
TimelinePostReaction reaction, {
|
||||
Uint8List? image,
|
||||
}) async {
|
||||
var reactionId = const Uuid().v4();
|
||||
// also fetch the user information and add it to the reaction
|
||||
var user = await _userService.getUser(reaction.creatorId);
|
||||
var updatedReaction = reaction.copyWith(id: reactionId, creator: user);
|
||||
if (image != null) {
|
||||
var imageRef = _storage
|
||||
.ref()
|
||||
.child('${_options.timelineCollectionName}/${post.id}/$reactionId}');
|
||||
var result = await imageRef.putData(image);
|
||||
var imageUrl = await result.ref.getDownloadURL();
|
||||
updatedReaction = updatedReaction.copyWith(imageUrl: imageUrl);
|
||||
}
|
||||
|
||||
var updatedPost = post.copyWith(
|
||||
reaction: post.reaction + 1,
|
||||
reactions: post.reactions?..add(updatedReaction),
|
||||
);
|
||||
|
||||
var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
|
||||
await postRef.update({
|
||||
'reaction': FieldValue.increment(1),
|
||||
'reactions': FieldValue.arrayUnion([updatedReaction.toJson()]),
|
||||
});
|
||||
posts = posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
CollectionReference<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;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> addCategory(TimelineCategory category) async {
|
||||
var exists = categories.firstWhereOrNull(
|
||||
(element) => element.title.toLowerCase() == category.title.toLowerCase(),
|
||||
);
|
||||
if (exists != null) return false;
|
||||
try {
|
||||
await _db
|
||||
.collection(_options.timelineCategoryCollectionName)
|
||||
.add(category.toJson());
|
||||
categories.add(category);
|
||||
notifyListeners();
|
||||
return true;
|
||||
} on Exception catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TimelineCategory>> fetchCategories() async {
|
||||
categories.clear();
|
||||
categories.add(
|
||||
const TimelineCategory(
|
||||
key: null,
|
||||
title: 'All',
|
||||
),
|
||||
);
|
||||
var categoriesSnapshot = await _db
|
||||
.collection(_options.timelineCategoryCollectionName)
|
||||
.withConverter(
|
||||
fromFirestore: (snapshot, _) =>
|
||||
TimelineCategory.fromJson(snapshot.data()!),
|
||||
toFirestore: (model, _) => model.toJson(),
|
||||
)
|
||||
.get();
|
||||
categories.addAll(categoriesSnapshot.docs.map((e) => e.data()));
|
||||
|
||||
notifyListeners();
|
||||
return categories;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> likeReaction(
|
||||
String userId,
|
||||
TimelinePost post,
|
||||
String reactionId,
|
||||
) async {
|
||||
// update the post with the new like
|
||||
var updatedPost = post.copyWith(
|
||||
reactions: post.reactions?.map(
|
||||
(r) {
|
||||
if (r.id == reactionId) {
|
||||
return r.copyWith(
|
||||
likedBy: (r.likedBy ?? [])..add(userId),
|
||||
);
|
||||
}
|
||||
return r;
|
||||
},
|
||||
).toList(),
|
||||
);
|
||||
posts = posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
|
||||
await postRef.update({
|
||||
'reactions': post.reactions
|
||||
?.map(
|
||||
(r) =>
|
||||
r.id == reactionId ? r.copyWith(likedBy: r.likedBy ?? []) : r,
|
||||
)
|
||||
.map((e) => e.toJson())
|
||||
.toList(),
|
||||
});
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> unlikeReaction(
|
||||
String userId,
|
||||
TimelinePost post,
|
||||
String reactionId,
|
||||
) async {
|
||||
// update the post with the new like
|
||||
var updatedPost = post.copyWith(
|
||||
reactions: post.reactions?.map(
|
||||
(r) {
|
||||
if (r.id == reactionId) {
|
||||
return r.copyWith(
|
||||
likedBy: r.likedBy?..remove(userId),
|
||||
);
|
||||
}
|
||||
return r;
|
||||
},
|
||||
).toList(),
|
||||
);
|
||||
posts = posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
|
||||
await postRef.update({
|
||||
'reactions': post.reactions
|
||||
?.map(
|
||||
(r) => r.id == reactionId
|
||||
? r.copyWith(likedBy: r.likedBy?..remove(userId))
|
||||
: r,
|
||||
)
|
||||
.map((e) => e.toJson())
|
||||
.toList(),
|
||||
});
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
}
|
|
@ -1,202 +1,53 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_firebase/src/config/firebase_timeline_options.dart';
|
||||
import 'package:flutter_timeline_firebase/flutter_timeline_firebase.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class FirebaseTimelineService with ChangeNotifier implements TimelineService {
|
||||
class FirebaseTimelineService implements TimelineService {
|
||||
FirebaseTimelineService({
|
||||
required TimelineUserService userService,
|
||||
FirebaseApp? app,
|
||||
options = const FirebaseTimelineOptions(),
|
||||
this.options,
|
||||
this.app,
|
||||
this.firebasePostService,
|
||||
this.firebaseUserService,
|
||||
}) {
|
||||
var appInstance = app ?? Firebase.app();
|
||||
_db = FirebaseFirestore.instanceFor(app: appInstance);
|
||||
_storage = FirebaseStorage.instanceFor(app: appInstance);
|
||||
_userService = userService;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
late FirebaseFirestore _db;
|
||||
late FirebaseStorage _storage;
|
||||
late TimelineUserService _userService;
|
||||
late FirebaseTimelineOptions _options;
|
||||
|
||||
List<TimelinePost> _posts = [];
|
||||
|
||||
@override
|
||||
Future<TimelinePost> createPost(TimelinePost post) async {
|
||||
var postId = const Uuid().v4();
|
||||
var user = await _userService.getUser(post.creatorId);
|
||||
var updatedPost = post.copyWith(id: postId, creator: user);
|
||||
if (post.image != null) {
|
||||
var imageRef =
|
||||
_storage.ref().child('${_options.timelineCollectionName}/$postId');
|
||||
var result = await imageRef.putData(post.image!);
|
||||
var imageUrl = await result.ref.getDownloadURL();
|
||||
updatedPost = updatedPost.copyWith(imageUrl: imageUrl);
|
||||
}
|
||||
var postRef =
|
||||
_db.collection(_options.timelineCollectionName).doc(updatedPost.id);
|
||||
await postRef.set(updatedPost.toJson());
|
||||
_posts.add(updatedPost);
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deletePost(TimelinePost post) async {
|
||||
_posts = _posts.where((element) => element.id != post.id).toList();
|
||||
var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
|
||||
await postRef.delete();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> fetchPostDetails(TimelinePost post) async {
|
||||
var reactions = post.reactions ?? [];
|
||||
var updatedReactions = <TimelinePostReaction>[];
|
||||
for (var reaction in reactions) {
|
||||
var user = await _userService.getUser(reaction.creatorId);
|
||||
if (user != null) {
|
||||
updatedReactions.add(reaction.copyWith(creator: user));
|
||||
}
|
||||
}
|
||||
var updatedPost = post.copyWith(reactions: updatedReactions);
|
||||
_posts = _posts.map((p) => (p.id == post.id) ? updatedPost : p).toList();
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TimelinePost>> fetchPosts(String? category) async {
|
||||
debugPrint('fetching posts from firebase with category: $category');
|
||||
var snapshot = (category != null)
|
||||
? await _db
|
||||
.collection(_options.timelineCollectionName)
|
||||
.where('category', isEqualTo: category)
|
||||
.get()
|
||||
: await _db.collection(_options.timelineCollectionName).get();
|
||||
|
||||
var posts = <TimelinePost>[];
|
||||
for (var doc in snapshot.docs) {
|
||||
var data = doc.data();
|
||||
var user = await _userService.getUser(data['creator_id']);
|
||||
var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user);
|
||||
posts.add(post);
|
||||
}
|
||||
_posts = posts;
|
||||
notifyListeners();
|
||||
return posts;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> fetchPost(TimelinePost post) async {
|
||||
var doc = await _db
|
||||
.collection(_options.timelineCollectionName)
|
||||
.doc(post.id)
|
||||
.get();
|
||||
var data = doc.data();
|
||||
if (data == null) return post;
|
||||
var user = await _userService.getUser(data['creator_id']);
|
||||
var updatedPost = TimelinePost.fromJson(doc.id, data).copyWith(
|
||||
creator: user,
|
||||
);
|
||||
_posts = _posts.map((p) => (p.id == post.id) ? updatedPost : p).toList();
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
@override
|
||||
List<TimelinePost> getPosts(String? category) => _posts
|
||||
.where((element) => category == null || element.category == category)
|
||||
.toList();
|
||||
|
||||
@override
|
||||
Future<TimelinePost> likePost(String userId, TimelinePost post) async {
|
||||
// update the post with the new like
|
||||
var updatedPost = post.copyWith(
|
||||
likes: post.likes + 1,
|
||||
likedBy: post.likedBy?..add(userId),
|
||||
);
|
||||
_posts = _posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
|
||||
await postRef.update({
|
||||
'likes': FieldValue.increment(1),
|
||||
'liked_by': FieldValue.arrayUnion([userId]),
|
||||
});
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> unlikePost(String userId, TimelinePost post) async {
|
||||
// update the post with the new like
|
||||
var updatedPost = post.copyWith(
|
||||
likes: post.likes - 1,
|
||||
likedBy: post.likedBy?..remove(userId),
|
||||
);
|
||||
_posts = _posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
|
||||
await postRef.update({
|
||||
'likes': FieldValue.increment(-1),
|
||||
'liked_by': FieldValue.arrayRemove([userId]),
|
||||
});
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> reactToPost(
|
||||
TimelinePost post,
|
||||
TimelinePostReaction reaction, {
|
||||
Uint8List? image,
|
||||
}) async {
|
||||
var reactionId = const Uuid().v4();
|
||||
// also fetch the user information and add it to the reaction
|
||||
var user = await _userService.getUser(reaction.creatorId);
|
||||
var updatedReaction = reaction.copyWith(id: reactionId, creator: user);
|
||||
if (image != null) {
|
||||
var imageRef = _storage
|
||||
.ref()
|
||||
.child('${_options.timelineCollectionName}/${post.id}/$reactionId}');
|
||||
var result = await imageRef.putData(image);
|
||||
var imageUrl = await result.ref.getDownloadURL();
|
||||
updatedReaction = updatedReaction.copyWith(imageUrl: imageUrl);
|
||||
}
|
||||
|
||||
var updatedPost = post.copyWith(
|
||||
reaction: post.reaction + 1,
|
||||
reactions: post.reactions?..add(updatedReaction),
|
||||
firebaseUserService ??= FirebaseTimelineUserService(
|
||||
options: options,
|
||||
app: app,
|
||||
);
|
||||
|
||||
var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
|
||||
await postRef.update({
|
||||
'reaction': FieldValue.increment(1),
|
||||
'reactions': FieldValue.arrayUnion([updatedReaction.toJson()]),
|
||||
});
|
||||
_posts = _posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
firebasePostService ??= FirebaseTimelinePostService(
|
||||
userService: userService,
|
||||
options: options,
|
||||
app: app,
|
||||
);
|
||||
}
|
||||
|
||||
final FirebaseTimelineOptions? options;
|
||||
final FirebaseApp? app;
|
||||
TimelinePostService? firebasePostService;
|
||||
TimelineUserService? firebaseUserService;
|
||||
|
||||
@override
|
||||
TimelinePostService get postService {
|
||||
if (firebasePostService != null) {
|
||||
return firebasePostService!;
|
||||
} else {
|
||||
return FirebaseTimelinePostService(
|
||||
userService: userService,
|
||||
options: options,
|
||||
app: app,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
TimelineUserService get userService {
|
||||
if (firebaseUserService != null) {
|
||||
return firebaseUserService!;
|
||||
} else {
|
||||
return FirebaseTimelineUserService(
|
||||
options: options,
|
||||
app: app,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,14 +8,14 @@ import 'package:flutter_timeline_firebase/src/config/firebase_timeline_options.d
|
|||
import 'package:flutter_timeline_firebase/src/models/firebase_user_document.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
|
||||
class FirebaseUserService implements TimelineUserService {
|
||||
FirebaseUserService({
|
||||
class FirebaseTimelineUserService implements TimelineUserService {
|
||||
FirebaseTimelineUserService({
|
||||
FirebaseApp? app,
|
||||
options = const FirebaseTimelineOptions(),
|
||||
FirebaseTimelineOptions? options,
|
||||
}) {
|
||||
var appInstance = app ?? Firebase.app();
|
||||
_db = FirebaseFirestore.instanceFor(app: appInstance);
|
||||
_options = options;
|
||||
_options = options ?? const FirebaseTimelineOptions();
|
||||
}
|
||||
|
||||
late FirebaseFirestore _db;
|
||||
|
|
|
@ -4,33 +4,30 @@
|
|||
|
||||
name: flutter_timeline_firebase
|
||||
description: Implementation of the Flutter Timeline interface for Firebase.
|
||||
version: 0.0.1
|
||||
|
||||
publish_to: none
|
||||
version: 5.1.1
|
||||
homepage: https://github.com/Iconica-Development/flutter_timeline
|
||||
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
|
||||
environment:
|
||||
sdk: '>=3.1.3 <4.0.0'
|
||||
sdk: ">=3.4.3 <4.0.0"
|
||||
flutter: '>=3.22.2'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
cloud_firestore: ^4.13.1
|
||||
firebase_core: ^2.22.0
|
||||
firebase_storage: ^11.5.1
|
||||
cloud_firestore: '>=4.13.1 <6.0.0'
|
||||
firebase_core: '>=2.22.0 <4.0.0'
|
||||
firebase_storage: '>=11.5.1 <13.0.0'
|
||||
uuid: ^4.2.1
|
||||
|
||||
collection: ^1.18.0
|
||||
flutter_timeline_interface:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_timeline.git
|
||||
path: packages/flutter_timeline_interface
|
||||
ref: 0.0.1
|
||||
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
version: ^5.1.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^2.0.0
|
||||
flutter_iconica_analysis:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_iconica_analysis
|
||||
ref: 6.0.0
|
||||
|
||||
flutter:
|
||||
|
||||
|
|
1
packages/flutter_timeline_interface/CHANGELOG.md
Symbolic link
1
packages/flutter_timeline_interface/CHANGELOG.md
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../CHANGELOG.md
|
1
packages/flutter_timeline_interface/LICENSE
Symbolic link
1
packages/flutter_timeline_interface/LICENSE
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../LICENSE
|
1
packages/flutter_timeline_interface/README.md
Symbolic link
1
packages/flutter_timeline_interface/README.md
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../README.md
|
|
@ -4,9 +4,11 @@
|
|||
///
|
||||
library flutter_timeline_interface;
|
||||
|
||||
export 'src/model/timeline_category.dart';
|
||||
export 'src/model/timeline_post.dart';
|
||||
export 'src/model/timeline_poster.dart';
|
||||
export 'src/model/timeline_reaction.dart';
|
||||
|
||||
export 'src/services/filter_service.dart';
|
||||
export 'src/services/timeline_post_service.dart';
|
||||
export 'src/services/timeline_service.dart';
|
||||
export 'src/services/user_service.dart';
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
@immutable
|
||||
class TimelineCategory {
|
||||
const TimelineCategory({
|
||||
required this.key,
|
||||
required this.title,
|
||||
this.icon,
|
||||
this.canCreate = true,
|
||||
this.canView = true,
|
||||
});
|
||||
|
||||
TimelineCategory.fromJson(Map<String, dynamic> json)
|
||||
: key = json['key'] as String?,
|
||||
title = json['title'] as String,
|
||||
icon = json['icon'] as Widget?,
|
||||
canCreate = json['canCreate'] as bool? ?? true,
|
||||
canView = json['canView'] as bool? ?? true;
|
||||
|
||||
final String? key;
|
||||
final String title;
|
||||
final Widget? icon;
|
||||
final bool canCreate;
|
||||
final bool canView;
|
||||
|
||||
TimelineCategory copyWith({
|
||||
String? key,
|
||||
String? title,
|
||||
Widget? icon,
|
||||
bool? canCreate,
|
||||
bool? canView,
|
||||
}) =>
|
||||
TimelineCategory(
|
||||
key: key ?? this.key,
|
||||
title: title ?? this.title,
|
||||
icon: icon ?? this.icon,
|
||||
canCreate: canCreate ?? this.canCreate,
|
||||
canView: canView ?? this.canView,
|
||||
);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'key': key,
|
||||
'title': title,
|
||||
'icon': icon,
|
||||
'canCreate': canCreate,
|
||||
'canView': canView,
|
||||
};
|
||||
}
|
|
@ -15,17 +15,18 @@ class TimelinePost {
|
|||
required this.id,
|
||||
required this.creatorId,
|
||||
required this.title,
|
||||
required this.category,
|
||||
required this.content,
|
||||
required this.likes,
|
||||
required this.reaction,
|
||||
required this.createdAt,
|
||||
required this.reactionEnabled,
|
||||
this.category,
|
||||
this.creator,
|
||||
this.likedBy,
|
||||
this.reactions,
|
||||
this.imageUrl,
|
||||
this.image,
|
||||
this.data = const {},
|
||||
});
|
||||
|
||||
factory TimelinePost.fromJson(String id, Map<String, dynamic> json) =>
|
||||
|
@ -33,7 +34,7 @@ class TimelinePost {
|
|||
id: id,
|
||||
creatorId: json['creator_id'] as String,
|
||||
title: json['title'] as String,
|
||||
category: json['category'] as String,
|
||||
category: json['category'] as String?,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
content: json['content'] as String,
|
||||
likes: json['likes'] as int,
|
||||
|
@ -50,6 +51,7 @@ class TimelinePost {
|
|||
.toList(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
reactionEnabled: json['reaction_enabled'] as bool,
|
||||
data: json['data'] ?? {},
|
||||
);
|
||||
|
||||
/// The unique identifier of the post.
|
||||
|
@ -65,7 +67,7 @@ class TimelinePost {
|
|||
final String title;
|
||||
|
||||
/// The category of the post on which can be filtered.
|
||||
final String category;
|
||||
final String? category;
|
||||
|
||||
/// The url of the image of the post.
|
||||
final String? imageUrl;
|
||||
|
@ -94,6 +96,9 @@ class TimelinePost {
|
|||
/// If reacting is enabled on the post.
|
||||
final bool reactionEnabled;
|
||||
|
||||
/// Option to add extra data to a timelinepost that won't be shown anywhere
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
TimelinePost copyWith({
|
||||
String? id,
|
||||
String? creatorId,
|
||||
|
@ -109,6 +114,7 @@ class TimelinePost {
|
|||
List<TimelinePostReaction>? reactions,
|
||||
DateTime? createdAt,
|
||||
bool? reactionEnabled,
|
||||
Map<String, dynamic>? data,
|
||||
}) =>
|
||||
TimelinePost(
|
||||
id: id ?? this.id,
|
||||
|
@ -125,6 +131,7 @@ class TimelinePost {
|
|||
reactions: reactions ?? this.reactions,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
reactionEnabled: reactionEnabled ?? this.reactionEnabled,
|
||||
data: data ?? this.data,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
|
@ -140,5 +147,6 @@ class TimelinePost {
|
|||
'reactions': reactions?.map((e) => e.toJson()).toList() ?? [],
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'reaction_enabled': reactionEnabled,
|
||||
'data': data,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -13,11 +13,28 @@ class TimelinePosterUserModel {
|
|||
this.imageUrl,
|
||||
});
|
||||
|
||||
factory TimelinePosterUserModel.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
String userId,
|
||||
) =>
|
||||
TimelinePosterUserModel(
|
||||
userId: userId,
|
||||
firstName: json['first_name'] as String?,
|
||||
lastName: json['last_name'] as String?,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
);
|
||||
|
||||
final String userId;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String? imageUrl;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'first_name': firstName,
|
||||
'last_name': lastName,
|
||||
'image_url': imageUrl,
|
||||
};
|
||||
|
||||
String? get fullName {
|
||||
var fullName = '';
|
||||
|
||||
|
|
|
@ -15,6 +15,8 @@ class TimelinePostReaction {
|
|||
this.reaction,
|
||||
this.imageUrl,
|
||||
this.creator,
|
||||
this.createdAtString,
|
||||
this.likedBy,
|
||||
});
|
||||
|
||||
factory TimelinePostReaction.fromJson(
|
||||
|
@ -29,6 +31,8 @@ class TimelinePostReaction {
|
|||
reaction: json['reaction'] as String?,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
createdAtString: json['created_at'] as String,
|
||||
likedBy: (json['liked_by'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
);
|
||||
|
||||
/// The unique identifier of the reaction.
|
||||
|
@ -52,6 +56,11 @@ class TimelinePostReaction {
|
|||
/// Reaction creation date.
|
||||
final DateTime createdAt;
|
||||
|
||||
/// Reaction creation date as String with microseconds.
|
||||
final String? createdAtString;
|
||||
|
||||
final List<String>? likedBy;
|
||||
|
||||
TimelinePostReaction copyWith({
|
||||
String? id,
|
||||
String? postId,
|
||||
|
@ -60,6 +69,7 @@ class TimelinePostReaction {
|
|||
String? reaction,
|
||||
String? imageUrl,
|
||||
DateTime? createdAt,
|
||||
List<String>? likedBy,
|
||||
}) =>
|
||||
TimelinePostReaction(
|
||||
id: id ?? this.id,
|
||||
|
@ -69,6 +79,7 @@ class TimelinePostReaction {
|
|||
reaction: reaction ?? this.reaction,
|
||||
imageUrl: imageUrl ?? this.imageUrl,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
likedBy: likedBy ?? this.likedBy,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
|
@ -77,6 +88,17 @@ class TimelinePostReaction {
|
|||
'reaction': reaction,
|
||||
'image_url': imageUrl,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'liked_by': likedBy,
|
||||
},
|
||||
};
|
||||
|
||||
Map<String, dynamic> toJsonWithMicroseconds() => <String, dynamic>{
|
||||
id: {
|
||||
'creator_id': creatorId,
|
||||
'reaction': reaction,
|
||||
'image_url': imageUrl,
|
||||
'created_at': createdAtString,
|
||||
'liked_by': likedBy,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 TimelinePostService {
|
||||
List<TimelinePost> filterPosts(
|
||||
String filterWord,
|
||||
Map<String, dynamic> options,
|
||||
) {
|
||||
var filteredPosts = posts
|
||||
.where(
|
||||
(post) => post.title.toLowerCase().contains(
|
||||
filterWord.toLowerCase(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return filteredPosts;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
|
||||
abstract class TimelinePostService with ChangeNotifier {
|
||||
List<TimelinePost> posts = [];
|
||||
List<TimelineCategory> categories = [];
|
||||
TimelineCategory? selectedCategory;
|
||||
|
||||
Future<void> deletePost(TimelinePost post);
|
||||
Future<TimelinePost> deletePostReaction(TimelinePost post, String reactionId);
|
||||
Future<TimelinePost> createPost(TimelinePost post);
|
||||
Future<List<TimelinePost>> fetchPosts(String? category);
|
||||
Future<TimelinePost> fetchPost(TimelinePost post);
|
||||
Future<List<TimelinePost>> fetchPostsPaginated(String? category, int limit);
|
||||
Future<TimelinePost?> getPost(String postId);
|
||||
List<TimelinePost> getPosts(String? category);
|
||||
Future<List<TimelinePost>> refreshPosts(String? category);
|
||||
Future<TimelinePost> fetchPostDetails(TimelinePost post);
|
||||
Future<TimelinePost> reactToPost(
|
||||
TimelinePost post,
|
||||
TimelinePostReaction reaction, {
|
||||
Uint8List image,
|
||||
});
|
||||
Future<TimelinePost> likePost(String userId, TimelinePost post);
|
||||
Future<TimelinePost> unlikePost(String userId, TimelinePost post);
|
||||
|
||||
Future<List<TimelineCategory>> fetchCategories();
|
||||
Future<bool> addCategory(TimelineCategory category);
|
||||
Future<TimelinePost> likeReaction(
|
||||
String userId,
|
||||
TimelinePost post,
|
||||
String reactionId,
|
||||
);
|
||||
Future<TimelinePost> unlikeReaction(
|
||||
String userId,
|
||||
TimelinePost post,
|
||||
String reactionId,
|
||||
);
|
||||
}
|
|
@ -1,25 +1,12 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
import 'package:flutter_timeline_interface/src/services/timeline_post_service.dart';
|
||||
import 'package:flutter_timeline_interface/src/services/user_service.dart';
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_interface/src/model/timeline_post.dart';
|
||||
import 'package:flutter_timeline_interface/src/model/timeline_reaction.dart';
|
||||
|
||||
abstract class TimelineService with ChangeNotifier {
|
||||
Future<void> deletePost(TimelinePost post);
|
||||
Future<TimelinePost> createPost(TimelinePost post);
|
||||
Future<List<TimelinePost>> fetchPosts(String? category);
|
||||
Future<TimelinePost> fetchPost(TimelinePost post);
|
||||
List<TimelinePost> getPosts(String? category);
|
||||
Future<TimelinePost> fetchPostDetails(TimelinePost post);
|
||||
Future<TimelinePost> reactToPost(
|
||||
TimelinePost post,
|
||||
TimelinePostReaction reaction, {
|
||||
Uint8List image,
|
||||
class TimelineService {
|
||||
TimelineService({
|
||||
required this.postService,
|
||||
this.userService,
|
||||
});
|
||||
Future<TimelinePost> likePost(String userId, TimelinePost post);
|
||||
Future<TimelinePost> unlikePost(String userId, TimelinePost post);
|
||||
|
||||
final TimelinePostService postService;
|
||||
final TimelineUserService? userService;
|
||||
}
|
||||
|
|
|
@ -4,23 +4,18 @@
|
|||
|
||||
name: flutter_timeline_interface
|
||||
description: Interface for the service of the Flutter Timeline component
|
||||
version: 0.0.1
|
||||
|
||||
publish_to: none
|
||||
version: 5.1.1
|
||||
homepage: https://github.com/Iconica-Development/flutter_timeline
|
||||
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
|
||||
environment:
|
||||
sdk: '>=3.1.3 <4.0.0'
|
||||
sdk: '>=3.4.3 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_data_interface:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_data_interface.git
|
||||
ref: 1.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^2.0.0
|
||||
flutter_iconica_analysis:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_iconica_analysis
|
||||
|
|
1
packages/flutter_timeline_view/CHANGELOG.md
Symbolic link
1
packages/flutter_timeline_view/CHANGELOG.md
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../CHANGELOG.md
|
1
packages/flutter_timeline_view/LICENSE
Symbolic link
1
packages/flutter_timeline_view/LICENSE
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../LICENSE
|
1
packages/flutter_timeline_view/README.md
Symbolic link
1
packages/flutter_timeline_view/README.md
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../README.md
|
3
packages/flutter_timeline_view/assets/Comment.svg
Normal file
3
packages/flutter_timeline_view/assets/Comment.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.5 3.5C14.3587 3.5 17.5 6.64125 17.5 10.5C17.5 14.3587 14.3587 17.5 10.5 17.5C9.4675 17.5 8.4525 17.2725 7.49875 16.8175C7.2625 16.7037 7.00875 16.6513 6.74625 16.6513C6.58 16.6513 6.41375 16.6775 6.25625 16.7213L3.45625 17.5437L4.27875 14.7438C4.40125 14.3325 4.36625 13.8863 4.1825 13.5013C3.7275 12.5475 3.5 11.5325 3.5 10.5C3.5 6.64125 6.64125 3.5 10.5 3.5ZM10.5 1.75C5.67 1.75 1.75 5.67 1.75 10.5C1.75 11.8475 2.065 13.1075 2.59875 14.2538L0.875 20.125L6.74625 18.4013C7.8925 18.935 9.1525 19.25 10.5 19.25C15.33 19.25 19.25 15.33 19.25 10.5C19.25 5.67 15.33 1.75 10.5 1.75Z" fill="#212121"/>
|
||||
</svg>
|
After Width: | Height: | Size: 713 B |
3
packages/flutter_timeline_view/assets/send.svg
Normal file
3
packages/flutter_timeline_view/assets/send.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.05813 12.3603L7.52523 16.4769L11.6405 22.944C12.0627 23.6073 12.7875 23.9999 13.5628 23.9999C13.6281 23.9999 13.6921 23.9974 13.7585 23.9913C14.6077 23.9186 15.3399 23.3858 15.6697 22.6006L23.8204 3.1673C24.1809 2.30831 23.989 1.3287 23.3318 0.6703C22.6734 0.0118984 21.6938 -0.181315 20.8348 0.179268L1.39902 8.33114C0.612628 8.66096 0.0797531 9.3932 0.008375 10.2424C-0.0642338 11.0915 0.339422 11.9037 1.05813 12.3603ZM2.1128 10.0331L21.5473 1.8825C21.6113 1.85542 21.6704 1.84435 21.7233 1.84435C21.8735 1.84435 21.9793 1.92926 22.0248 1.97603C22.0876 2.03756 22.2205 2.20862 22.1184 2.45352L13.9677 21.8881C13.8754 22.1084 13.6822 22.1465 13.6022 22.1539C13.5222 22.1588 13.3241 22.1539 13.1973 21.9533L9.36876 15.9378L14.4674 10.8392C14.828 10.4786 14.828 9.89408 14.4674 9.53349C14.1068 9.17291 13.5222 9.17291 13.1616 9.53349L8.06303 14.6321L2.04757 10.8035C1.85436 10.6805 1.83836 10.4971 1.84698 10.3986C1.85436 10.3199 1.89251 10.1254 2.1128 10.0331Z" fill="#212121"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -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:',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -5,9 +5,16 @@
|
|||
library flutter_timeline_view;
|
||||
|
||||
export 'src/config/timeline_options.dart';
|
||||
export 'src/config/timeline_paddings.dart';
|
||||
export 'src/config/timeline_styles.dart';
|
||||
export 'src/config/timeline_theme.dart';
|
||||
export 'src/config/timeline_translations.dart';
|
||||
export 'src/screens/timeline_post_creation_screen.dart';
|
||||
export 'src/screens/timeline_post_overview_screen.dart';
|
||||
export 'src/screens/timeline_post_screen.dart';
|
||||
export 'src/screens/timeline_screen.dart';
|
||||
export 'src/screens/timeline_selection_screen.dart';
|
||||
export 'src/services/local_post_service.dart';
|
||||
export 'src/widgets/category_selector.dart';
|
||||
export 'src/widgets/category_selector_button.dart';
|
||||
export 'src/widgets/timeline_post_widget.dart';
|
||||
|
|
|
@ -1,35 +1,65 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_image_picker/flutter_image_picker.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
import 'package:flutter_timeline_view/src/config/timeline_paddings.dart';
|
||||
import 'package:flutter_timeline_view/src/config/timeline_theme.dart';
|
||||
import 'package:flutter_timeline_view/src/config/timeline_translations.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
@immutable
|
||||
class TimelineOptions {
|
||||
const TimelineOptions({
|
||||
this.theme = const TimelineTheme(),
|
||||
this.translations = const TimelineTranslations(),
|
||||
this.translations = const TimelineTranslations.empty(),
|
||||
this.paddings = const TimelinePaddingOptions(),
|
||||
this.imagePickerConfig = const ImagePickerConfig(),
|
||||
this.imagePickerTheme = const ImagePickerTheme(),
|
||||
this.allowAllDeletion = false,
|
||||
this.imagePickerTheme,
|
||||
this.timelinePostHeight,
|
||||
this.sortCommentsAscending = true,
|
||||
this.sortPostsAscending = false,
|
||||
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.buttonBuilder,
|
||||
this.textInputBuilder,
|
||||
this.dividerBuilder,
|
||||
this.userAvatarBuilder,
|
||||
this.anonymousAvatarBuilder,
|
||||
this.nameBuilder,
|
||||
this.iconSize = 24,
|
||||
this.postWidgetHeight,
|
||||
this.filterOptions = const FilterOptions(),
|
||||
this.categoriesOptions = const CategoriesOptions(),
|
||||
this.requireImageForPost = false,
|
||||
this.minTitleLength,
|
||||
this.maxTitleLength,
|
||||
this.minContentLength,
|
||||
this.maxContentLength,
|
||||
this.categorySelectorButtonBuilder,
|
||||
this.postOverviewButtonBuilder,
|
||||
this.deletionDialogBuilder,
|
||||
this.listHeaderBuilder,
|
||||
this.titleInputDecoration,
|
||||
this.contentInputDecoration,
|
||||
});
|
||||
|
||||
/// Theming options for the timeline
|
||||
final TimelineTheme theme;
|
||||
|
||||
/// The format to display the post date in
|
||||
final DateFormat? dateformat;
|
||||
final DateFormat? dateFormat;
|
||||
|
||||
/// The format to display the post time in
|
||||
final DateFormat? timeFormat;
|
||||
|
@ -38,27 +68,161 @@ class TimelineOptions {
|
|||
final bool sortCommentsAscending;
|
||||
|
||||
/// Whether to sort posts ascending or descending
|
||||
final bool sortPostsAscending;
|
||||
final bool? sortPostsAscending;
|
||||
|
||||
/// Allow all posts to be deleted instead of
|
||||
/// only the posts of the current user
|
||||
final bool allowAllDeletion;
|
||||
/// The height of a post in the timeline
|
||||
final double? timelinePostHeight;
|
||||
|
||||
/// Class that contains all the translations used in the timeline
|
||||
final TimelineTranslations translations;
|
||||
|
||||
/// Class that contains all the paddings used in the timeline
|
||||
final TimelinePaddingOptions paddings;
|
||||
|
||||
final ButtonBuilder? buttonBuilder;
|
||||
|
||||
final TextInputBuilder? textInputBuilder;
|
||||
|
||||
final UserAvatarBuilder? userAvatarBuilder;
|
||||
|
||||
/// When the imageUrl is null this anonymousAvatarBuilder will be used
|
||||
/// You can use it to display a default avatarW
|
||||
final UserAvatarBuilder? anonymousAvatarBuilder;
|
||||
|
||||
final String Function(TimelinePosterUserModel?)? nameBuilder;
|
||||
|
||||
/// ImagePickerTheme can be used to change the UI of the
|
||||
/// Image Picker Widget to change the text/icons to your liking.
|
||||
final ImagePickerTheme imagePickerTheme;
|
||||
final ImagePickerTheme? imagePickerTheme;
|
||||
|
||||
/// ImagePickerConfig can be used to define the
|
||||
/// size and quality for the uploaded image.
|
||||
final ImagePickerConfig imagePickerConfig;
|
||||
|
||||
/// Whether to allow double tap to like
|
||||
final bool doubleTapTolike;
|
||||
|
||||
/// The icons to display when double tap to like is enabled
|
||||
final (Icon?, Icon?) likeAndDislikeIconsForDoubleTap;
|
||||
|
||||
/// Whether to display the icons with values
|
||||
final bool iconsWithValues;
|
||||
|
||||
/// The builder for the item info, all below the like and comment buttons
|
||||
final Widget Function({required TimelinePost post})? itemInfoBuilder;
|
||||
|
||||
/// The builder for the divider
|
||||
final Widget Function()? dividerBuilder;
|
||||
|
||||
/// Size of icons like the comment and like icons. Dafualts to 26
|
||||
final double iconSize;
|
||||
|
||||
/// Sets a predefined height for the postWidget.
|
||||
final double? postWidgetHeight;
|
||||
|
||||
/// Options for filtering
|
||||
final FilterOptions filterOptions;
|
||||
|
||||
/// Options for using the category selector.
|
||||
final CategoriesOptions categoriesOptions;
|
||||
|
||||
/// Require image for post
|
||||
final bool requireImageForPost;
|
||||
|
||||
/// Minimum length of the title
|
||||
final int? minTitleLength;
|
||||
|
||||
/// Maximum length of the title
|
||||
final int? maxTitleLength;
|
||||
|
||||
/// Minimum length of the post content
|
||||
final int? minContentLength;
|
||||
|
||||
/// Maximum length of the post content
|
||||
final int? maxContentLength;
|
||||
|
||||
/// Builder for the category selector button
|
||||
/// on the timeline category selection screen
|
||||
final Widget Function(
|
||||
BuildContext context,
|
||||
Function() onPressed,
|
||||
String text,
|
||||
)? categorySelectorButtonBuilder;
|
||||
|
||||
/// This widgetbuilder is placed at the top of the list of posts and can be
|
||||
/// used to add custom elements
|
||||
final Widget Function(BuildContext context, String? category)?
|
||||
listHeaderBuilder;
|
||||
|
||||
/// Builder for the post overview button
|
||||
/// on the timeline post overview screen
|
||||
final Widget Function(
|
||||
BuildContext context,
|
||||
Function() onPressed,
|
||||
String text,
|
||||
TimelinePost post,
|
||||
)? postOverviewButtonBuilder;
|
||||
|
||||
/// Optional builder to override the default alertdialog for post deletion
|
||||
/// It should pop the navigator with true to delete the post and
|
||||
/// false to cancel deletion
|
||||
final WidgetBuilder? deletionDialogBuilder;
|
||||
|
||||
/// inputdecoration for the title textfield
|
||||
final InputDecoration? titleInputDecoration;
|
||||
|
||||
/// inputdecoration for the content textfield
|
||||
final InputDecoration? contentInputDecoration;
|
||||
}
|
||||
|
||||
class CategoriesOptions {
|
||||
const CategoriesOptions({
|
||||
this.categoryButtonBuilder,
|
||||
this.categorySelectorHorizontalPadding,
|
||||
});
|
||||
|
||||
/// List of categories that the user can select.
|
||||
/// If this is null no categories will be shown.
|
||||
|
||||
/// Abilty to override the standard category selector
|
||||
final Widget Function(
|
||||
TimelineCategory category,
|
||||
Function() onTap,
|
||||
// ignore: avoid_positional_boolean_parameters
|
||||
bool selected,
|
||||
bool isOnTop,
|
||||
)? categoryButtonBuilder;
|
||||
|
||||
/// Overides the standard horizontal padding of the whole category selector.
|
||||
final double? categorySelectorHorizontalPadding;
|
||||
|
||||
TimelineCategory? getCategoryByKey(
|
||||
List<TimelineCategory> categories,
|
||||
BuildContext context,
|
||||
String? key,
|
||||
) =>
|
||||
categories.firstWhereOrNull((category) => category.key == key);
|
||||
}
|
||||
|
||||
class FilterOptions {
|
||||
const FilterOptions({
|
||||
this.initialFilterWord,
|
||||
this.searchBarBuilder,
|
||||
this.onFilterEnabledChange,
|
||||
});
|
||||
|
||||
/// Set a value to search through posts. When set the searchbar is shown.
|
||||
/// If null no searchbar is shown.
|
||||
final String? initialFilterWord;
|
||||
|
||||
// Possibilty to override the standard search bar.
|
||||
final Widget Function(
|
||||
Future<List<TimelinePost>> Function(
|
||||
String filterWord,
|
||||
) search,
|
||||
)? searchBarBuilder;
|
||||
|
||||
final void Function({required bool filterEnabled})? onFilterEnabledChange;
|
||||
}
|
||||
|
||||
typedef ButtonBuilder = Widget Function(
|
||||
|
@ -74,7 +238,7 @@ typedef TextInputBuilder = Widget Function(
|
|||
String hintText,
|
||||
);
|
||||
|
||||
typedef UserAvatarBuilder = Widget Function(
|
||||
typedef UserAvatarBuilder = Widget? Function(
|
||||
TimelinePosterUserModel user,
|
||||
double size,
|
||||
);
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// This class contains the paddings used in the timeline options
|
||||
class TimelinePaddingOptions {
|
||||
const TimelinePaddingOptions({
|
||||
this.mainPadding =
|
||||
const EdgeInsets.only(left: 32, top: 20, right: 32, bottom: 40),
|
||||
this.postPadding =
|
||||
const EdgeInsets.only(left: 12.0, top: 12, right: 12.0, bottom: 8),
|
||||
this.postOverviewButtonBottomPadding = 30.0,
|
||||
this.categoryButtonTextPadding,
|
||||
});
|
||||
|
||||
/// The padding between posts in the timeline
|
||||
final EdgeInsets mainPadding;
|
||||
|
||||
/// The padding of each post
|
||||
final EdgeInsets postPadding;
|
||||
|
||||
/// The bottom padding of the button on the post overview screen
|
||||
final double postOverviewButtonBottomPadding;
|
||||
|
||||
/// The padding between the icon and the text in the category button
|
||||
final double? categoryButtonTextPadding;
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
@immutable
|
||||
class TimelineTextStyles {
|
||||
/// Options to update all the texts in the timeline view
|
||||
/// with different textstyles
|
||||
const TimelineTextStyles({
|
||||
this.viewPostStyle,
|
||||
this.listPostTitleStyle,
|
||||
this.listPostCreatorTitleStyle,
|
||||
this.listCreatorNameStyle,
|
||||
this.listPostLikeTitleAndAmount,
|
||||
this.deletePostStyle,
|
||||
this.categorySelectionDescriptionStyle,
|
||||
this.categorySelectionTitleStyle,
|
||||
this.noPostsStyle,
|
||||
this.errorTextStyle,
|
||||
this.postCreatorTitleStyle,
|
||||
this.postCreatorNameStyle,
|
||||
this.postTitleStyle,
|
||||
this.postLikeTitleAndAmount,
|
||||
this.postCreatedAtStyle,
|
||||
this.categoryTitleStyle,
|
||||
});
|
||||
|
||||
/// The TextStyle for the text indicating that you can view a post
|
||||
final TextStyle? viewPostStyle;
|
||||
|
||||
/// The TextStyle for the creatorname at the top of the card
|
||||
/// when it is in the list
|
||||
final TextStyle? listPostCreatorTitleStyle;
|
||||
|
||||
/// The TextStyle for the post title when it is in the list
|
||||
final TextStyle? listPostTitleStyle;
|
||||
|
||||
/// The TextStyle for the creatorname at the bottom of the card
|
||||
/// when it is in the list
|
||||
final TextStyle? listCreatorNameStyle;
|
||||
|
||||
/// The TextStyle for the amount of like and name of the likes at
|
||||
/// the bottom of the card when it is in the list
|
||||
final TextStyle? listPostLikeTitleAndAmount;
|
||||
|
||||
/// The TextStyle for the deletion text that shows in the popupmenu
|
||||
final TextStyle? deletePostStyle;
|
||||
|
||||
/// The TextStyle for the category explainer on the selection page
|
||||
final TextStyle? categorySelectionDescriptionStyle;
|
||||
|
||||
/// The TextStyle for the category items in the list on the selection page
|
||||
final TextStyle? categorySelectionTitleStyle;
|
||||
|
||||
/// The TextStyle for the text when there are no posts
|
||||
final TextStyle? noPostsStyle;
|
||||
|
||||
/// The TextStyle for all error texts
|
||||
final TextStyle? errorTextStyle;
|
||||
|
||||
/// The TextStyle for the creatorname at the top of the post page
|
||||
final TextStyle? postCreatorTitleStyle;
|
||||
|
||||
/// The TextStyle for the creatorname at the bottom of the post page
|
||||
final TextStyle? postCreatorNameStyle;
|
||||
|
||||
/// The TextStyle for the title of the post on the post page
|
||||
final TextStyle? postTitleStyle;
|
||||
|
||||
/// The TextStyle for the amount of likes and name of the likes
|
||||
/// on the post page
|
||||
final TextStyle? postLikeTitleAndAmount;
|
||||
|
||||
/// The TextStyle for the creation time of the post
|
||||
final TextStyle? postCreatedAtStyle;
|
||||
|
||||
final TextStyle? categoryTitleStyle;
|
||||
}
|
|
@ -3,6 +3,7 @@
|
|||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_view/src/config/timeline_styles.dart';
|
||||
|
||||
@immutable
|
||||
class TimelineTheme {
|
||||
|
@ -14,6 +15,12 @@ class TimelineTheme {
|
|||
this.sendIcon,
|
||||
this.moreIcon,
|
||||
this.deleteIcon,
|
||||
this.categorySelectionButtonBorderColor,
|
||||
this.categorySelectionButtonBackgroundColor,
|
||||
this.categorySelectionButtonSelectedTextColor,
|
||||
this.categorySelectionButtonUnselectedTextColor,
|
||||
this.postCreationFloatingActionButtonColor,
|
||||
this.textStyles = const TimelineTextStyles(),
|
||||
});
|
||||
|
||||
final Color? iconColor;
|
||||
|
@ -35,4 +42,24 @@ class TimelineTheme {
|
|||
|
||||
/// The icon for delete action (delete post)
|
||||
final Widget? deleteIcon;
|
||||
|
||||
/// The text style overrides for all the texts in the timeline
|
||||
final TimelineTextStyles textStyles;
|
||||
|
||||
/// The color of the border around the category in the selection screen
|
||||
final Color? categorySelectionButtonBorderColor;
|
||||
|
||||
/// The color of the background of the category selection button in the
|
||||
/// selection screen
|
||||
final Color? categorySelectionButtonBackgroundColor;
|
||||
|
||||
/// The color of the text of the category selection button when it is selected
|
||||
final Color? categorySelectionButtonSelectedTextColor;
|
||||
|
||||
/// The color of the text of the category selection button when
|
||||
/// it is not selected
|
||||
final Color? categorySelectionButtonUnselectedTextColor;
|
||||
|
||||
/// The color of the floating action button on the overview screen
|
||||
final Color? postCreationFloatingActionButtonColor;
|
||||
}
|
||||
|
|
|
@ -5,28 +5,107 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
@immutable
|
||||
|
||||
/// Class that holds all the translations for the timeline component view and
|
||||
/// the corresponding userstory
|
||||
class TimelineTranslations {
|
||||
/// TimelineTranslations constructor where everything is required use this
|
||||
/// if you want to be sure to have all translations specified
|
||||
/// If you just want the default values use the empty constructor
|
||||
/// and optionally override the values with the copyWith method
|
||||
const TimelineTranslations({
|
||||
required this.anonymousUser,
|
||||
required this.noPosts,
|
||||
required this.noPostsWithFilter,
|
||||
required this.title,
|
||||
required this.titleHintText,
|
||||
required this.content,
|
||||
required this.contentHintText,
|
||||
required this.contentDescription,
|
||||
required this.uploadImage,
|
||||
required this.uploadImageDescription,
|
||||
required this.allowComments,
|
||||
required this.allowCommentsDescription,
|
||||
required this.commentsTitleOnPost,
|
||||
required this.checkPost,
|
||||
required this.deletePost,
|
||||
required this.deleteReaction,
|
||||
required this.deleteConfirmationMessage,
|
||||
required this.deleteConfirmationTitle,
|
||||
required this.deleteCancelButton,
|
||||
required this.deleteButton,
|
||||
required this.viewPost,
|
||||
required this.oneLikeTitle,
|
||||
required this.multipleLikesTitle,
|
||||
required this.commentsTitle,
|
||||
required this.firstComment,
|
||||
required this.writeComment,
|
||||
required this.postLoadingError,
|
||||
required this.timelineSelectionDescription,
|
||||
required this.searchHint,
|
||||
required this.postOverview,
|
||||
required this.postIn,
|
||||
required this.postCreation,
|
||||
required this.yes,
|
||||
required this.no,
|
||||
required this.timeLineScreenTitle,
|
||||
required this.createCategoryPopuptitle,
|
||||
required this.addCategoryTitle,
|
||||
required this.addCategorySubmitButton,
|
||||
required this.addCategoryCancelButtton,
|
||||
required this.addCategoryHintText,
|
||||
required this.addCategoryErrorText,
|
||||
required this.titleErrorText,
|
||||
required this.contentErrorText,
|
||||
});
|
||||
|
||||
/// Default translations for the timeline component view
|
||||
const TimelineTranslations.empty({
|
||||
this.anonymousUser = 'Anonymous user',
|
||||
this.noPosts = 'No posts yet',
|
||||
this.noPostsWithFilter = 'No posts with this filter',
|
||||
this.title = 'Title',
|
||||
this.titleHintText = 'Title...',
|
||||
this.content = 'Content',
|
||||
this.contentHintText = 'Content...',
|
||||
this.contentDescription = 'What do you want to share?',
|
||||
this.uploadImage = 'Upload image',
|
||||
this.uploadImageDescription = 'Upload an image to your message (optional)',
|
||||
this.allowComments = 'Are people allowed to comment?',
|
||||
this.allowCommentsDescription =
|
||||
'Indicate whether people are allowed to respond',
|
||||
this.checkPost = 'Check post overview',
|
||||
this.commentsTitleOnPost = 'Comments',
|
||||
this.checkPost = 'Overview',
|
||||
this.deletePost = 'Delete post',
|
||||
this.deleteConfirmationTitle = 'Delete Post',
|
||||
this.deleteConfirmationMessage =
|
||||
'Are you sure you want to delete this post?',
|
||||
this.deleteButton = 'Delete',
|
||||
this.deleteCancelButton = 'Cancel',
|
||||
this.deleteReaction = 'Delete Reaction',
|
||||
this.viewPost = 'View post',
|
||||
this.likesTitle = 'Likes',
|
||||
this.commentsTitle = 'Comments',
|
||||
this.oneLikeTitle = 'like',
|
||||
this.multipleLikesTitle = 'likes',
|
||||
this.commentsTitle = 'Are people allowed to comment?',
|
||||
this.firstComment = 'Be the first to comment',
|
||||
this.writeComment = 'Write your comment here...',
|
||||
this.postAt = 'at',
|
||||
this.postLoadingError = 'Something went wrong while loading the post',
|
||||
this.timelineSelectionDescription = 'Choose a category',
|
||||
this.searchHint = 'Search...',
|
||||
this.postOverview = 'Post Overview',
|
||||
this.postIn = 'Post',
|
||||
this.postCreation = 'add post',
|
||||
this.yes = 'Yes',
|
||||
this.no = 'No',
|
||||
this.timeLineScreenTitle = 'iconinstagram',
|
||||
this.createCategoryPopuptitle = 'Choose a title for the new category',
|
||||
this.addCategoryTitle = 'Add category',
|
||||
this.addCategorySubmitButton = 'Add category',
|
||||
this.addCategoryCancelButtton = 'Cancel',
|
||||
this.addCategoryHintText = 'Category name...',
|
||||
this.addCategoryErrorText = 'Please enter a category name',
|
||||
this.titleErrorText = 'Please enter a title',
|
||||
this.contentErrorText = 'Please enter content',
|
||||
});
|
||||
|
||||
final String noPosts;
|
||||
|
@ -41,13 +120,144 @@ class TimelineTranslations {
|
|||
final String allowComments;
|
||||
final String allowCommentsDescription;
|
||||
final String checkPost;
|
||||
final String postAt;
|
||||
|
||||
final String titleHintText;
|
||||
final String contentHintText;
|
||||
final String titleErrorText;
|
||||
final String contentErrorText;
|
||||
|
||||
final String deletePost;
|
||||
final String deleteConfirmationTitle;
|
||||
final String deleteConfirmationMessage;
|
||||
final String deleteButton;
|
||||
final String deleteCancelButton;
|
||||
|
||||
final String deleteReaction;
|
||||
final String viewPost;
|
||||
final String likesTitle;
|
||||
final String oneLikeTitle;
|
||||
final String multipleLikesTitle;
|
||||
final String commentsTitle;
|
||||
final String commentsTitleOnPost;
|
||||
final String writeComment;
|
||||
final String firstComment;
|
||||
final String postLoadingError;
|
||||
|
||||
final String timelineSelectionDescription;
|
||||
|
||||
final String searchHint;
|
||||
|
||||
final String postOverview;
|
||||
final String postIn;
|
||||
final String postCreation;
|
||||
|
||||
final String createCategoryPopuptitle;
|
||||
final String addCategoryTitle;
|
||||
final String addCategorySubmitButton;
|
||||
final String addCategoryCancelButtton;
|
||||
final String addCategoryHintText;
|
||||
final String addCategoryErrorText;
|
||||
|
||||
final String yes;
|
||||
final String no;
|
||||
final String timeLineScreenTitle;
|
||||
|
||||
/// Method to override the default values of the translations
|
||||
TimelineTranslations copyWith({
|
||||
String? noPosts,
|
||||
String? noPostsWithFilter,
|
||||
String? anonymousUser,
|
||||
String? title,
|
||||
String? content,
|
||||
String? contentDescription,
|
||||
String? uploadImage,
|
||||
String? uploadImageDescription,
|
||||
String? allowComments,
|
||||
String? allowCommentsDescription,
|
||||
String? commentsTitleOnPost,
|
||||
String? checkPost,
|
||||
String? deletePost,
|
||||
String? deleteConfirmationTitle,
|
||||
String? deleteConfirmationMessage,
|
||||
String? deleteButton,
|
||||
String? deleteCancelButton,
|
||||
String? deleteReaction,
|
||||
String? viewPost,
|
||||
String? oneLikeTitle,
|
||||
String? multipleLikesTitle,
|
||||
String? commentsTitle,
|
||||
String? writeComment,
|
||||
String? firstComment,
|
||||
String? postLoadingError,
|
||||
String? timelineSelectionDescription,
|
||||
String? searchHint,
|
||||
String? postOverview,
|
||||
String? postIn,
|
||||
String? postCreation,
|
||||
String? titleHintText,
|
||||
String? contentHintText,
|
||||
String? yes,
|
||||
String? no,
|
||||
String? timeLineScreenTitle,
|
||||
String? createCategoryPopuptitle,
|
||||
String? addCategoryTitle,
|
||||
String? addCategorySubmitButton,
|
||||
String? addCategoryCancelButtton,
|
||||
String? addCategoryHintText,
|
||||
String? addCategoryErrorText,
|
||||
String? titleErrorText,
|
||||
String? contentErrorText,
|
||||
}) =>
|
||||
TimelineTranslations(
|
||||
noPosts: noPosts ?? this.noPosts,
|
||||
noPostsWithFilter: noPostsWithFilter ?? this.noPostsWithFilter,
|
||||
anonymousUser: anonymousUser ?? this.anonymousUser,
|
||||
title: title ?? this.title,
|
||||
content: content ?? this.content,
|
||||
contentDescription: contentDescription ?? this.contentDescription,
|
||||
uploadImage: uploadImage ?? this.uploadImage,
|
||||
uploadImageDescription:
|
||||
uploadImageDescription ?? this.uploadImageDescription,
|
||||
allowComments: allowComments ?? this.allowComments,
|
||||
allowCommentsDescription:
|
||||
allowCommentsDescription ?? this.allowCommentsDescription,
|
||||
commentsTitleOnPost: commentsTitleOnPost ?? this.commentsTitleOnPost,
|
||||
checkPost: checkPost ?? this.checkPost,
|
||||
deletePost: deletePost ?? this.deletePost,
|
||||
deleteConfirmationTitle:
|
||||
deleteConfirmationTitle ?? this.deleteConfirmationTitle,
|
||||
deleteConfirmationMessage:
|
||||
deleteConfirmationMessage ?? this.deleteConfirmationMessage,
|
||||
deleteButton: deleteButton ?? this.deleteButton,
|
||||
deleteCancelButton: deleteCancelButton ?? this.deleteCancelButton,
|
||||
deleteReaction: deleteReaction ?? this.deleteReaction,
|
||||
viewPost: viewPost ?? this.viewPost,
|
||||
oneLikeTitle: oneLikeTitle ?? this.oneLikeTitle,
|
||||
multipleLikesTitle: multipleLikesTitle ?? this.multipleLikesTitle,
|
||||
commentsTitle: commentsTitle ?? this.commentsTitle,
|
||||
writeComment: writeComment ?? this.writeComment,
|
||||
firstComment: firstComment ?? this.firstComment,
|
||||
postLoadingError: postLoadingError ?? this.postLoadingError,
|
||||
timelineSelectionDescription:
|
||||
timelineSelectionDescription ?? this.timelineSelectionDescription,
|
||||
searchHint: searchHint ?? this.searchHint,
|
||||
postOverview: postOverview ?? this.postOverview,
|
||||
postIn: postIn ?? this.postIn,
|
||||
postCreation: postCreation ?? this.postCreation,
|
||||
titleHintText: titleHintText ?? this.titleHintText,
|
||||
contentHintText: contentHintText ?? this.contentHintText,
|
||||
yes: yes ?? this.yes,
|
||||
no: no ?? this.no,
|
||||
timeLineScreenTitle: timeLineScreenTitle ?? this.timeLineScreenTitle,
|
||||
addCategoryTitle: addCategoryTitle ?? this.addCategoryTitle,
|
||||
addCategorySubmitButton:
|
||||
addCategorySubmitButton ?? this.addCategorySubmitButton,
|
||||
addCategoryCancelButtton:
|
||||
addCategoryCancelButtton ?? this.addCategoryCancelButtton,
|
||||
addCategoryHintText: addCategoryHintText ?? this.addCategoryHintText,
|
||||
createCategoryPopuptitle:
|
||||
createCategoryPopuptitle ?? this.createCategoryPopuptitle,
|
||||
addCategoryErrorText: addCategoryErrorText ?? this.addCategoryErrorText,
|
||||
titleErrorText: titleErrorText ?? this.titleErrorText,
|
||||
contentErrorText: contentErrorText ?? this.contentErrorText,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,28 +2,33 @@
|
|||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dotted_border/dotted_border.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_image_picker/flutter_image_picker.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
import 'package:flutter_timeline_view/flutter_timeline_view.dart';
|
||||
import 'package:flutter_timeline_view/src/config/timeline_options.dart';
|
||||
import 'package:flutter_timeline_view/src/widgets/default_filled_button.dart';
|
||||
import 'package:flutter_timeline_view/src/widgets/post_creation_textfield.dart';
|
||||
|
||||
class TimelinePostCreationScreen extends StatefulWidget {
|
||||
const TimelinePostCreationScreen({
|
||||
required this.userId,
|
||||
required this.postCategory,
|
||||
required this.onPostCreated,
|
||||
required this.service,
|
||||
required this.options,
|
||||
this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
|
||||
this.postCategory,
|
||||
this.onPostOverview,
|
||||
this.enablePostOverviewScreen = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
|
||||
final String postCategory;
|
||||
final String? postCategory;
|
||||
|
||||
/// called when the post is created
|
||||
final Function(TimelinePost) onPostCreated;
|
||||
|
@ -34,8 +39,9 @@ class TimelinePostCreationScreen extends StatefulWidget {
|
|||
/// The options for the timeline
|
||||
final TimelineOptions options;
|
||||
|
||||
/// The padding around the screen
|
||||
final EdgeInsets padding;
|
||||
/// Nullable callback for routing to the post overview
|
||||
final void Function(TimelinePost)? onPostOverview;
|
||||
final bool enablePostOverviewScreen;
|
||||
|
||||
@override
|
||||
State<TimelinePostCreationScreen> createState() =>
|
||||
|
@ -47,217 +53,342 @@ class _TimelinePostCreationScreenState
|
|||
TextEditingController titleController = TextEditingController();
|
||||
TextEditingController contentController = TextEditingController();
|
||||
Uint8List? image;
|
||||
bool editingDone = false;
|
||||
bool allowComments = false;
|
||||
bool titleIsValid = false;
|
||||
bool contentIsValid = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
titleController.addListener(_listenForInputs);
|
||||
contentController.addListener(_listenForInputs);
|
||||
|
||||
super.initState();
|
||||
titleController.addListener(checkIfEditingDone);
|
||||
contentController.addListener(checkIfEditingDone);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
titleController.dispose();
|
||||
contentController.dispose();
|
||||
super.dispose();
|
||||
void _listenForInputs() {
|
||||
titleIsValid = titleController.text.isNotEmpty;
|
||||
contentIsValid = contentController.text.isNotEmpty;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void checkIfEditingDone() {
|
||||
setState(() {
|
||||
editingDone =
|
||||
titleController.text.isNotEmpty && contentController.text.isNotEmpty;
|
||||
});
|
||||
}
|
||||
var formkey = GlobalKey<FormState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var imageRequired = widget.options.requireImageForPost;
|
||||
|
||||
Future<void> onPostCreated() async {
|
||||
var user = await widget.service.userService?.getUser(widget.userId);
|
||||
var post = TimelinePost(
|
||||
id: '',
|
||||
id: 'Post${Random().nextInt(1000)}',
|
||||
creatorId: widget.userId,
|
||||
title: titleController.text,
|
||||
category: widget.postCategory,
|
||||
content: contentController.text,
|
||||
likes: 0,
|
||||
likedBy: const [],
|
||||
reaction: 0,
|
||||
createdAt: DateTime.now(),
|
||||
reactionEnabled: allowComments,
|
||||
image: image,
|
||||
creator: user,
|
||||
);
|
||||
var newPost = await widget.service.createPost(post);
|
||||
widget.onPostCreated.call(newPost);
|
||||
|
||||
if (widget.enablePostOverviewScreen) {
|
||||
widget.onPostOverview?.call(post);
|
||||
} else {
|
||||
widget.onPostCreated.call(post);
|
||||
}
|
||||
}
|
||||
|
||||
var theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: widget.padding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.options.translations.title,
|
||||
style: theme.textTheme.displaySmall,
|
||||
),
|
||||
widget.options.textInputBuilder?.call(
|
||||
titleController,
|
||||
null,
|
||||
'',
|
||||
) ??
|
||||
TextField(
|
||||
controller: titleController,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.options.translations.content,
|
||||
style: theme.textTheme.displaySmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.options.translations.contentDescription,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
// input field for the content
|
||||
SizedBox(
|
||||
height: 100,
|
||||
child: TextField(
|
||||
controller: contentController,
|
||||
expands: true,
|
||||
maxLines: null,
|
||||
minLines: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
// input field for the content
|
||||
Text(
|
||||
widget.options.translations.uploadImage,
|
||||
style: theme.textTheme.displaySmall,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.uploadImageDescription,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
// image picker field
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
// open a dialog to choose between camera and gallery
|
||||
var result = await showModalBottomSheet<Uint8List?>(
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
color: Colors.black,
|
||||
child: ImagePicker(
|
||||
imagePickerConfig: widget.options.imagePickerConfig,
|
||||
imagePickerTheme: widget.options.imagePickerTheme,
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: widget.options.paddings.mainPadding,
|
||||
child: Form(
|
||||
key: formkey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.options.translations.title,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
widget.options.textInputBuilder?.call(
|
||||
titleController,
|
||||
null,
|
||||
'',
|
||||
) ??
|
||||
PostCreationTextfield(
|
||||
fieldKey: const ValueKey('title'),
|
||||
controller: titleController,
|
||||
hintText: widget.options.translations.titleHintText,
|
||||
textMaxLength: widget.options.maxTitleLength,
|
||||
decoration: widget.options.titleInputDecoration,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
expands: null,
|
||||
minLines: null,
|
||||
maxLines: 1,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return widget.options.translations.titleErrorText;
|
||||
}
|
||||
if (value.trim().isEmpty) {
|
||||
return widget.options.translations.titleErrorText;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
widget.options.translations.content,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.contentDescription,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
PostCreationTextfield(
|
||||
fieldKey: const ValueKey('content'),
|
||||
controller: contentController,
|
||||
hintText: widget.options.translations.contentHintText,
|
||||
textMaxLength: null,
|
||||
decoration: widget.options.contentInputDecoration,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
expands: false,
|
||||
minLines: null,
|
||||
maxLines: null,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return widget.options.translations.contentErrorText;
|
||||
}
|
||||
if (value.trim().isEmpty) {
|
||||
return widget.options.translations.contentErrorText;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 24,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.uploadImage,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.uploadImageDescription,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
var result = await showModalBottomSheet<Uint8List?>(
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
color: theme.colorScheme.surface,
|
||||
child: ImagePicker(
|
||||
config: widget.options.imagePickerConfig,
|
||||
theme: widget.options.imagePickerTheme ??
|
||||
ImagePickerTheme(
|
||||
titleStyle: theme.textTheme.titleMedium,
|
||||
iconSize: 40,
|
||||
selectImageText: 'UPLOAD FILE',
|
||||
makePhotoText: 'TAKE PICTURE',
|
||||
selectImageIcon: const Icon(
|
||||
size: 40,
|
||||
Icons.insert_drive_file,
|
||||
),
|
||||
closeButtonBuilder: (onTap) => TextButton(
|
||||
onPressed: () {
|
||||
onTap();
|
||||
},
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: theme.textTheme.bodyMedium!
|
||||
.copyWith(
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (result != null) {
|
||||
setState(() {
|
||||
image = result;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
child: image != null
|
||||
? Image.memory(
|
||||
image!,
|
||||
width: double.infinity,
|
||||
height: 150.0,
|
||||
fit: BoxFit.cover,
|
||||
// give it a rounded border
|
||||
)
|
||||
: DottedBorder(
|
||||
dashPattern: const [4, 4],
|
||||
radius: const Radius.circular(8.0),
|
||||
color: theme.textTheme.displayMedium?.color ??
|
||||
Colors.white,
|
||||
child: const SizedBox(
|
||||
width: double.infinity,
|
||||
height: 150.0,
|
||||
child: Icon(
|
||||
Icons.image,
|
||||
size: 50,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (result != null) {
|
||||
setState(() {
|
||||
image = result;
|
||||
});
|
||||
}
|
||||
checkIfEditingDone();
|
||||
},
|
||||
child: image != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
child: Image.memory(
|
||||
image!,
|
||||
width: double.infinity,
|
||||
height: 150.0,
|
||||
fit: BoxFit.cover,
|
||||
// give it a rounded border
|
||||
),
|
||||
)
|
||||
: DottedBorder(
|
||||
radius: const Radius.circular(8.0),
|
||||
color: theme.textTheme.displayMedium?.color ??
|
||||
Colors.white,
|
||||
child: const SizedBox(
|
||||
width: double.infinity,
|
||||
height: 150.0,
|
||||
child: Icon(
|
||||
Icons.image,
|
||||
size: 32,
|
||||
// if an image is selected, show a delete button
|
||||
if (image != null) ...[
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
image = null;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.delete,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// if an image is selected, show a delete button
|
||||
if (image != null) ...[
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
image = null;
|
||||
});
|
||||
checkIfEditingDone();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.delete,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.options.translations.commentsTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.allowCommentsDescription,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Checkbox(
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity:
|
||||
const VisualDensity(horizontal: -4, vertical: -4),
|
||||
activeColor: theme.colorScheme.primary,
|
||||
value: allowComments,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
allowComments = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
width: 4,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.yes,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
width: 32,
|
||||
),
|
||||
Checkbox(
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity:
|
||||
const VisualDensity(horizontal: -4, vertical: -4),
|
||||
activeColor: theme.colorScheme.primary,
|
||||
value: !allowComments,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
allowComments = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
width: 4,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.no,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 120),
|
||||
SafeArea(
|
||||
bottom: true,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: widget.options.buttonBuilder?.call(
|
||||
context,
|
||||
onPostCreated,
|
||||
widget.options.translations.checkPost,
|
||||
enabled: formkey.currentState!.validate(),
|
||||
) ??
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 48),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DefaultFilledButton(
|
||||
onPressed: titleIsValid &&
|
||||
contentIsValid &&
|
||||
(!imageRequired || image != null)
|
||||
? () async {
|
||||
if (formkey.currentState!
|
||||
.validate()) {
|
||||
await onPostCreated();
|
||||
await widget.service.postService
|
||||
.fetchPosts(null);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
buttonText: widget.enablePostOverviewScreen
|
||||
? widget.options.translations.checkPost
|
||||
: widget
|
||||
.options.translations.postCreation,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
import 'package:flutter_timeline_view/flutter_timeline_view.dart';
|
||||
import 'package:flutter_timeline_view/src/widgets/default_filled_button.dart';
|
||||
|
||||
class TimelinePostOverviewScreen extends StatelessWidget {
|
||||
const TimelinePostOverviewScreen({
|
||||
required this.timelinePost,
|
||||
required this.options,
|
||||
required this.service,
|
||||
required this.onPostSubmit,
|
||||
super.key,
|
||||
});
|
||||
final TimelinePost timelinePost;
|
||||
final TimelineOptions options;
|
||||
final TimelineService service;
|
||||
final void Function(TimelinePost) onPostSubmit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var isSubmitted = false;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TimelinePostScreen(
|
||||
userId: timelinePost.creatorId,
|
||||
options: options,
|
||||
post: timelinePost,
|
||||
onPostDelete: () async {},
|
||||
service: service,
|
||||
isOverviewScreen: true,
|
||||
),
|
||||
),
|
||||
options.postOverviewButtonBuilder?.call(
|
||||
context,
|
||||
() {
|
||||
if (isSubmitted) return;
|
||||
isSubmitted = true;
|
||||
onPostSubmit(timelinePost);
|
||||
},
|
||||
options.translations.postIn,
|
||||
timelinePost,
|
||||
) ??
|
||||
options.buttonBuilder?.call(
|
||||
context,
|
||||
() {
|
||||
if (isSubmitted) return;
|
||||
isSubmitted = true;
|
||||
onPostSubmit(timelinePost);
|
||||
},
|
||||
options.translations.postIn,
|
||||
enabled: true,
|
||||
) ??
|
||||
SafeArea(
|
||||
bottom: true,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 80),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DefaultFilledButton(
|
||||
onPressed: () async {
|
||||
if (isSubmitted) return;
|
||||
isSubmitted = true;
|
||||
onPostSubmit(timelinePost);
|
||||
},
|
||||
buttonText: options.translations.postIn,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -3,52 +3,53 @@
|
|||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_image_picker/flutter_image_picker.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
import 'package:flutter_timeline_view/src/config/timeline_options.dart';
|
||||
import 'package:flutter_timeline_view/src/widgets/reaction_bottom.dart';
|
||||
import 'package:flutter_timeline_view/src/widgets/tappable_image.dart';
|
||||
import 'package:flutter_timeline_view/src/widgets/timeline_post_widget.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class TimelinePostScreen extends StatefulWidget {
|
||||
const TimelinePostScreen({
|
||||
required this.userId,
|
||||
required this.service,
|
||||
required this.userService,
|
||||
required this.options,
|
||||
required this.post,
|
||||
required this.onPostDelete,
|
||||
this.allowAllDeletion = false,
|
||||
this.isOverviewScreen = false,
|
||||
this.onUserTap,
|
||||
this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The user id of the current user
|
||||
final String userId;
|
||||
|
||||
/// Allow all posts to be deleted instead of
|
||||
/// only the posts of the current user
|
||||
final bool allowAllDeletion;
|
||||
|
||||
/// The timeline service to fetch the post details
|
||||
final TimelineService service;
|
||||
|
||||
/// The user service to fetch the profile picture of the user
|
||||
final TimelineUserService userService;
|
||||
|
||||
/// Options to configure the timeline screens
|
||||
final TimelineOptions options;
|
||||
|
||||
/// The post to show
|
||||
final TimelinePost post;
|
||||
|
||||
/// The padding around the screen
|
||||
final EdgeInsets padding;
|
||||
|
||||
/// If this is not null, the user can tap on the user avatar or name
|
||||
final Function(String userId)? onUserTap;
|
||||
|
||||
final VoidCallback onPostDelete;
|
||||
|
||||
final bool? isOverviewScreen;
|
||||
|
||||
@override
|
||||
State<TimelinePostScreen> createState() => _TimelinePostScreenState();
|
||||
}
|
||||
|
@ -67,13 +68,13 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
|
|||
|
||||
Future<void> loadPostDetails() async {
|
||||
try {
|
||||
var loadedPost = await widget.service.fetchPostDetails(widget.post);
|
||||
var loadedPost =
|
||||
await widget.service.postService.fetchPostDetails(widget.post);
|
||||
setState(() {
|
||||
post = loadedPost;
|
||||
isLoading = false;
|
||||
});
|
||||
} on Exception catch (e) {
|
||||
debugPrint('Error loading post: $e');
|
||||
} on Exception catch (_) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
|
@ -89,17 +90,22 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
var dateFormat = widget.options.dateformat ??
|
||||
DateFormat('dd/MM/yyyy', Localizations.localeOf(context).languageCode);
|
||||
var timeFormat = widget.options.timeFormat ?? DateFormat('HH:mm');
|
||||
var dateFormat = widget.options.dateFormat ??
|
||||
DateFormat(
|
||||
"dd/MM/yyyy 'at' HH:mm",
|
||||
Localizations.localeOf(context).languageCode,
|
||||
);
|
||||
if (isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
);
|
||||
}
|
||||
if (this.post == null) {
|
||||
return Center(
|
||||
child: Text(widget.options.translations.postLoadingError),
|
||||
child: Text(
|
||||
widget.options.translations.postLoadingError,
|
||||
style: widget.options.theme.textStyles.errorTextStyle,
|
||||
),
|
||||
);
|
||||
}
|
||||
var post = this.post!;
|
||||
|
@ -108,14 +114,53 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
|
|||
? a.createdAt.compareTo(b.createdAt)
|
||||
: b.createdAt.compareTo(a.createdAt),
|
||||
);
|
||||
var isLikedByUser = post.likedBy?.contains(widget.userId) ?? false;
|
||||
|
||||
var textInputBuilder = widget.options.textInputBuilder ??
|
||||
(controller, suffixIcon, hintText) => TextField(
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 0,
|
||||
horizontal: 16,
|
||||
),
|
||||
hintText: widget.options.translations.writeComment,
|
||||
hintStyle: theme.textTheme.bodyMedium!.copyWith(
|
||||
color: theme.textTheme.bodyMedium!.color!.withOpacity(0.5),
|
||||
),
|
||||
fillColor: Colors.white,
|
||||
filled: true,
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(25),
|
||||
),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
suffixIcon: suffixIcon,
|
||||
),
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
RefreshIndicator(
|
||||
RefreshIndicator.adaptive(
|
||||
onRefresh: () async {
|
||||
updatePost(
|
||||
await widget.service.fetchPostDetails(
|
||||
await widget.service.fetchPost(
|
||||
await widget.service.postService.fetchPostDetails(
|
||||
await widget.service.postService.fetchPost(
|
||||
post,
|
||||
),
|
||||
),
|
||||
|
@ -123,7 +168,7 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
|
|||
},
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: widget.padding,
|
||||
padding: widget.options.paddings.postPadding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
@ -140,34 +185,54 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
|
|||
if (post.creator!.imageUrl != null) ...[
|
||||
widget.options.userAvatarBuilder?.call(
|
||||
post.creator!,
|
||||
40,
|
||||
28,
|
||||
) ??
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
radius: 14,
|
||||
backgroundImage:
|
||||
CachedNetworkImageProvider(
|
||||
post.creator!.imageUrl!,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
widget.options.anonymousAvatarBuilder?.call(
|
||||
post.creator!,
|
||||
28,
|
||||
) ??
|
||||
const CircleAvatar(
|
||||
radius: 14,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 10),
|
||||
if (post.creator!.fullName != null) ...[
|
||||
Text(
|
||||
post.creator!.fullName!,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
Text(
|
||||
widget.options.nameBuilder
|
||||
?.call(post.creator) ??
|
||||
post.creator?.fullName ??
|
||||
widget.options.translations.anonymousUser,
|
||||
style: widget.options.theme.textStyles
|
||||
.postCreatorTitleStyle ??
|
||||
theme.textTheme.titleSmall!.copyWith(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (widget.options.allowAllDeletion ||
|
||||
post.creator?.userId == widget.userId)
|
||||
if (!(widget.isOverviewScreen ?? false) &&
|
||||
(widget.allowAllDeletion ||
|
||||
post.creator?.userId == widget.userId)) ...[
|
||||
PopupMenuButton(
|
||||
onSelected: (value) async {
|
||||
if (value == 'delete') {
|
||||
await widget.service.deletePost(post);
|
||||
widget.onPostDelete();
|
||||
await showPostDeletionConfirmationDialog(
|
||||
widget.options,
|
||||
context,
|
||||
widget.onPostDelete,
|
||||
);
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) =>
|
||||
|
@ -176,7 +241,12 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
|
|||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Text(widget.options.translations.deletePost),
|
||||
Text(
|
||||
widget.options.translations.deletePost,
|
||||
style: widget.options.theme.textStyles
|
||||
.deletePostStyle ??
|
||||
theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
widget.options.theme.deleteIcon ??
|
||||
Icon(
|
||||
|
@ -193,172 +263,353 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
|
|||
color: widget.options.theme.iconColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
// image of the posts
|
||||
if (post.imageUrl != null || post.image != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: widget.options.doubleTapTolike
|
||||
? TappableImage(
|
||||
likeAndDislikeIcon: widget
|
||||
.options.likeAndDislikeIconsForDoubleTap,
|
||||
post: post,
|
||||
userId: widget.userId,
|
||||
onLike: ({required bool liked}) async {
|
||||
var userId = widget.userId;
|
||||
|
||||
late TimelinePost result;
|
||||
|
||||
if (!liked) {
|
||||
result =
|
||||
await widget.service.postService.likePost(
|
||||
userId,
|
||||
post,
|
||||
);
|
||||
} else {
|
||||
result = await widget.service.postService
|
||||
.unlikePost(
|
||||
userId,
|
||||
post,
|
||||
);
|
||||
}
|
||||
|
||||
await loadPostDetails();
|
||||
|
||||
return result.likedBy?.contains(userId) ??
|
||||
false;
|
||||
},
|
||||
)
|
||||
: post.image != null
|
||||
? Image.memory(
|
||||
width: double.infinity,
|
||||
post.image!,
|
||||
fit: BoxFit.fitHeight,
|
||||
)
|
||||
: CachedNetworkImage(
|
||||
width: double.infinity,
|
||||
imageUrl: post.imageUrl!,
|
||||
fit: BoxFit.fitHeight,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
// post information
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () async {
|
||||
if (widget.isOverviewScreen ?? false) return;
|
||||
if (isLikedByUser) {
|
||||
updatePost(
|
||||
await widget.service.postService.unlikePost(
|
||||
widget.userId,
|
||||
post,
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
} else {
|
||||
updatePost(
|
||||
await widget.service.postService.likePost(
|
||||
widget.userId,
|
||||
post,
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
icon: isLikedByUser
|
||||
? widget.options.theme.likedIcon ??
|
||||
Icon(
|
||||
Icons.favorite_rounded,
|
||||
color: widget.options.theme.iconColor,
|
||||
size: widget.options.iconSize,
|
||||
)
|
||||
: widget.options.theme.likeIcon ??
|
||||
Icon(
|
||||
Icons.favorite_outline_outlined,
|
||||
color: widget.options.theme.iconColor,
|
||||
size: widget.options.iconSize,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (post.reactionEnabled)
|
||||
widget.options.theme.commentIcon ??
|
||||
SvgPicture.asset(
|
||||
'assets/Comment.svg',
|
||||
package: 'flutter_timeline_view',
|
||||
// ignore: deprecated_member_use
|
||||
color: widget.options.theme.iconColor,
|
||||
width: widget.options.iconSize,
|
||||
height: widget.options.iconSize,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// image of the post
|
||||
if (post.imageUrl != null) ...[
|
||||
CachedNetworkImage(
|
||||
imageUrl: post.imageUrl!,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.fitHeight,
|
||||
// ignore: avoid_bool_literals_in_conditional_expressions
|
||||
if (widget.isOverviewScreen != null
|
||||
? !widget.isOverviewScreen!
|
||||
: false) ...[
|
||||
Text(
|
||||
// ignore: lines_longer_than_80_chars
|
||||
'${post.likes} ${post.likes > 1 ? widget.options.translations.multipleLikesTitle : widget.options.translations.oneLikeTitle}',
|
||||
style: widget.options.theme.textStyles
|
||||
.postLikeTitleAndAmount ??
|
||||
theme.textTheme.titleSmall
|
||||
?.copyWith(color: Colors.black),
|
||||
),
|
||||
],
|
||||
// post information
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (post.likedBy?.contains(widget.userId) ?? false) ...[
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
updatePost(
|
||||
await widget.service.unlikePost(
|
||||
widget.userId,
|
||||
post,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: widget.options.theme.likedIcon ??
|
||||
Icon(
|
||||
Icons.thumb_up_rounded,
|
||||
color: widget.options.theme.iconColor,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
updatePost(
|
||||
await widget.service.likePost(
|
||||
widget.userId,
|
||||
post,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: widget.options.theme.likeIcon ??
|
||||
Icon(
|
||||
Icons.thumb_up_alt_outlined,
|
||||
color: widget.options.theme.iconColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
if (post.reactionEnabled)
|
||||
widget.options.theme.commentIcon ??
|
||||
Icon(
|
||||
Icons.chat_bubble_outline_rounded,
|
||||
color: widget.options.theme.iconColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${post.likes} ${widget.options.translations.likesTitle}',
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
text: post.creator?.fullName ??
|
||||
text: widget.options.nameBuilder?.call(post.creator) ??
|
||||
post.creator?.fullName ??
|
||||
widget.options.translations.anonymousUser,
|
||||
style: theme.textTheme.titleSmall,
|
||||
style: widget
|
||||
.options.theme.textStyles.postCreatorNameStyle ??
|
||||
theme.textTheme.titleSmall!
|
||||
.copyWith(color: Colors.black),
|
||||
children: [
|
||||
const TextSpan(text: ' '),
|
||||
TextSpan(
|
||||
text: post.title,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
style:
|
||||
widget.options.theme.textStyles.postTitleStyle ??
|
||||
theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
post.content,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${dateFormat.format(post.createdAt)} '
|
||||
'${widget.options.translations.postAt} '
|
||||
'${timeFormat.format(post.createdAt)}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (post.reactionEnabled) ...[
|
||||
Text(
|
||||
'${dateFormat.format(post.createdAt)} ',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// ignore: avoid_bool_literals_in_conditional_expressions
|
||||
if (post.reactionEnabled && widget.isOverviewScreen != null
|
||||
? !widget.isOverviewScreen!
|
||||
: false) ...[
|
||||
Text(
|
||||
widget.options.translations.commentsTitle,
|
||||
style: theme.textTheme.displaySmall,
|
||||
widget.options.translations.commentsTitleOnPost,
|
||||
style: theme.textTheme.titleSmall!
|
||||
.copyWith(color: Colors.black),
|
||||
),
|
||||
for (var reaction
|
||||
in post.reactions ?? <TimelinePostReaction>[]) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: reaction.imageUrl != null
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (reaction.creator?.imageUrl != null &&
|
||||
reaction.creator!.imageUrl!.isNotEmpty) ...[
|
||||
widget.options.userAvatarBuilder?.call(
|
||||
reaction.creator!,
|
||||
25,
|
||||
) ??
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
reaction.creator!.imageUrl!,
|
||||
const SizedBox(height: 4),
|
||||
GestureDetector(
|
||||
onLongPressStart: (details) async {
|
||||
if (reaction.creatorId == widget.userId ||
|
||||
widget.allowAllDeletion) {
|
||||
var overlay = Overlay.of(context)
|
||||
.context
|
||||
.findRenderObject()! as RenderBox;
|
||||
var position = RelativeRect.fromRect(
|
||||
Rect.fromPoints(
|
||||
details.globalPosition,
|
||||
details.globalPosition,
|
||||
),
|
||||
Offset.zero & overlay.size,
|
||||
);
|
||||
// Show popup menu for deletion
|
||||
var value = await showMenu<String>(
|
||||
context: context,
|
||||
position: position,
|
||||
items: [
|
||||
PopupMenuItem<String>(
|
||||
value: 'delete',
|
||||
child: Text(
|
||||
widget.options.translations.deleteReaction,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 10),
|
||||
if (reaction.imageUrl != null) ...[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
reaction.creator?.fullName ??
|
||||
widget
|
||||
.options.translations.anonymousUser,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: reaction.imageUrl!,
|
||||
fit: BoxFit.fitWidth,
|
||||
],
|
||||
);
|
||||
if (value == 'delete') {
|
||||
// Call service to delete reaction
|
||||
updatePost(
|
||||
await widget.service.postService
|
||||
.deletePostReaction(post, reaction.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (reaction.creator?.imageUrl != null &&
|
||||
reaction.creator!.imageUrl!.isNotEmpty) ...[
|
||||
widget.options.userAvatarBuilder?.call(
|
||||
reaction.creator!,
|
||||
14,
|
||||
) ??
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
reaction.creator!.imageUrl!,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: reaction.creator?.fullName ??
|
||||
widget.options.translations.anonymousUser,
|
||||
style: theme.textTheme.titleSmall,
|
||||
] else ...[
|
||||
widget.options.anonymousAvatarBuilder?.call(
|
||||
reaction.creator!,
|
||||
14,
|
||||
) ??
|
||||
const CircleAvatar(
|
||||
radius: 14,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 10),
|
||||
if (reaction.imageUrl != null) ...[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const TextSpan(text: ' '),
|
||||
TextSpan(
|
||||
text: reaction.reaction ?? '',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
Text(
|
||||
widget.options.nameBuilder
|
||||
?.call(reaction.creator) ??
|
||||
reaction.creator?.fullName ??
|
||||
widget.options.translations
|
||||
.anonymousUser,
|
||||
style: theme.textTheme.titleSmall!
|
||||
.copyWith(color: Colors.black),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: reaction.imageUrl!,
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
),
|
||||
// text should go to new line
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: widget.options.nameBuilder
|
||||
?.call(reaction.creator) ??
|
||||
reaction.creator?.fullName ??
|
||||
widget
|
||||
.options.translations.anonymousUser,
|
||||
style: theme.textTheme.titleSmall!
|
||||
.copyWith(color: Colors.black),
|
||||
children: [
|
||||
const TextSpan(text: ' '),
|
||||
TextSpan(
|
||||
text: reaction.reaction ?? '',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const TextSpan(text: '\n'),
|
||||
TextSpan(
|
||||
text: dateFormat
|
||||
.format(reaction.createdAt),
|
||||
style: theme.textTheme.labelSmall!
|
||||
.copyWith(
|
||||
color: theme
|
||||
.textTheme.labelSmall!.color!
|
||||
.withOpacity(0.5),
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
|
||||
// text should go to new line
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
Builder(
|
||||
builder: (context) {
|
||||
var isLikedByUser =
|
||||
reaction.likedBy?.contains(widget.userId) ??
|
||||
false;
|
||||
return IconButton(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () async {
|
||||
if (isLikedByUser) {
|
||||
updatePost(
|
||||
await widget.service.postService
|
||||
.unlikeReaction(
|
||||
widget.userId,
|
||||
post,
|
||||
reaction.id,
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
} else {
|
||||
updatePost(
|
||||
await widget.service.postService
|
||||
.likeReaction(
|
||||
widget.userId,
|
||||
post,
|
||||
reaction.id,
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
icon: isLikedByUser
|
||||
? widget.options.theme.likedIcon ??
|
||||
Icon(
|
||||
Icons.favorite_rounded,
|
||||
color:
|
||||
widget.options.theme.iconColor,
|
||||
size: 14,
|
||||
)
|
||||
: widget.options.theme.likeIcon ??
|
||||
Icon(
|
||||
Icons.favorite_outline_outlined,
|
||||
color:
|
||||
widget.options.theme.iconColor,
|
||||
size: 14,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
if (post.reactions?.isEmpty ?? true) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.options.translations.firstComment,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 120),
|
||||
|
@ -368,53 +619,74 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
|
|||
),
|
||||
),
|
||||
),
|
||||
if (post.reactionEnabled)
|
||||
if (post.reactionEnabled && !(widget.isOverviewScreen ?? false))
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ReactionBottom(
|
||||
messageInputBuilder: widget.options.textInputBuilder!,
|
||||
onPressSelectImage: () async {
|
||||
// open the image picker
|
||||
var result = await showModalBottomSheet<Uint8List?>(
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
color: Colors.black,
|
||||
child: ImagePicker(
|
||||
imagePickerConfig: widget.options.imagePickerConfig,
|
||||
imagePickerTheme: widget.options.imagePickerTheme,
|
||||
child: Container(
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width,
|
||||
),
|
||||
child: SafeArea(
|
||||
bottom: true,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: post.creator!.imageUrl != null
|
||||
? widget.options.userAvatarBuilder?.call(
|
||||
post.creator!,
|
||||
28,
|
||||
) ??
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
post.creator!.imageUrl!,
|
||||
),
|
||||
)
|
||||
: widget.options.anonymousAvatarBuilder?.call(
|
||||
post.creator!,
|
||||
28,
|
||||
) ??
|
||||
const CircleAvatar(
|
||||
radius: 14,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (result != null) {
|
||||
updatePost(
|
||||
await widget.service.reactToPost(
|
||||
post,
|
||||
TimelinePostReaction(
|
||||
id: '',
|
||||
postId: post.id,
|
||||
creatorId: widget.userId,
|
||||
createdAt: DateTime.now(),
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 16,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
),
|
||||
child: ReactionBottom(
|
||||
messageInputBuilder: textInputBuilder,
|
||||
onReactionSubmit: (reaction) async => updatePost(
|
||||
await widget.service.postService.reactToPost(
|
||||
post,
|
||||
TimelinePostReaction(
|
||||
id: '',
|
||||
postId: post.id,
|
||||
reaction: reaction,
|
||||
creatorId: widget.userId,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
),
|
||||
),
|
||||
translations: widget.options.translations,
|
||||
iconColor: widget.options.theme.iconColor,
|
||||
),
|
||||
),
|
||||
image: result,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onReactionSubmit: (reaction) async => updatePost(
|
||||
await widget.service.reactToPost(
|
||||
post,
|
||||
TimelinePostReaction(
|
||||
id: '',
|
||||
postId: post.id,
|
||||
reaction: reaction,
|
||||
creatorId: widget.userId,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
translations: widget.options.translations,
|
||||
iconColor: widget.options.theme.iconColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
@ -6,27 +6,35 @@ import 'dart:async';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
import 'package:flutter_timeline_view/src/config/timeline_options.dart';
|
||||
import 'package:flutter_timeline_view/src/widgets/timeline_post_widget.dart';
|
||||
import 'package:flutter_timeline_view/flutter_timeline_view.dart';
|
||||
|
||||
class TimelineScreen extends StatefulWidget {
|
||||
const TimelineScreen({
|
||||
required this.userId,
|
||||
required this.options,
|
||||
required this.onPostTap,
|
||||
required this.service,
|
||||
TimelineScreen({
|
||||
this.userId = 'test_user',
|
||||
TimelineService? service,
|
||||
this.options = const TimelineOptions(),
|
||||
this.onPostTap,
|
||||
this.scrollController,
|
||||
this.onUserTap,
|
||||
this.onRefresh,
|
||||
this.posts,
|
||||
this.controller,
|
||||
this.timelineCategoryFilter,
|
||||
this.timelinePostHeight,
|
||||
this.padding = const EdgeInsets.symmetric(vertical: 12.0),
|
||||
this.timelineCategory,
|
||||
this.postWidgetBuilder,
|
||||
this.filterEnabled = false,
|
||||
this.allowAllDeletion = false,
|
||||
super.key,
|
||||
});
|
||||
}) : service = service ??
|
||||
TimelineService(
|
||||
postService: LocalTimelinePostService(),
|
||||
);
|
||||
|
||||
/// The user id of the current user
|
||||
final String userId;
|
||||
|
||||
/// Allow all posts to be deleted instead of
|
||||
/// only the posts of the current user
|
||||
final bool allowAllDeletion;
|
||||
|
||||
/// The service to use for fetching and manipulating posts
|
||||
final TimelineService service;
|
||||
|
||||
|
@ -34,25 +42,29 @@ class TimelineScreen extends StatefulWidget {
|
|||
final TimelineOptions options;
|
||||
|
||||
/// The controller for the scroll view
|
||||
final ScrollController? controller;
|
||||
final ScrollController? scrollController;
|
||||
|
||||
final String? timelineCategoryFilter;
|
||||
|
||||
/// The height of a post in the timeline
|
||||
final double? timelinePostHeight;
|
||||
/// The string to filter the timeline by category
|
||||
final String? timelineCategory;
|
||||
|
||||
/// This is used if you want to pass in a list of posts instead
|
||||
/// of fetching them from the service
|
||||
final List<TimelinePost>? posts;
|
||||
|
||||
/// Called when a post is tapped
|
||||
final Function(TimelinePost) onPostTap;
|
||||
final Function(TimelinePost)? onPostTap;
|
||||
|
||||
/// Called when the timeline is refreshed by pulling down
|
||||
final Function(BuildContext context, String? category)? onRefresh;
|
||||
|
||||
/// If this is not null, the user can tap on the user avatar or name
|
||||
final Function(String userId)? onUserTap;
|
||||
|
||||
/// The padding between posts in the timeline
|
||||
final EdgeInsets padding;
|
||||
/// Override the standard postwidget
|
||||
final Widget Function(TimelinePost post)? postWidgetBuilder;
|
||||
|
||||
/// if true the filter textfield is enabled.
|
||||
final bool filterEnabled;
|
||||
|
||||
@override
|
||||
State<TimelineScreen> createState() => _TimelineScreenState();
|
||||
|
@ -60,85 +72,273 @@ class TimelineScreen extends StatefulWidget {
|
|||
|
||||
class _TimelineScreenState extends State<TimelineScreen> {
|
||||
late ScrollController controller;
|
||||
late var textFieldController = TextEditingController(
|
||||
text: widget.options.filterOptions.initialFilterWord,
|
||||
);
|
||||
|
||||
bool isLoading = true;
|
||||
|
||||
late var category = widget.timelineCategory;
|
||||
|
||||
late var filterWord = widget.options.filterOptions.initialFilterWord;
|
||||
|
||||
bool _isOnTop = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.removeListener(_updateIsOnTop);
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateIsOnTop() {
|
||||
setState(() {
|
||||
_isOnTop = controller.position.pixels < 0.1;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = widget.controller ?? ScrollController();
|
||||
unawaited(loadPosts());
|
||||
controller = widget.scrollController ?? ScrollController();
|
||||
controller.addListener(_updateIsOnTop);
|
||||
|
||||
// only load the posts after the first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(loadPosts());
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TimelineScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(loadPosts());
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isLoading && widget.posts == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return const Center(child: CircularProgressIndicator.adaptive());
|
||||
}
|
||||
|
||||
// Build the list of posts
|
||||
return ListenableBuilder(
|
||||
listenable: widget.service,
|
||||
listenable: widget.service.postService,
|
||||
builder: (context, _) {
|
||||
var posts = widget.posts ??
|
||||
widget.service.getPosts(widget.timelineCategoryFilter);
|
||||
if (!context.mounted) return const SizedBox();
|
||||
var posts =
|
||||
widget.posts ?? widget.service.postService.getPosts(category);
|
||||
|
||||
if (widget.filterEnabled && filterWord != null) {
|
||||
if (widget.service.postService is TimelineFilterService) {
|
||||
posts = (widget.service.postService as TimelineFilterService)
|
||||
.filterPosts(filterWord!, {});
|
||||
} else {
|
||||
debugPrint('Timeline service needs to mixin'
|
||||
' with TimelineFilterService');
|
||||
}
|
||||
}
|
||||
|
||||
posts = posts
|
||||
.where(
|
||||
(p) =>
|
||||
widget.timelineCategoryFilter == null ||
|
||||
p.category == widget.timelineCategoryFilter,
|
||||
(p) => category == null || p.category == category,
|
||||
)
|
||||
.toList();
|
||||
|
||||
// sort posts by date
|
||||
posts.sort(
|
||||
(a, b) => widget.options.sortPostsAscending
|
||||
? a.createdAt.compareTo(b.createdAt)
|
||||
: b.createdAt.compareTo(a.createdAt),
|
||||
);
|
||||
return SingleChildScrollView(
|
||||
controller: controller,
|
||||
child: Column(
|
||||
children: [
|
||||
...posts.map(
|
||||
(post) => Padding(
|
||||
padding: widget.padding,
|
||||
child: TimelinePostWidget(
|
||||
userId: widget.userId,
|
||||
options: widget.options,
|
||||
post: post,
|
||||
height: widget.timelinePostHeight,
|
||||
onTap: () => widget.onPostTap.call(post),
|
||||
onTapLike: () async =>
|
||||
widget.service.likePost(widget.userId, post),
|
||||
onTapUnlike: () async =>
|
||||
widget.service.unlikePost(widget.userId, post),
|
||||
onPostDelete: () async => widget.service.deletePost(post),
|
||||
onUserTap: widget.onUserTap,
|
||||
if (widget.options.sortPostsAscending != null) {
|
||||
posts.sort(
|
||||
(a, b) => widget.options.sortPostsAscending!
|
||||
? a.createdAt.compareTo(b.createdAt)
|
||||
: b.createdAt.compareTo(a.createdAt),
|
||||
);
|
||||
}
|
||||
|
||||
var categories = widget.service.postService.categories;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: widget.options.paddings.mainPadding.top,
|
||||
),
|
||||
if (widget.filterEnabled) ...[
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: widget.options.paddings.mainPadding.left,
|
||||
right: widget.options.paddings.mainPadding.right,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: textFieldController,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
filterWord = value;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.options.translations.searchHint,
|
||||
suffixIconConstraints:
|
||||
const BoxConstraints(maxHeight: 14),
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom: -10,
|
||||
),
|
||||
suffixIcon: const Padding(
|
||||
padding: EdgeInsets.only(right: 12),
|
||||
child: Icon(Icons.search),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
textFieldController.clear();
|
||||
filterWord = null;
|
||||
widget.options.filterOptions.onFilterEnabledChange
|
||||
?.call(filterEnabled: false);
|
||||
});
|
||||
},
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
color: Color(0xFF000000),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 24,
|
||||
),
|
||||
],
|
||||
CategorySelector(
|
||||
categories: categories,
|
||||
isOnTop: _isOnTop,
|
||||
filter: category,
|
||||
options: widget.options,
|
||||
onTapCategory: (categoryKey) {
|
||||
setState(() {
|
||||
service.postService.selectedCategory =
|
||||
categories.firstWhereOrNull(
|
||||
(element) => element.key == categoryKey,
|
||||
);
|
||||
category = categoryKey;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator.adaptive(
|
||||
onRefresh: () async {
|
||||
await widget.onRefresh?.call(context, category);
|
||||
await loadPosts();
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
controller: controller,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
/// Add a optional custom header to the list of posts
|
||||
widget.options.listHeaderBuilder
|
||||
?.call(context, category) ??
|
||||
const SizedBox.shrink(),
|
||||
...posts.map(
|
||||
(post) => Padding(
|
||||
padding: widget.options.paddings.postPadding,
|
||||
child: widget.postWidgetBuilder?.call(post) ??
|
||||
TimelinePostWidget(
|
||||
service: widget.service,
|
||||
userId: widget.userId,
|
||||
options: widget.options,
|
||||
allowAllDeletion: widget.allowAllDeletion,
|
||||
post: post,
|
||||
onTap: () async {
|
||||
if (widget.onPostTap != null) {
|
||||
widget.onPostTap!.call(post);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => Scaffold(
|
||||
body: TimelinePostScreen(
|
||||
userId: 'test_user',
|
||||
service: widget.service,
|
||||
options: widget.options,
|
||||
post: post,
|
||||
onPostDelete: () {
|
||||
widget.service.postService
|
||||
.deletePost(post);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onTapLike: () async => widget
|
||||
.service.postService
|
||||
.likePost(widget.userId, post),
|
||||
onTapUnlike: () async => widget
|
||||
.service.postService
|
||||
.unlikePost(widget.userId, post),
|
||||
onPostDelete: () async =>
|
||||
widget.service.postService.deletePost(post),
|
||||
onUserTap: widget.onUserTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (posts.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
category == null
|
||||
? widget.options.translations.noPosts
|
||||
: widget
|
||||
.options.translations.noPostsWithFilter,
|
||||
style:
|
||||
widget.options.theme.textStyles.noPostsStyle,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: widget.options.paddings.mainPadding.bottom,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (posts.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
widget.timelineCategoryFilter == null
|
||||
? widget.options.translations.noPosts
|
||||
: widget.options.translations.noPostsWithFilter,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> loadPosts() async {
|
||||
if (widget.posts != null) return;
|
||||
if (widget.posts != null || !context.mounted) return;
|
||||
try {
|
||||
await widget.service.fetchPosts(widget.timelineCategoryFilter);
|
||||
await widget.service.postService.fetchCategories();
|
||||
await widget.service.postService.fetchPosts(category);
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
|
|
|
@ -0,0 +1,215 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
import 'package:flutter_timeline_view/src/config/timeline_options.dart';
|
||||
import 'package:flutter_timeline_view/src/widgets/default_filled_button.dart';
|
||||
import 'package:flutter_timeline_view/src/widgets/post_creation_textfield.dart';
|
||||
|
||||
class TimelineSelectionScreen extends StatefulWidget {
|
||||
const TimelineSelectionScreen({
|
||||
required this.options,
|
||||
required this.categories,
|
||||
required this.onCategorySelected,
|
||||
required this.postService,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final List<TimelineCategory> categories;
|
||||
|
||||
final TimelineOptions options;
|
||||
|
||||
final Function(TimelineCategory) onCategorySelected;
|
||||
|
||||
final TimelinePostService postService;
|
||||
|
||||
@override
|
||||
State<TimelineSelectionScreen> createState() =>
|
||||
_TimelineSelectionScreenState();
|
||||
}
|
||||
|
||||
class _TimelineSelectionScreenState extends State<TimelineSelectionScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var size = MediaQuery.of(context).size;
|
||||
var theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: size.width * 0.05,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 12),
|
||||
child: Text(
|
||||
widget.options.translations.timelineSelectionDescription,
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
for (var category in widget.categories.where(
|
||||
(element) => element.canCreate && element.key != null,
|
||||
)) ...[
|
||||
widget.options.categorySelectorButtonBuilder?.call(
|
||||
context,
|
||||
() {
|
||||
widget.onCategorySelected.call(category);
|
||||
},
|
||||
category.title,
|
||||
) ??
|
||||
InkWell(
|
||||
onTap: () => widget.onCategorySelected.call(category),
|
||||
child: Container(
|
||||
height: 60,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: widget.options.theme
|
||||
.categorySelectionButtonBorderColor ??
|
||||
Theme.of(context).primaryColor,
|
||||
width: 2,
|
||||
),
|
||||
color: widget.options.theme
|
||||
.categorySelectionButtonBackgroundColor,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Text(
|
||||
category.title,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
InkWell(
|
||||
onTap: showCategoryPopup,
|
||||
child: Container(
|
||||
height: 60,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: widget
|
||||
.options.theme.categorySelectionButtonBorderColor ??
|
||||
const Color(0xFF9E9E9E),
|
||||
width: 2,
|
||||
),
|
||||
color: widget
|
||||
.options.theme.categorySelectionButtonBackgroundColor,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add,
|
||||
color: theme.textTheme.titleMedium?.color!
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.options.translations.addCategoryTitle,
|
||||
style: theme.textTheme.titleMedium!.copyWith(
|
||||
color: theme.textTheme.titleMedium?.color!
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showCategoryPopup() async {
|
||||
var theme = Theme.of(context);
|
||||
var controller = TextEditingController();
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: theme.scaffoldBackgroundColor,
|
||||
insetPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 50, vertical: 24),
|
||||
titlePadding: const EdgeInsets.only(left: 44, right: 44, top: 32),
|
||||
title: Text(
|
||||
widget.options.translations.createCategoryPopuptitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PostCreationTextfield(
|
||||
controller: controller,
|
||||
hintText: widget.options.translations.addCategoryHintText,
|
||||
validator: (p0) => p0!.isEmpty
|
||||
? widget.options.translations.addCategoryErrorText
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: DefaultFilledButton(
|
||||
onPressed: () async {
|
||||
if (controller.text.isEmpty) return;
|
||||
await widget.postService.addCategory(
|
||||
TimelineCategory(
|
||||
key: controller.text,
|
||||
title: controller.text,
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
buttonText:
|
||||
widget.options.translations.addCategorySubmitButton,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(false);
|
||||
},
|
||||
child: Text(
|
||||
widget.options.translations.addCategoryCancelButtton,
|
||||
style: theme.textTheme.bodyMedium!.copyWith(
|
||||
decoration: TextDecoration.underline,
|
||||
color: theme.textTheme.bodyMedium?.color!.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,332 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
|
||||
class LocalTimelinePostService
|
||||
with ChangeNotifier
|
||||
implements TimelinePostService {
|
||||
@override
|
||||
List<TimelinePost> posts = [];
|
||||
|
||||
@override
|
||||
List<TimelineCategory> categories = [];
|
||||
|
||||
@override
|
||||
TimelineCategory? selectedCategory;
|
||||
|
||||
@override
|
||||
Future<TimelinePost> createPost(TimelinePost post) async {
|
||||
posts.add(
|
||||
post.copyWith(
|
||||
creator: const TimelinePosterUserModel(
|
||||
userId: 'test_user',
|
||||
imageUrl:
|
||||
'https://cdn.britannica.com/68/143568-050-5246474F/Donkey.jpg?w=400&h=300&c=crop',
|
||||
firstName: 'Ico',
|
||||
lastName: 'Nica',
|
||||
),
|
||||
),
|
||||
);
|
||||
notifyListeners();
|
||||
return post;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<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',
|
||||
imageUrl:
|
||||
'https://cdn.britannica.com/68/143568-050-5246474F/Donkey.jpg?w=400&h=300&c=crop',
|
||||
firstName: 'Dirk',
|
||||
lastName: 'lukassen',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
var updatedPost = post.copyWith(reactions: updatedReactions);
|
||||
posts = posts.map((p) => (p.id == post.id) ? updatedPost : p).toList();
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TimelinePost>> fetchPosts(String? category) async {
|
||||
if (posts.isEmpty) {
|
||||
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
|
||||
Future<TimelinePost?> getPost(String postId) => Future.value(
|
||||
(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 = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
|
||||
var updatedReaction = reaction.copyWith(
|
||||
id: reactionId,
|
||||
creator: const TimelinePosterUserModel(
|
||||
userId: 'test_user',
|
||||
imageUrl:
|
||||
'https://cdn.britannica.com/68/143568-050-5246474F/Donkey.jpg?w=400&h=300&c=crop',
|
||||
firstName: 'Ico',
|
||||
lastName: 'Nica',
|
||||
),
|
||||
);
|
||||
|
||||
var updatedPost = post.copyWith(
|
||||
reaction: post.reaction + 1,
|
||||
reactions: post.reactions?..add(updatedReaction),
|
||||
);
|
||||
|
||||
posts = posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
List<TimelinePost> getMockedPosts() => [
|
||||
TimelinePost(
|
||||
id: 'Post0',
|
||||
creatorId: 'test_user',
|
||||
title: 'De topper van de maand september',
|
||||
category: 'Category',
|
||||
imageUrl:
|
||||
'https://firebasestorage.googleapis.com/v0/b/appshell-demo.appspot.com/o/do_not_delete_1.png?alt=media&token=e4b2f9f3-c81f-4ac7-a938-e846691399f7',
|
||||
content: 'Dit is onze topper van de maand september! Gefeliciteerd!',
|
||||
likes: 72,
|
||||
reaction: 0,
|
||||
createdAt: DateTime.now(),
|
||||
reactionEnabled: true,
|
||||
creator: const TimelinePosterUserModel(
|
||||
userId: 'test_user',
|
||||
imageUrl:
|
||||
'https://firebasestorage.googleapis.com/v0/b/appshell-demo.appspot.com/o/do_not_delete_3.png?alt=media&token=cd7c156d-0dda-43be-9199-f7d31c30132e',
|
||||
firstName: 'Robin',
|
||||
lastName: 'De Vries',
|
||||
),
|
||||
),
|
||||
TimelinePost(
|
||||
id: 'Post1',
|
||||
creatorId: 'test_user2',
|
||||
title: 'De soep van de week is: Aspergesoep',
|
||||
category: 'Category with two lines',
|
||||
content:
|
||||
'Aspergesoep is echt een heerlijke delicatesse. Deze soep wordt'
|
||||
' vaak gemaakt met verse asperges, bouillon en wat kruiden voor'
|
||||
' smaak. Het is een perfecte keuze voor een lichte en smaakvolle'
|
||||
' maaltijd, vooral in het voorjaar wanneer asperges in seizoen'
|
||||
' zijn. We serveren het met een vleugje room en wat knapperige'
|
||||
' croutons voor die extra touch.',
|
||||
likes: 72,
|
||||
reaction: 0,
|
||||
createdAt: DateTime.now(),
|
||||
reactionEnabled: true,
|
||||
imageUrl:
|
||||
'https://firebasestorage.googleapis.com/v0/b/appshell-demo.appspot.com/o/do_not_delete_2.png?alt=media&token=ee4a8771-531f-4d1d-8613-a2366771e775',
|
||||
creator: const TimelinePosterUserModel(
|
||||
userId: 'test_user',
|
||||
imageUrl:
|
||||
'https://firebasestorage.googleapis.com/v0/b/appshell-demo.appspot.com/o/do_not_delete_4.png?alt=media&token=775d4d10-6d2b-4aef-a51b-ba746b7b137f',
|
||||
firstName: 'Elise',
|
||||
lastName: 'Welling',
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<bool> addCategory(TimelineCategory category) async {
|
||||
categories.add(category);
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TimelineCategory>> fetchCategories() async {
|
||||
categories = [
|
||||
const TimelineCategory(key: null, title: 'All'),
|
||||
const TimelineCategory(
|
||||
key: 'Category',
|
||||
title: 'Category',
|
||||
),
|
||||
const TimelineCategory(
|
||||
key: 'Category with two lines',
|
||||
title: 'Category with two lines',
|
||||
),
|
||||
];
|
||||
notifyListeners();
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> likeReaction(
|
||||
String userId,
|
||||
TimelinePost post,
|
||||
String reactionId,
|
||||
) async {
|
||||
var updatedPost = post.copyWith(
|
||||
reactions: post.reactions?.map(
|
||||
(r) {
|
||||
if (r.id == reactionId) {
|
||||
return r.copyWith(
|
||||
likedBy: (r.likedBy ?? [])..add(userId),
|
||||
);
|
||||
}
|
||||
return r;
|
||||
},
|
||||
).toList(),
|
||||
);
|
||||
posts = posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> unlikeReaction(
|
||||
String userId,
|
||||
TimelinePost post,
|
||||
String reactionId,
|
||||
) async {
|
||||
var updatedPost = post.copyWith(
|
||||
reactions: post.reactions?.map(
|
||||
(r) {
|
||||
if (r.id == reactionId) {
|
||||
return r.copyWith(
|
||||
likedBy: r.likedBy?..remove(userId),
|
||||
);
|
||||
}
|
||||
return r;
|
||||
},
|
||||
).toList(),
|
||||
);
|
||||
posts = posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
import 'package:flutter_timeline_view/flutter_timeline_view.dart';
|
||||
|
||||
class CategorySelector extends StatefulWidget {
|
||||
const CategorySelector({
|
||||
required this.filter,
|
||||
required this.options,
|
||||
required this.onTapCategory,
|
||||
required this.isOnTop,
|
||||
required this.categories,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String? filter;
|
||||
final TimelineOptions options;
|
||||
final void Function(String? categoryKey) onTapCategory;
|
||||
final bool isOnTop;
|
||||
final List<TimelineCategory> categories;
|
||||
|
||||
@override
|
||||
State<CategorySelector> createState() => _CategorySelectorState();
|
||||
}
|
||||
|
||||
class _CategorySelectorState extends State<CategorySelector> {
|
||||
@override
|
||||
Widget build(BuildContext context) => SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: widget.options.categoriesOptions
|
||||
.categorySelectorHorizontalPadding ??
|
||||
max(widget.options.paddings.mainPadding.left - 20, 0),
|
||||
),
|
||||
for (var category in widget.categories) ...[
|
||||
widget.options.categoriesOptions.categoryButtonBuilder?.call(
|
||||
category,
|
||||
() => widget.onTapCategory(category.key),
|
||||
widget.filter == category.key,
|
||||
widget.isOnTop,
|
||||
) ??
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: CategorySelectorButton(
|
||||
isOnTop: widget.isOnTop,
|
||||
category: category,
|
||||
selected: widget.filter == category.key,
|
||||
onTap: () => widget.onTapCategory(category.key),
|
||||
options: widget.options,
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(
|
||||
width: widget.options.categoriesOptions
|
||||
.categorySelectorHorizontalPadding ??
|
||||
max(widget.options.paddings.mainPadding.right - 4, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
|
@ -0,0 +1,143 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
import 'package:flutter_timeline_view/flutter_timeline_view.dart';
|
||||
|
||||
class CategorySelectorButton extends StatelessWidget {
|
||||
const CategorySelectorButton({
|
||||
required this.category,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
required this.options,
|
||||
required this.isOnTop,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TimelineCategory category;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
final TimelineOptions options;
|
||||
final bool isOnTop;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
var size = MediaQuery.of(context).size;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
height: isOnTop ? 140 : 40,
|
||||
child: TextButton(
|
||||
onPressed: onTap,
|
||||
style: ButtonStyle(
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
padding: const WidgetStatePropertyAll(
|
||||
EdgeInsets.symmetric(
|
||||
vertical: 5,
|
||||
horizontal: 12,
|
||||
),
|
||||
),
|
||||
fixedSize: WidgetStatePropertyAll(Size(140, isOnTop ? 140 : 20)),
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
selected
|
||||
? theme.colorScheme.primary
|
||||
: options.theme.categorySelectionButtonBackgroundColor ??
|
||||
Colors.transparent,
|
||||
),
|
||||
shape: WidgetStatePropertyAll(
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
side: BorderSide(
|
||||
color: options.theme.categorySelectionButtonBorderColor ??
|
||||
theme.colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: isOnTop
|
||||
? SizedBox(
|
||||
width: size.width,
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_CategoryButtonText(
|
||||
category: category,
|
||||
options: options,
|
||||
theme: theme,
|
||||
selected: selected,
|
||||
),
|
||||
],
|
||||
),
|
||||
Center(child: category.icon),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Row(
|
||||
children: [
|
||||
if (category.icon != null) ...[
|
||||
category.icon!,
|
||||
SizedBox(
|
||||
width:
|
||||
options.paddings.categoryButtonTextPadding ?? 8,
|
||||
),
|
||||
],
|
||||
Expanded(
|
||||
child: _CategoryButtonText(
|
||||
category: category,
|
||||
options: options,
|
||||
theme: theme,
|
||||
selected: selected,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryButtonText extends StatelessWidget {
|
||||
const _CategoryButtonText({
|
||||
required this.category,
|
||||
required this.options,
|
||||
required this.theme,
|
||||
required this.selected,
|
||||
this.overflow,
|
||||
});
|
||||
|
||||
final TimelineCategory category;
|
||||
final TimelineOptions options;
|
||||
final ThemeData theme;
|
||||
final bool selected;
|
||||
final TextOverflow? overflow;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(
|
||||
category.title,
|
||||
style: (options.theme.textStyles.categoryTitleStyle ??
|
||||
(selected
|
||||
? theme.textTheme.titleMedium
|
||||
: theme.textTheme.bodyMedium))
|
||||
?.copyWith(
|
||||
color: selected
|
||||
? options.theme.categorySelectionButtonSelectedTextColor ??
|
||||
theme.colorScheme.onPrimary
|
||||
: options.theme.categorySelectionButtonUnselectedTextColor ??
|
||||
theme.colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.start,
|
||||
overflow: overflow,
|
||||
);
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class DefaultFilledButton extends StatelessWidget {
|
||||
const DefaultFilledButton({
|
||||
required this.onPressed,
|
||||
required this.buttonText,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Future<void> Function()? onPressed;
|
||||
final String buttonText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
return FilledButton(
|
||||
style: onPressed != null
|
||||
? ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
theme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onPressed: onPressed,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
buttonText,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.displayLarge,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class PostCreationTextfield extends StatelessWidget {
|
||||
const PostCreationTextfield({
|
||||
required this.controller,
|
||||
required this.hintText,
|
||||
required this.validator,
|
||||
super.key,
|
||||
this.textMaxLength,
|
||||
this.decoration,
|
||||
this.textCapitalization,
|
||||
this.expands,
|
||||
this.minLines,
|
||||
this.maxLines,
|
||||
this.fieldKey,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String hintText;
|
||||
final int? textMaxLength;
|
||||
final InputDecoration? decoration;
|
||||
final TextCapitalization? textCapitalization;
|
||||
// ignore: avoid_positional_boolean_parameters
|
||||
final bool? expands;
|
||||
final int? minLines;
|
||||
final int? maxLines;
|
||||
final String? Function(String?)? validator;
|
||||
final Key? fieldKey;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
return TextFormField(
|
||||
key: fieldKey,
|
||||
validator: validator,
|
||||
style: theme.textTheme.bodySmall,
|
||||
controller: controller,
|
||||
maxLength: textMaxLength,
|
||||
decoration: decoration ??
|
||||
InputDecoration(
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 0,
|
||||
horizontal: 16,
|
||||
),
|
||||
hintText: hintText,
|
||||
hintStyle: theme.textTheme.bodySmall!.copyWith(
|
||||
color: theme.textTheme.bodySmall!.color!.withOpacity(0.5),
|
||||
),
|
||||
fillColor: Colors.white,
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
textCapitalization: textCapitalization ?? TextCapitalization.none,
|
||||
expands: expands ?? false,
|
||||
minLines: minLines,
|
||||
maxLines: maxLines,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@
|
|||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:flutter_timeline_view/src/config/timeline_options.dart';
|
||||
import 'package:flutter_timeline_view/src/config/timeline_translations.dart';
|
||||
|
||||
|
@ -11,14 +12,12 @@ class ReactionBottom extends StatefulWidget {
|
|||
required this.onReactionSubmit,
|
||||
required this.messageInputBuilder,
|
||||
required this.translations,
|
||||
this.onPressSelectImage,
|
||||
this.iconColor,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Future<void> Function(String text) onReactionSubmit;
|
||||
final TextInputBuilder messageInputBuilder;
|
||||
final VoidCallback? onPressSelectImage;
|
||||
final TimelineTranslations translations;
|
||||
final Color? iconColor;
|
||||
|
||||
|
@ -31,46 +30,29 @@ class _ReactionBottomState extends State<ReactionBottom> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
color: Theme.of(context).colorScheme.background,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
height: 45,
|
||||
child: widget.messageInputBuilder(
|
||||
_textEditingController,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 15.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: widget.onPressSelectImage,
|
||||
icon: Icon(
|
||||
Icons.image,
|
||||
color: widget.iconColor,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
var value = _textEditingController.text;
|
||||
|
||||
if (value.isNotEmpty) {
|
||||
await widget.onReactionSubmit(value);
|
||||
_textEditingController.clear();
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.send,
|
||||
color: widget.iconColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: widget.messageInputBuilder(
|
||||
_textEditingController,
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: () async {
|
||||
var value = _textEditingController.text;
|
||||
if (value.isNotEmpty) {
|
||||
await widget.onReactionSubmit(value);
|
||||
_textEditingController.clear();
|
||||
}
|
||||
},
|
||||
icon: SvgPicture.asset(
|
||||
'assets/send.svg',
|
||||
package: 'flutter_timeline_view',
|
||||
// ignore: deprecated_member_use
|
||||
color: widget.iconColor,
|
||||
),
|
||||
),
|
||||
widget.translations.writeComment,
|
||||
),
|
||||
widget.translations.writeComment,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,175 @@
|
|||
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: widget.post.imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: widget.post.imageUrl ?? '',
|
||||
width: double.infinity,
|
||||
fit: BoxFit.fitHeight,
|
||||
)
|
||||
: Image.memory(
|
||||
width: double.infinity,
|
||||
widget.post.image!,
|
||||
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;
|
||||
// ignore: use_build_context_synchronously
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
|
@ -4,187 +4,441 @@
|
|||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
import 'package:flutter_timeline_view/src/config/timeline_options.dart';
|
||||
import 'package:flutter_timeline_view/src/widgets/default_filled_button.dart';
|
||||
import 'package:flutter_timeline_view/src/widgets/tappable_image.dart';
|
||||
|
||||
class TimelinePostWidget extends StatelessWidget {
|
||||
class TimelinePostWidget extends StatefulWidget {
|
||||
const TimelinePostWidget({
|
||||
required this.userId,
|
||||
required this.options,
|
||||
required this.post,
|
||||
required this.height,
|
||||
required this.onTap,
|
||||
required this.onTapLike,
|
||||
required this.onTapUnlike,
|
||||
required this.onPostDelete,
|
||||
required this.service,
|
||||
required this.allowAllDeletion,
|
||||
this.onUserTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The user id of the current user
|
||||
final String userId;
|
||||
|
||||
/// Allow all posts to be deleted instead of
|
||||
/// only the posts of the current user
|
||||
final bool allowAllDeletion;
|
||||
|
||||
final TimelineOptions options;
|
||||
|
||||
final TimelinePost post;
|
||||
|
||||
/// Optional max height of the post
|
||||
final double? height;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onTapLike;
|
||||
final VoidCallback onTapUnlike;
|
||||
final VoidCallback onPostDelete;
|
||||
final TimelineService service;
|
||||
|
||||
/// If this is not null, the user can tap on the user avatar or name
|
||||
final Function(String userId)? onUserTap;
|
||||
|
||||
@override
|
||||
State<TimelinePostWidget> createState() => _TimelinePostWidgetState();
|
||||
}
|
||||
|
||||
class _TimelinePostWidgetState extends State<TimelinePostWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
// TODO(anyone): should posts with text have a max height?
|
||||
height: post.imageUrl != null ? height : null,
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (post.creator != null)
|
||||
InkWell(
|
||||
onTap: onUserTap != null
|
||||
? () => onUserTap?.call(post.creator!.userId)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
if (post.creator!.imageUrl != null) ...[
|
||||
options.userAvatarBuilder?.call(
|
||||
post.creator!,
|
||||
40,
|
||||
) ??
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
post.creator!.imageUrl!,
|
||||
),
|
||||
var isLikedByUser = widget.post.likedBy?.contains(widget.userId) ?? false;
|
||||
|
||||
return SizedBox(
|
||||
height: widget.post.imageUrl != null || widget.post.image != null
|
||||
? widget.options.postWidgetHeight
|
||||
: null,
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (widget.post.creator != null) ...[
|
||||
InkWell(
|
||||
onTap: widget.onUserTap != null
|
||||
? () =>
|
||||
widget.onUserTap?.call(widget.post.creator!.userId)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.post.creator!.imageUrl != null) ...[
|
||||
widget.options.userAvatarBuilder?.call(
|
||||
widget.post.creator!,
|
||||
28,
|
||||
) ??
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
widget.post.creator!.imageUrl!,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 10),
|
||||
if (post.creator!.fullName != null) ...[
|
||||
Text(
|
||||
post.creator!.fullName!,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
widget.options.anonymousAvatarBuilder?.call(
|
||||
widget.post.creator!,
|
||||
28,
|
||||
) ??
|
||||
const CircleAvatar(
|
||||
radius: 14,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (options.allowAllDeletion || post.creator?.userId == userId)
|
||||
PopupMenuButton(
|
||||
onSelected: (value) {
|
||||
if (value == 'delete') {
|
||||
onPostDelete();
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) =>
|
||||
<PopupMenuEntry<String>>[
|
||||
PopupMenuItem<String>(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Text(options.translations.deletePost),
|
||||
const SizedBox(width: 8),
|
||||
options.theme.deleteIcon ??
|
||||
Icon(
|
||||
Icons.delete,
|
||||
color: options.theme.iconColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
widget.options.nameBuilder?.call(widget.post.creator) ??
|
||||
widget.post.creator?.fullName ??
|
||||
widget.options.translations.anonymousUser,
|
||||
style: widget.options.theme.textStyles
|
||||
.postCreatorTitleStyle ??
|
||||
theme.textTheme.titleSmall!.copyWith(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: options.theme.moreIcon ??
|
||||
Icon(
|
||||
Icons.more_horiz_rounded,
|
||||
color: options.theme.iconColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// image of the post
|
||||
if (post.imageUrl != null) ...[
|
||||
Flexible(
|
||||
flex: height != null ? 1 : 0,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: post.imageUrl!,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
),
|
||||
],
|
||||
// post information
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (post.likedBy?.contains(userId) ?? false) ...[
|
||||
InkWell(
|
||||
onTap: onTapUnlike,
|
||||
child: options.theme.likedIcon ??
|
||||
Icon(
|
||||
Icons.thumb_up_rounded,
|
||||
color: options.theme.iconColor,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
InkWell(
|
||||
onTap: onTapLike,
|
||||
child: options.theme.likeIcon ??
|
||||
Icon(
|
||||
Icons.thumb_up_alt_outlined,
|
||||
color: options.theme.iconColor,
|
||||
],
|
||||
const Spacer(),
|
||||
if (widget.allowAllDeletion ||
|
||||
widget.post.creator?.userId == widget.userId) ...[
|
||||
PopupMenuButton(
|
||||
onSelected: (value) async {
|
||||
if (value == 'delete') {
|
||||
await showPostDeletionConfirmationDialog(
|
||||
widget.options,
|
||||
context,
|
||||
widget.onPostDelete,
|
||||
);
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) =>
|
||||
<PopupMenuEntry<String>>[
|
||||
PopupMenuItem<String>(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.options.translations.deletePost,
|
||||
style: widget
|
||||
.options.theme.textStyles.deletePostStyle ??
|
||||
theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
widget.options.theme.deleteIcon ??
|
||||
Icon(
|
||||
Icons.delete,
|
||||
color: widget.options.theme.iconColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
if (post.reactionEnabled)
|
||||
options.theme.commentIcon ??
|
||||
const Icon(
|
||||
Icons.chat_bubble_outline_rounded,
|
||||
),
|
||||
],
|
||||
child: widget.options.theme.moreIcon ??
|
||||
Icon(
|
||||
Icons.more_horiz_rounded,
|
||||
color: widget.options.theme.iconColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
// image of the post
|
||||
if (widget.post.imageUrl != null || widget.post.image != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Flexible(
|
||||
flex: widget.options.postWidgetHeight != null ? 1 : 0,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: widget.options.doubleTapTolike
|
||||
? TappableImage(
|
||||
likeAndDislikeIcon:
|
||||
widget.options.likeAndDislikeIconsForDoubleTap,
|
||||
post: widget.post,
|
||||
userId: widget.userId,
|
||||
onLike: ({required bool liked}) async {
|
||||
var userId = widget.userId;
|
||||
|
||||
late TimelinePost result;
|
||||
|
||||
if (!liked) {
|
||||
result = await widget.service.postService.likePost(
|
||||
userId,
|
||||
widget.post,
|
||||
);
|
||||
} else {
|
||||
result =
|
||||
await widget.service.postService.unlikePost(
|
||||
userId,
|
||||
widget.post,
|
||||
);
|
||||
}
|
||||
|
||||
return result.likedBy?.contains(userId) ?? false;
|
||||
},
|
||||
)
|
||||
: widget.post.imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
width: double.infinity,
|
||||
imageUrl: widget.post.imageUrl!,
|
||||
fit: BoxFit.fitWidth,
|
||||
)
|
||||
: Image.memory(
|
||||
width: double.infinity,
|
||||
widget.post.image!,
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${post.likes} ${options.translations.likesTitle}',
|
||||
style: theme.textTheme.titleSmall,
|
||||
],
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
// post information
|
||||
if (widget.options.iconsWithValues) ...[
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () async {
|
||||
var userId = widget.userId;
|
||||
|
||||
if (!isLikedByUser) {
|
||||
await widget.service.postService.likePost(
|
||||
userId,
|
||||
widget.post,
|
||||
);
|
||||
} else {
|
||||
await widget.service.postService.unlikePost(
|
||||
userId,
|
||||
widget.post,
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: widget.options.theme.likeIcon ??
|
||||
Icon(
|
||||
isLikedByUser
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_outline_outlined,
|
||||
color: widget.options.theme.iconColor,
|
||||
size: widget.options.iconSize,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 4,
|
||||
),
|
||||
Text('${widget.post.likes}'),
|
||||
if (widget.post.reactionEnabled) ...[
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: widget.onTap,
|
||||
icon: widget.options.theme.commentIcon ??
|
||||
SvgPicture.asset(
|
||||
'assets/Comment.svg',
|
||||
package: 'flutter_timeline_view',
|
||||
// ignore: deprecated_member_use
|
||||
color: widget.options.theme.iconColor,
|
||||
width: widget.options.iconSize,
|
||||
height: widget.options.iconSize,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 4,
|
||||
),
|
||||
Text('${widget.post.reaction}'),
|
||||
],
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed:
|
||||
isLikedByUser ? widget.onTapUnlike : widget.onTapLike,
|
||||
icon: (isLikedByUser
|
||||
? widget.options.theme.likedIcon
|
||||
: widget.options.theme.likeIcon) ??
|
||||
Icon(
|
||||
isLikedByUser
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_outline,
|
||||
color: widget.options.theme.iconColor,
|
||||
size: widget.options.iconSize,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (widget.post.reactionEnabled) ...[
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: widget.onTap,
|
||||
icon: widget.options.theme.commentIcon ??
|
||||
SvgPicture.asset(
|
||||
'assets/Comment.svg',
|
||||
package: 'flutter_timeline_view',
|
||||
// ignore: deprecated_member_use
|
||||
color: widget.options.theme.iconColor,
|
||||
width: widget.options.iconSize,
|
||||
height: widget.options.iconSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
|
||||
if (widget.options.itemInfoBuilder != null) ...[
|
||||
widget.options.itemInfoBuilder!(
|
||||
post: widget.post,
|
||||
),
|
||||
] else ...[
|
||||
_PostLikeCountText(
|
||||
post: widget.post,
|
||||
options: widget.options,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
text: post.creator?.fullName ??
|
||||
options.translations.anonymousUser,
|
||||
style: theme.textTheme.titleSmall,
|
||||
text: widget.options.nameBuilder?.call(widget.post.creator) ??
|
||||
widget.post.creator?.fullName ??
|
||||
widget.options.translations.anonymousUser,
|
||||
style: widget.options.theme.textStyles.listCreatorNameStyle ??
|
||||
theme.textTheme.titleSmall!.copyWith(
|
||||
color: Colors.black,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: ' '),
|
||||
TextSpan(
|
||||
text: post.title,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
text: widget.post.title,
|
||||
style: widget.options.theme.textStyles.listPostTitleStyle ??
|
||||
theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
options.translations.viewPost,
|
||||
style: theme.textTheme.bodySmall,
|
||||
const SizedBox(height: 4),
|
||||
InkWell(
|
||||
onTap: widget.onTap,
|
||||
child: Text(
|
||||
widget.options.translations.viewPost,
|
||||
style: widget.options.theme.textStyles.viewPostStyle ??
|
||||
theme.textTheme.titleSmall!.copyWith(
|
||||
color: const Color(0xFF8D8D8D),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.options.dividerBuilder != null)
|
||||
widget.options.dividerBuilder!(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostLikeCountText extends StatelessWidget {
|
||||
const _PostLikeCountText({
|
||||
required this.post,
|
||||
required this.options,
|
||||
});
|
||||
|
||||
final TimelineOptions options;
|
||||
final TimelinePost post;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
var likeTranslation = post.likes > 1
|
||||
? options.translations.multipleLikesTitle
|
||||
: options.translations.oneLikeTitle;
|
||||
|
||||
return Text(
|
||||
'${post.likes} '
|
||||
'$likeTranslation',
|
||||
style: options.theme.textStyles.listPostLikeTitleAndAmount ??
|
||||
theme.textTheme.titleSmall!.copyWith(
|
||||
color: Colors.black,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showPostDeletionConfirmationDialog(
|
||||
TimelineOptions options,
|
||||
BuildContext context,
|
||||
Function() onPostDelete,
|
||||
) async {
|
||||
var theme = Theme.of(context);
|
||||
var result = await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) =>
|
||||
options.deletionDialogBuilder?.call(context) ??
|
||||
AlertDialog(
|
||||
insetPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 64, vertical: 24),
|
||||
titlePadding: const EdgeInsets.only(left: 44, right: 44, top: 32),
|
||||
title: Text(
|
||||
options.translations.deleteConfirmationMessage,
|
||||
style: theme.textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DefaultFilledButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
buttonText: options.translations.deleteButton,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(false);
|
||||
},
|
||||
child: Text(
|
||||
options.translations.deleteCancelButton,
|
||||
style: theme.textTheme.bodyMedium!.copyWith(
|
||||
decoration: TextDecoration.underline,
|
||||
color: theme.textTheme.bodyMedium?.color!.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
onPostDelete();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,36 +4,35 @@
|
|||
|
||||
name: flutter_timeline_view
|
||||
description: Visual elements of the Flutter Timeline Component
|
||||
version: 0.0.1
|
||||
|
||||
publish_to: none
|
||||
version: 5.1.1
|
||||
homepage: https://github.com/Iconica-Development/flutter_timeline
|
||||
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
|
||||
environment:
|
||||
sdk: '>=3.1.3 <4.0.0'
|
||||
sdk: ">=3.4.3 <4.0.0"
|
||||
flutter: '>=3.22.2'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
intl: any
|
||||
intl: ^0.19.0
|
||||
cached_network_image: ^3.2.2
|
||||
dotted_border: ^2.1.0
|
||||
|
||||
collection: ^1.18.0
|
||||
flutter_svg: ^2.0.10+1
|
||||
flutter_timeline_interface:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_timeline.git
|
||||
path: packages/flutter_timeline_interface
|
||||
ref: 0.0.1
|
||||
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
version: ^5.1.1
|
||||
flutter_image_picker:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_image_picker
|
||||
ref: 1.0.4
|
||||
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
version: ^4.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^2.0.0
|
||||
flutter_iconica_analysis:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_iconica_analysis
|
||||
ref: 6.0.0
|
||||
|
||||
flutter:
|
||||
|
||||
assets:
|
||||
- assets/
|
||||
|
|
|
@ -5,6 +5,6 @@
|
|||
name: flutter_timeline_workspace
|
||||
|
||||
environment:
|
||||
sdk: '>=2.18.0 <3.0.0'
|
||||
sdk: '>=3.4.3 <4.0.0'
|
||||
dev_dependencies:
|
||||
melos: ^3.0.1
|
||||
|
|
Loading…
Reference in a new issue