Compare commits

...

63 commits

Author SHA1 Message Date
65d27ce4a0 fix: reload posts when the parent widget has rebuilt on the timeline screen
This way if the service passed to us is changed we'll update our posts from it instead
of keeping the ones from the previous service.
2025-04-22 15:07:11 +02:00
3615342c64 fix: don't use TimelineService as state
Otherwise the widget won't use a new service if it has been provided by
the parent. It really isn't state anyway.
2025-04-22 15:06:56 +02:00
13ba6ada07 chore: relax the Firebase versions we support
We don't currently use API that's incompatible with the latest versions
of all Firebase packages, so let's indicate that we support them.
2025-04-22 08:50:54 +02:00
4f0c36a1cc fix: use a minimum of Dart 3.4.3 and don't use 'any' for any dep version constraints
We're very inconsistent with marking what Flutter and Dart versions we support. The .fvmrc indicates
Flutter 3.27.4 which comes with Dart 3.6.2, but the packages said 3.0 and 3.1.
This commit brings all versions in line and sets the minimum to Dart 3.4.3. This version is chosen
so it can be used as is with one of our projects that currently uses that Dart version.
2025-04-22 08:50:54 +02:00
a62935eb60 chore: add fvm configuration to gitignore 2025-02-17 15:52:52 +01:00
mike doornenbal
554c4526da
Merge pull request #99 from Iconica-Development/bugfix/feedback
5.1.0
2024-09-05 11:15:18 +02:00
mike doornenbal
c41f43bb2a fix: first item scrolling under categories 2024-08-23 14:40:08 +02:00
mike doornenbal
09b11dbbc7 fix: update github action 2024-08-22 14:04:25 +02:00
mike doornenbal
f9525c60b5 feat: add symlinks 2024-08-22 13:48:15 +02:00
mike doornenbal
38bb41ce10 fix: feedback 2024-08-22 13:35:55 +02:00
mike doornenbal
02c136d7ea fix: update image_picker dependency 2024-08-07 15:01:12 +02:00
mike doornenbal
7aef9d9617 feat: publish to server 2024-08-01 13:39:38 +02:00
mike doornenbal
8188c179fb fix: postModel not including creator 2024-08-01 13:22:36 +02:00
mike doornenbal
49f0853cca fix: post creation inputfields 2024-08-01 09:57:21 +02:00
mike doornenbal
1dc79b8d74 fix: button text 2024-08-01 09:38:43 +02:00
mike doornenbal
3bd7b0951f fix: remove gorouter 2024-08-01 09:32:09 +02:00
mike doornenbal
a8897242e7 fix: post creation, reaction like 2024-07-31 16:40:12 +02:00
mike doornenbal
eb953ede0d feat: add category, remove post 2024-07-31 14:49:33 +02:00
mike doornenbal
1f629ddf1f fix: small ui fixes 2024-07-31 10:31:56 +02:00
mike doornenbal
c572e6cd8b fix: imagepicker popup 2024-07-30 16:08:49 +02:00
mike doornenbal
a1024dac3d fix: small issues 2024-07-30 15:59:58 +02:00
mike doornenbal
971a030b5c fix: post_overview_screen 2024-07-30 15:08:45 +02:00
mike doornenbal
32fe08a7af fix: post_creation_screen 2024-07-30 14:35:29 +02:00
mike doornenbal
a7fff5ae91 fix: timeline_selection_screen 2024-07-30 13:34:23 +02:00
mike doornenbal
13ae371191 fix: timeline_post_detail_screen 2024-07-30 10:43:40 +02:00
mike doornenbal
38dd43ab39 fix: timeline_screen 2024-07-29 16:39:56 +02:00
mike doornenbal
c99ecffa64
Merge pull request #98 from Iconica-Development/4.1.0
fix: add return for loading indicator in post screen
2024-07-29 14:55:10 +02:00
Freek van de Ven
d089cec5a4 fix: add return for loading indicator in post screen 2024-06-17 22:00:23 +02:00
Freek van de Ven
d77136edd4 feat: add categorySelectionButtonSelectedTextColor and categorySelectionButtonUnselectedTextColor to the theme 2024-06-17 18:33:41 +02:00
Freek van de Ven
567765f856 feat: migrate to flutter 3.22 2024-06-17 16:31:25 +02:00
Gorter-dev
9d476129fd
Merge pull request #54 from Iconica-Development/4.0.0
Improve flutter_timeline for safino usage
2024-05-29 16:04:18 +02:00
Freek van de Ven
71645eee3b fix: remove allowAllDeletion from the options and add it to the userstory 2024-05-29 16:02:42 +02:00
Freek van de Ven
aa22e1305b feat: add a post deletion confirmation dialog 2024-05-24 12:35:15 +02:00
Freek van de Ven
1c46fbea4b fix: incorrect timeline reaction name 2024-05-24 10:09:56 +02:00
Freek van de Ven
31f93704b6 feat: lock CI version to flutter 3.19.6 2024-05-24 08:39:23 +02:00
Freek van de Ven
23449ec57d feat: change CategorySelectorButton to use the category icon and some extra styling 2024-05-23 15:59:18 +02:00
Freek van de Ven
35028b9bb9 fix: initialize post with empty like count so posts liking works correctly 2024-05-23 14:40:15 +02:00
Freek van de Ven
6a522f3209 fix: change liked to the like icon for unliked posts 2024-05-23 13:19:08 +02:00
Freek van de Ven
013e82e61d fix: add post deletion in the timeline userstory 2024-05-23 11:54:43 +02:00
Freek van de Ven
25264ba44b fix: show category title everywhere and use category key for storing posts 2024-05-23 11:12:22 +02:00
Freek van de Ven
767215a53e fix: add the iconbutton for image uploading back to the ReactionBottom 2024-05-22 16:51:24 +02:00
Freek van de Ven
504a7de343 fix: the avatar size to match the new design 2024-05-22 14:58:27 +02:00
Freek van de Ven
5f6bb26404 feat: add TimelinePaddingOptions for all the paddings 2024-05-22 13:58:28 +02:00
Freek van de Ven
ab0f96121e feat: make all translations required for TimelineTranslations and provide an .empty() alternative 2024-05-22 11:43:55 +02:00
Freek van de Ven
ad5390fff8 feat: change blue color to the primary color as default 2024-05-22 11:25:49 +02:00
Freek van de Ven
bca15b6307 feat: use adaptive elements 2024-05-21 14:16:18 +02:00
Freek van de Ven
035f795130 feat: add a refreshindicator to the timeline with an extra callback onRefresh 2024-05-21 08:39:34 +02:00
Freek van de Ven
7fa33cdfb4 feat: add a post and category to the postViewOpenPageBuilder 2024-05-21 07:23:49 +02:00
Freek van de Ven
24c779d43b feat: add option to set postCreationFloatingActionButtonColor to override floatingbuttoncolor 2024-05-17 14:57:01 +02:00
Freek van de Ven
2c8d523f32 fix: set the optional maxLength on the default inputfield for post titles 2024-05-17 14:38:13 +02:00
Freek van de Ven
8d13e4af27 fix: filter out timeline categories with key null when creating a new one 2024-05-10 10:07:15 +02:00
Freek van de Ven
13960c4f1c fix: add nullcheck for timeline overview screen 2024-05-10 09:40:07 +02:00
Freek van de Ven
deaca5b126 feat: add getter for the userId in the timeline userstory configuration 2024-05-10 09:20:43 +02:00
Freek van de Ven
933386623a feat: add listHeaderBuilder 2024-05-09 22:36:53 +02:00
Freek van de Ven
4f7fa834e4 feat: add serviceBuilder for userstory configuration to fetch the service when needed 2024-05-09 21:45:24 +02:00
Gorter-dev
93a74fb904
Merge pull request #45 from Iconica-Development/3.0.1
fix: postOverviewScreen not displaying creators name
2024-04-30 12:50:31 +02:00
mike doornenbal
af1f7c2d78 fix: postOverviewScreen not displaying creators name 2024-04-29 14:35:15 +02:00
Gorter-dev
423f4ce03a
Merge pull request #42 from Iconica-Development/3.0.0
feat: default styling and flow
2024-04-25 15:03:51 +02:00
mike doornenbal
e695b7020b Merge branch '3.0.0' of https://github.com/Iconica-Development/flutter_timeline into 3.0.0 2024-04-25 14:57:21 +02:00
mike doornenbal
024f267ae5 feat: default styling and flow 2024-04-25 14:51:12 +02:00
mike doornenbal
09b66e9921 feat: default styling and flow 2024-04-25 14:47:16 +02:00
Gorter-dev
d4f7ec3768
Merge pull request #40 from Iconica-Development/2.3.1
fix: feedback
2024-04-23 14:14:06 +02:00
mike doornenbal
a964830cc1 fix: feedback 2024-04-23 14:06:21 +02:00
60 changed files with 2590 additions and 1593 deletions

3
.fvmrc Normal file
View file

@ -0,0 +1,3 @@
{
"flutter": "3.22.2"
}

5
.gitignore vendored
View file

@ -50,4 +50,7 @@ pubspec_overrides.yaml
**/example/macos
**/example/windows
**/example/web
**/example/README.md
**/example/README.md
# FVM Version Cache
.fvm/

View file

@ -1,3 +1,55 @@
## 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

View file

@ -1,6 +1,6 @@
# Flutter Timeline
Flutter Timeline is a package which shows a list posts by a user. This package also has additional features like liking a post and leaving comments. Default this package adds support for a Firebase back-end. You can add your custom back-end (like a Websocket-API) by extending the `CommunityChatInterface` interface from the `flutter_community_chat_interface` package.
Flutter Timeline 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.
![Flutter Timeline GIF](example.gif)
@ -8,10 +8,10 @@ Flutter Timeline is a package which shows a list posts by a user. This package a
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:
@ -19,52 +19,23 @@ 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 with predefined `Go_router` routes you should add the following:
Add go_router as dependency to your project.
Add the following configuration to your flutter_application:
```
List<GoRoute> getTimelineStoryRoutes() => getTimelineStoryRoutes(
TimelineUserStoryConfiguration(
service: FirebaseTimelineService(),
userService: FirebaseUserService(),
userId: currentUserId,
optionsBuilder: (context) => FirebaseOptions(),
),
);
```
Add the `getTimelineStoryRoutes()` to your go_router routes like so:
```
final GoRouter _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (BuildContext context, GoRouterState state) {
return const MyHomePage(
title: "home",
);
},
),
...getTimelineStoryRoutes(timelineUserStoryConfiguration)
],
);
```
The user story can also be used without go router:
Add the following code somewhere in your widget tree:
To use the userstory add the following code somewhere in your widget tree:
````
timeLineNavigatorUserStory(TimelineUserStoryConfiguration, context),
@ -171,7 +142,7 @@ The `TimelineOptions` has its own parameters, as specified below:
| categoriesOptions | Options for using the category selector to provide posts of a certain category. |
The `ImagePickerTheme` ans `imagePickerConfig` also have their own parameters, how to use these parameters can be found in [the documentation of the flutter_image_picker package](https://github.com/Iconica-Development/flutter_image_picker).
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

View file

@ -0,0 +1 @@
../../CHANGELOG.md

View file

@ -0,0 +1 @@
../../LICENSE

View file

@ -0,0 +1 @@
../../README.md

View file

@ -1,41 +0,0 @@
import 'package:example/config/config.dart';
import 'package:flutter/material.dart';
import 'package:flutter_timeline/flutter_timeline.dart';
import 'package:go_router/go_router.dart';
List<GoRoute> getTimelineRoutes() => getTimelineStoryRoutes(
configuration: getConfig(TimelineService(
postService: LocalTimelinePostService(),
)),
);
final _router = GoRouter(
initialLocation: '/timeline',
routes: [
...getTimelineRoutes(),
],
);
class GoRouterApp extends StatelessWidget {
const GoRouterApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: _router,
title: 'Flutter Timeline',
theme: ThemeData(
textTheme: const TextTheme(
titleLarge: TextStyle(
color: Color(0xffb71c6d), fontFamily: 'Playfair Display')),
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFFB8E2E8),
primary: const Color(0xffb71c6d),
).copyWith(
background: const Color(0XFFFAF9F6),
),
useMaterial3: true,
),
);
}
}

View file

@ -12,7 +12,7 @@ class NavigatorApp extends StatelessWidget {
theme: ThemeData(
colorScheme:
ColorScheme.fromSeed(seedColor: Colors.deepPurple).copyWith(
background: const Color(0xFFB8E2E8),
surface: const Color(0xFFB8E2E8),
),
useMaterial3: true,
),

View file

@ -13,7 +13,7 @@ class WidgetApp extends StatelessWidget {
theme: ThemeData(
colorScheme:
ColorScheme.fromSeed(seedColor: Colors.deepPurple).copyWith(
background: const Color(0xFFB8E2E8),
surface: const Color(0xFFB8E2E8),
),
useMaterial3: true,
),

View file

@ -7,31 +7,14 @@ TimelineUserStoryConfiguration getConfig(TimelineService service) {
userId: 'test_user',
optionsBuilder: (context) => options,
enablePostOverviewScreen: false,
canDeleteAllPosts: (_) => true,
);
}
var options = TimelineOptions(
textInputBuilder: null,
padding: const EdgeInsets.all(20).copyWith(top: 28),
allowAllDeletion: true,
categoriesOptions: CategoriesOptions(
categoriesBuilder: (context) => [
const TimelineCategory(
key: null,
title: 'All',
icon: SizedBox.shrink(),
),
const TimelineCategory(
key: 'category1',
title: 'Category 1',
icon: SizedBox.shrink(),
),
const TimelineCategory(
key: 'category2',
title: 'Category 2',
icon: SizedBox.shrink(),
),
],
paddings: TimelinePaddingOptions(
mainPadding: const EdgeInsets.all(20).copyWith(top: 28),
),
);

View file

@ -1,15 +1,9 @@
// import 'package:example/apps/go_router/app.dart';
// import 'package:example/apps/navigator/app.dart';
import 'package:example/apps/go_router/app.dart';
import 'package:example/apps/navigator/app.dart';
import 'package:flutter/material.dart';
import 'package:intl/date_symbol_data_local.dart';
void main() {
initializeDateFormatting();
// Uncomment any, but only one, of these lines to run the example with specific navigation.
// runApp(const WidgetApp());
// runApp(const NavigatorApp());
runApp(const GoRouterApp());
runApp(const NavigatorApp());
}

View file

@ -38,7 +38,6 @@ dependencies:
flutter_timeline:
path: ../
intl: ^0.19.0
go_router: ^13.0.1
dev_dependencies:
flutter_test:

View file

@ -5,7 +5,6 @@
/// Flutter Timeline library
library flutter_timeline;
export 'package:flutter_timeline/src/flutter_timeline_gorouter_userstory.dart';
export 'package:flutter_timeline/src/flutter_timeline_navigator_userstory.dart';
export 'package:flutter_timeline/src/models/timeline_configuration.dart';
export 'package:flutter_timeline/src/routes.dart';

View file

@ -1,195 +0,0 @@
// SPDX-FileCopyrightText: 2023 Iconica
//
// SPDX-License-Identifier: BSD-3-Clause
import 'package:flutter/material.dart';
import 'package:flutter_timeline/flutter_timeline.dart';
import 'package:flutter_timeline/src/go_router.dart';
import 'package:go_router/go_router.dart';
/// Retrieves a list of GoRouter routes for timeline stories.
///
/// This function retrieves a list of GoRouter routes for displaying timeline
/// stories. It takes an optional [TimelineUserStoryConfiguration] as parameter.
/// If no configuration is provided, default values will be used.
List<GoRoute> getTimelineStoryRoutes({
TimelineUserStoryConfiguration? configuration,
}) {
var config = configuration ??
TimelineUserStoryConfiguration(
userId: 'test_user',
service: TimelineService(
postService: LocalTimelinePostService(),
),
optionsBuilder: (context) => const TimelineOptions(),
);
return <GoRoute>[
GoRoute(
path: TimelineUserStoryRoutes.timelineHome,
pageBuilder: (context, state) {
var timelineScreen = TimelineScreen(
userId: config.userId,
onUserTap: (user) => config.onUserTap?.call(context, user),
service: config.service,
options: config.optionsBuilder(context),
onPostTap: (post) async =>
config.onPostTap?.call(context, post) ??
await context.push(
TimelineUserStoryRoutes.timelineViewPath(post.id),
),
filterEnabled: config.filterEnabled,
postWidgetBuilder: config.postWidgetBuilder,
);
var button = FloatingActionButton(
backgroundColor: Theme.of(context).primaryColor,
onPressed: () async => context.go(
TimelineUserStoryRoutes.timelinePostCreation,
),
shape: const CircleBorder(),
child: const Icon(
Icons.add,
color: Colors.white,
size: 30,
),
);
return buildScreenWithoutTransition(
context: context,
state: state,
child: config.homeOpenPageBuilder
?.call(context, timelineScreen, button) ??
Scaffold(
appBar: AppBar(
backgroundColor: Colors.black,
title: Text(
'Iconinstagram',
style: Theme.of(context).textTheme.titleLarge,
),
),
body: timelineScreen,
floatingActionButton: button,
),
);
},
),
GoRoute(
path: TimelineUserStoryRoutes.timelineView,
pageBuilder: (context, state) {
var post =
config.service.postService.getPost(state.pathParameters['post']!);
var timelinePostWidget = TimelinePostScreen(
userId: config.userId,
options: config.optionsBuilder(context),
service: config.service,
post: post!,
onPostDelete: () => config.onPostDelete?.call(context, post),
onUserTap: (user) => config.onUserTap?.call(context, user),
);
var backButton = IconButton(
color: Colors.white,
icon: const Icon(Icons.arrow_back_ios),
onPressed: () => context.go(TimelineUserStoryRoutes.timelineHome),
);
return buildScreenWithoutTransition(
context: context,
state: state,
child: config.postViewOpenPageBuilder
?.call(context, timelinePostWidget, backButton) ??
Scaffold(
appBar: AppBar(
leading: backButton,
backgroundColor: Colors.black,
title: Text(
'Category',
style: Theme.of(context).textTheme.titleLarge,
),
),
body: timelinePostWidget,
),
);
},
),
GoRoute(
path: TimelineUserStoryRoutes.timelinePostCreation,
pageBuilder: (context, state) {
var timelinePostCreationWidget = TimelinePostCreationScreen(
userId: config.userId,
options: config.optionsBuilder(context),
service: config.service,
onPostCreated: (post) async {
var newPost = await config.service.postService.createPost(post);
if (context.mounted) {
if (config.afterPostCreationGoHome) {
context.go(TimelineUserStoryRoutes.timelineHome);
} else {
await context
.push(TimelineUserStoryRoutes.timelineViewPath(newPost.id));
}
}
},
onPostOverview: (post) async => context.push(
TimelineUserStoryRoutes.timelinePostOverview,
extra: post,
),
enablePostOverviewScreen: config.enablePostOverviewScreen,
);
var backButton = IconButton(
icon: const Icon(Icons.arrow_back_ios),
onPressed: () => context.go(TimelineUserStoryRoutes.timelineHome),
);
return buildScreenWithoutTransition(
context: context,
state: state,
child: config.postCreationOpenPageBuilder
?.call(context, timelinePostCreationWidget, backButton) ??
Scaffold(
appBar: AppBar(
backgroundColor: Colors.black,
title: Text(
config.optionsBuilder(context).translations.postCreation,
style: Theme.of(context).textTheme.titleLarge,
),
leading: backButton,
),
body: timelinePostCreationWidget,
),
);
},
),
GoRoute(
path: TimelineUserStoryRoutes.timelinePostOverview,
pageBuilder: (context, state) {
var post = state.extra! as TimelinePost;
var timelinePostOverviewWidget = TimelinePostOverviewScreen(
options: config.optionsBuilder(context),
service: config.service,
timelinePost: post,
onPostSubmit: (post) async {
await config.service.postService.createPost(post);
if (context.mounted) {
context.go(TimelineUserStoryRoutes.timelineHome);
}
},
);
return buildScreenWithoutTransition(
context: context,
state: state,
child: config.postOverviewOpenPageBuilder?.call(
context,
timelinePostOverviewWidget,
) ??
timelinePostOverviewWidget,
);
},
),
];
}

View file

@ -10,11 +10,13 @@ import 'package:flutter_timeline/flutter_timeline.dart';
/// This function creates a navigator for displaying user stories on a timeline.
/// It takes a [BuildContext] and an optional [TimelineUserStoryConfiguration]
/// as parameters. If no configuration is provided, default values will be used.
late TimelineUserStoryConfiguration timelineUserStoryConfiguration;
Widget timeLineNavigatorUserStory({
required BuildContext context,
TimelineUserStoryConfiguration? configuration,
}) {
var config = configuration ??
timelineUserStoryConfiguration = configuration ??
TimelineUserStoryConfiguration(
userId: 'test_user',
service: TimelineService(
@ -23,7 +25,10 @@ Widget timeLineNavigatorUserStory({
optionsBuilder: (context) => const TimelineOptions(),
);
return _timelineScreenRoute(configuration: config, context: context);
return _timelineScreenRoute(
config: timelineUserStoryConfiguration,
context: context,
);
}
/// A widget function that creates a timeline screen route.
@ -33,52 +38,80 @@ Widget timeLineNavigatorUserStory({
/// parameters. If no configuration is provided, default values will be used.
Widget _timelineScreenRoute({
required BuildContext context,
TimelineUserStoryConfiguration? configuration,
required TimelineUserStoryConfiguration config,
String? initalCategory,
}) {
var config = configuration ??
TimelineUserStoryConfiguration(
userId: 'test_user',
service: TimelineService(
postService: LocalTimelinePostService(),
),
optionsBuilder: (context) => const TimelineOptions(),
);
return Scaffold(
appBar: AppBar(),
floatingActionButton: FloatingActionButton(
onPressed: () async => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => _postCreationScreenRoute(
configuration: config,
context: context,
),
),
),
child: const Icon(Icons.add),
),
body: TimelineScreen(
service: config.service,
options: config.optionsBuilder(context),
userId: config.userId,
onPostTap: (post) async =>
config.onPostTap?.call(context, post) ??
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => _postDetailScreenRoute(
configuration: config,
context: context,
post: post,
),
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,
),
),
onUserTap: (userId) {
config.onUserTap?.call(context, userId);
},
filterEnabled: config.filterEnabled,
postWidgetBuilder: config.postWidgetBuilder,
),
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.
@ -90,27 +123,50 @@ Widget _timelineScreenRoute({
Widget _postDetailScreenRoute({
required BuildContext context,
required TimelinePost post,
TimelineUserStoryConfiguration? configuration,
required TimelineUserStoryConfiguration config,
}) {
var config = configuration ??
TimelineUserStoryConfiguration(
userId: 'test_user',
service: TimelineService(
postService: LocalTimelinePostService(),
),
optionsBuilder: (context) => const TimelineOptions(),
);
return TimelinePostScreen(
userId: config.userId,
service: config.service,
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) ??
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.
@ -120,69 +176,79 @@ Widget _postDetailScreenRoute({
/// as parameters. If no configuration is provided, default values will be used.
Widget _postCreationScreenRoute({
required BuildContext context,
TimelineUserStoryConfiguration? configuration,
required TimelineCategory category,
required TimelineUserStoryConfiguration config,
}) {
var config = configuration ??
TimelineUserStoryConfiguration(
userId: 'test_user',
service: TimelineService(
postService: LocalTimelinePostService(),
),
optionsBuilder: (context) => const TimelineOptions(),
);
var timelinePostCreationScreen = TimelinePostCreationScreen(
userId: config.getUserId?.call(context) ?? config.userId,
options: config.optionsBuilder(context),
service: config.service,
onPostCreated: (post) async {
var newPost = await config.service.postService.createPost(post);
return Scaffold(
appBar: AppBar(
title: Text(
style: Theme.of(context).textTheme.titleLarge,
config.optionsBuilder(context).translations.postCreation,
),
),
body: TimelinePostCreationScreen(
userId: config.userId,
service: config.service,
options: config.optionsBuilder(context),
onPostCreated: (post) async {
await config.service.postService.createPost(post);
if (context.mounted) {
if (config.afterPostCreationGoHome) {
await Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => _timelineScreenRoute(
configuration: config,
context: context,
),
),
);
} else {
await Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => _postDetailScreenRoute(
configuration: config,
context: context,
post: post,
),
),
);
}
}
},
onPostOverview: (post) async {
await Navigator.of(context).push(
if (!context.mounted) return;
if (config.afterPostCreationGoHome) {
await Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => _postOverviewScreenRoute(
configuration: config,
builder: (context) => _timelineScreenRoute(
config: config,
context: context,
post: post,
initalCategory: category.title,
),
),
);
},
enablePostOverviewScreen: config.enablePostOverviewScreen,
} 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.
@ -194,32 +260,115 @@ Widget _postCreationScreenRoute({
Widget _postOverviewScreenRoute({
required BuildContext context,
required TimelinePost post,
TimelineUserStoryConfiguration? configuration,
required TimelineUserStoryConfiguration config,
}) {
var config = configuration ??
TimelineUserStoryConfiguration(
userId: 'test_user',
service: TimelineService(
postService: LocalTimelinePostService(),
),
optionsBuilder: (context) => const TimelineOptions(),
);
return TimelinePostOverviewScreen(
timelinePost: post,
var timelinePostOverviewWidget = TimelinePostOverviewScreen(
options: config.optionsBuilder(context),
service: config.service,
timelinePost: post,
onPostSubmit: (post) async {
await config.service.postService.createPost(post);
var createdPost = await config.service.postService.createPost(post);
config.onPostCreate?.call(createdPost);
if (context.mounted) {
await Navigator.pushReplacement(
context,
await Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (context) =>
_timelineScreenRoute(configuration: config, context: context),
builder: (context) => _timelineScreenRoute(
config: config,
context: context,
initalCategory: post.category,
),
),
(route) => false,
);
}
},
);
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,
),
),
);
}

View file

@ -1,30 +0,0 @@
// SPDX-FileCopyrightText: 2023 Iconica
//
// SPDX-License-Identifier: BSD-3-Clause
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
CustomTransitionPage buildScreenWithFadeTransition<T>({
required BuildContext context,
required GoRouterState state,
required Widget child,
}) =>
CustomTransitionPage<T>(
key: state.pageKey,
child: child,
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
);
CustomTransitionPage buildScreenWithoutTransition<T>({
required BuildContext context,
required GoRouterState state,
required Widget child,
}) =>
CustomTransitionPage<T>(
key: state.pageKey,
child: child,
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
child,
);

View file

@ -48,6 +48,9 @@ class TimelineUserStoryConfiguration {
const TimelineUserStoryConfiguration({
required this.service,
required this.optionsBuilder,
this.getUserId,
this.serviceBuilder,
this.canDeleteAllPosts,
this.userId = 'test_user',
this.homeOpenPageBuilder,
this.postCreationOpenPageBuilder,
@ -55,19 +58,32 @@ class TimelineUserStoryConfiguration {
this.postOverviewOpenPageBuilder,
this.onPostTap,
this.onUserTap,
this.onRefresh,
this.onPostDelete,
this.filterEnabled = false,
this.postWidgetBuilder,
this.afterPostCreationGoHome = false,
this.enablePostOverviewScreen = 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;
@ -99,6 +115,8 @@ class TimelineUserStoryConfiguration {
BuildContext context,
Widget child,
IconButton? button,
TimelinePost post,
TimelineCategory? category,
)? postViewOpenPageBuilder;
/// Open page builder function for the post overview page. This function
@ -116,6 +134,9 @@ class TimelineUserStoryConfiguration {
/// A callback function invoked when the user's profile is tapped.
final Function(BuildContext context, String userId)? onUserTap;
/// A callback function invoked when the timeline is refreshed by pulling down
final Function(BuildContext context, String? category)? onRefresh;
/// A callback function invoked when a post deletion is requested.
final Widget Function(BuildContext context, TimelinePost post)? onPostDelete;
@ -132,4 +153,13 @@ class TimelineUserStoryConfiguration {
/// 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;
}

View file

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

View file

@ -3,32 +3,27 @@
# SPDX-License-Identifier: GPL-3.0-or-later
name: flutter_timeline
description: Visual elements and interface combined into one package
version: 2.3.0
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
go_router: any
flutter_timeline_view:
git:
url: https://github.com/Iconica-Development/flutter_timeline
path: packages/flutter_timeline_view
ref: 2.3.0
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
path: packages/flutter_timeline_interface
ref: 2.3.0
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

View file

@ -0,0 +1 @@
../../CHANGELOG.md

View file

@ -0,0 +1 @@
../../LICENSE

View file

@ -0,0 +1 @@
../../README.md

View file

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

View file

@ -2,9 +2,6 @@
//
// SPDX-License-Identifier: BSD-3-Clause
import 'package:flutter/material.dart';
@immutable
class FirebaseUserDocument {
const FirebaseUserDocument({
this.firstName,

View file

@ -5,6 +5,7 @@
import 'dart:typed_data';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:collection/collection.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_storage/firebase_storage.dart';
import 'package:flutter/material.dart';
@ -38,6 +39,12 @@ class FirebaseTimelinePostService
@override
List<TimelinePost> posts = [];
@override
List<TimelineCategory> categories = [];
@override
TimelineCategory? selectedCategory;
@override
Future<TimelinePost> createPost(TimelinePost post) async {
var postId = const Uuid().v4();
@ -107,7 +114,10 @@ class FirebaseTimelinePostService
updatedReactions.add(reaction.copyWith(creator: user));
}
}
var updatedPost = post.copyWith(reactions: updatedReactions);
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;
@ -115,7 +125,6 @@ class FirebaseTimelinePostService
@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)
@ -236,10 +245,20 @@ class FirebaseTimelinePostService
}
@override
TimelinePost? getPost(String postId) =>
(posts.any((element) => element.id == postId))
? posts.firstWhere((element) => element.id == postId)
: null;
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
@ -251,7 +270,7 @@ class FirebaseTimelinePostService
// update the post with the new like
var updatedPost = post.copyWith(
likes: post.likes + 1,
likedBy: post.likedBy?..add(userId),
likedBy: [...post.likedBy ?? [], userId],
);
posts = posts
.map(
@ -355,4 +374,122 @@ class FirebaseTimelinePostService
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;
}
}

View file

@ -4,33 +4,30 @@
name: flutter_timeline_firebase
description: Implementation of the Flutter Timeline interface for Firebase.
version: 2.3.0
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
path: packages/flutter_timeline_interface
ref: 2.3.0
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:

View file

@ -0,0 +1 @@
../../CHANGELOG.md

View file

@ -0,0 +1 @@
../../LICENSE

View file

@ -0,0 +1 @@
../../README.md

View file

@ -5,13 +5,44 @@ class TimelineCategory {
const TimelineCategory({
required this.key,
required this.title,
required this.icon,
this.icon,
this.canCreate = true,
this.canView = true,
});
TimelineCategory.fromJson(Map<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 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,
};
}

View file

@ -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 = '';

View file

@ -16,6 +16,7 @@ class TimelinePostReaction {
this.imageUrl,
this.creator,
this.createdAtString,
this.likedBy,
});
factory TimelinePostReaction.fromJson(
@ -31,6 +32,7 @@ class TimelinePostReaction {
imageUrl: json['image_url'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
createdAtString: json['created_at'] as String,
likedBy: (json['liked_by'] as List<dynamic>?)?.cast<String>() ?? [],
);
/// The unique identifier of the reaction.
@ -57,6 +59,8 @@ class TimelinePostReaction {
/// Reaction creation date as String with microseconds.
final String? createdAtString;
final List<String>? likedBy;
TimelinePostReaction copyWith({
String? id,
String? postId,
@ -65,6 +69,7 @@ class TimelinePostReaction {
String? reaction,
String? imageUrl,
DateTime? createdAt,
List<String>? likedBy,
}) =>
TimelinePostReaction(
id: id ?? this.id,
@ -74,6 +79,7 @@ class TimelinePostReaction {
reaction: reaction ?? this.reaction,
imageUrl: imageUrl ?? this.imageUrl,
createdAt: createdAt ?? this.createdAt,
likedBy: likedBy ?? this.likedBy,
);
Map<String, dynamic> toJson() => <String, dynamic>{
@ -82,6 +88,7 @@ class TimelinePostReaction {
'reaction': reaction,
'image_url': imageUrl,
'created_at': createdAt.toIso8601String(),
'liked_by': likedBy,
},
};
@ -91,6 +98,7 @@ class TimelinePostReaction {
'reaction': reaction,
'image_url': imageUrl,
'created_at': createdAtString,
'liked_by': likedBy,
},
};
}

View file

@ -5,11 +5,12 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_timeline_interface/src/model/timeline_post.dart';
import 'package:flutter_timeline_interface/src/model/timeline_reaction.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
abstract class TimelinePostService with ChangeNotifier {
List<TimelinePost> posts = [];
List<TimelineCategory> categories = [];
TimelineCategory? selectedCategory;
Future<void> deletePost(TimelinePost post);
Future<TimelinePost> deletePostReaction(TimelinePost post, String reactionId);
@ -17,7 +18,7 @@ abstract class TimelinePostService with ChangeNotifier {
Future<List<TimelinePost>> fetchPosts(String? category);
Future<TimelinePost> fetchPost(TimelinePost post);
Future<List<TimelinePost>> fetchPostsPaginated(String? category, int limit);
TimelinePost? getPost(String postId);
Future<TimelinePost?> getPost(String postId);
List<TimelinePost> getPosts(String? category);
Future<List<TimelinePost>> refreshPosts(String? category);
Future<TimelinePost> fetchPostDetails(TimelinePost post);
@ -28,4 +29,17 @@ abstract class TimelinePostService with ChangeNotifier {
});
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,
);
}

View file

@ -4,19 +4,18 @@
name: flutter_timeline_interface
description: Interface for the service of the Flutter Timeline component
version: 2.3.0
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
dev_dependencies:
flutter_lints: ^2.0.0
flutter_iconica_analysis:
git:
url: https://github.com/Iconica-Development/flutter_iconica_analysis

View file

@ -0,0 +1 @@
../../CHANGELOG.md

View file

@ -0,0 +1 @@
../../LICENSE

View file

@ -0,0 +1 @@
../../README.md

View 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

View 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

View file

@ -5,6 +5,7 @@
library flutter_timeline_view;
export 'src/config/timeline_options.dart';
export 'src/config/timeline_paddings.dart';
export 'src/config/timeline_styles.dart';
export 'src/config/timeline_theme.dart';
export 'src/config/timeline_translations.dart';

View file

@ -5,6 +5,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_image_picker/flutter_image_picker.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
import 'package:flutter_timeline_view/src/config/timeline_paddings.dart';
import 'package:flutter_timeline_view/src/config/timeline_theme.dart';
import 'package:flutter_timeline_view/src/config/timeline_translations.dart';
import 'package:intl/intl.dart';
@ -13,12 +14,12 @@ class TimelineOptions {
const TimelineOptions({
this.theme = const TimelineTheme(),
this.translations = const TimelineTranslations.empty(),
this.paddings = const TimelinePaddingOptions(),
this.imagePickerConfig = const ImagePickerConfig(),
this.imagePickerTheme = const ImagePickerTheme(),
this.imagePickerTheme,
this.timelinePostHeight,
this.allowAllDeletion = false,
this.sortCommentsAscending = true,
this.sortPostsAscending,
this.sortPostsAscending = false,
this.doubleTapTolike = false,
this.iconsWithValues = false,
this.likeAndDislikeIconsForDoubleTap = const (
@ -37,11 +38,8 @@ class TimelineOptions {
this.userAvatarBuilder,
this.anonymousAvatarBuilder,
this.nameBuilder,
this.padding = const EdgeInsets.symmetric(vertical: 12.0),
this.iconSize = 26,
this.iconSize = 24,
this.postWidgetHeight,
this.postPadding =
const EdgeInsets.symmetric(vertical: 12.0, horizontal: 12.0),
this.filterOptions = const FilterOptions(),
this.categoriesOptions = const CategoriesOptions(),
this.requireImageForPost = false,
@ -49,6 +47,12 @@ class TimelineOptions {
this.maxTitleLength,
this.minContentLength,
this.maxContentLength,
this.categorySelectorButtonBuilder,
this.postOverviewButtonBuilder,
this.deletionDialogBuilder,
this.listHeaderBuilder,
this.titleInputDecoration,
this.contentInputDecoration,
});
/// Theming options for the timeline
@ -66,15 +70,15 @@ class TimelineOptions {
/// Whether to sort posts ascending or descending
final bool? sortPostsAscending;
/// Allow all posts to be deleted instead of
/// only the posts of the current user
final bool allowAllDeletion;
/// The height of a post in the timeline
final double? timelinePostHeight;
/// Class that contains all the translations used in the timeline
final TimelineTranslations translations;
/// Class that contains all the paddings used in the timeline
final TimelinePaddingOptions paddings;
final ButtonBuilder? buttonBuilder;
final TextInputBuilder? textInputBuilder;
@ -89,7 +93,7 @@ class TimelineOptions {
/// ImagePickerTheme can be used to change the UI of the
/// Image Picker Widget to change the text/icons to your liking.
final ImagePickerTheme imagePickerTheme;
final ImagePickerTheme? imagePickerTheme;
/// ImagePickerConfig can be used to define the
/// size and quality for the uploaded image.
@ -110,18 +114,12 @@ class TimelineOptions {
/// The builder for the divider
final Widget Function()? dividerBuilder;
/// The padding between posts in the timeline
final EdgeInsets padding;
/// Size of icons like the comment and like icons. Dafualts to 26
final double iconSize;
/// Sets a predefined height for the postWidget.
final double? postWidgetHeight;
/// Padding of each post
final EdgeInsets postPadding;
/// Options for filtering
final FilterOptions filterOptions;
@ -142,43 +140,68 @@ class TimelineOptions {
/// 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.categoriesBuilder,
this.categoryButtonBuilder,
this.categorySelectorHorizontalPadding,
});
/// List of categories that the user can select.
/// If this is null no categories will be shown.
final List<TimelineCategory> Function(BuildContext context)?
categoriesBuilder;
/// Abilty to override the standard category selector
final Widget Function({
required String? categoryKey,
required String categoryName,
required Function onTap,
required bool selected,
})? categoryButtonBuilder;
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,
) {
if (categoriesBuilder == null) {
return null;
}
return categoriesBuilder!
.call(context)
.firstWhereOrNull((category) => category.key == key);
}
) =>
categories.firstWhereOrNull((category) => category.key == key);
}
class FilterOptions {

View file

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

View file

@ -15,6 +15,11 @@ class TimelineTheme {
this.sendIcon,
this.moreIcon,
this.deleteIcon,
this.categorySelectionButtonBorderColor,
this.categorySelectionButtonBackgroundColor,
this.categorySelectionButtonSelectedTextColor,
this.categorySelectionButtonUnselectedTextColor,
this.postCreationFloatingActionButtonColor,
this.textStyles = const TimelineTextStyles(),
});
@ -38,5 +43,23 @@ 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;
}

View file

@ -5,7 +5,14 @@
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,
@ -23,12 +30,16 @@ class TimelineTranslations {
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.likesTitle,
required this.oneLikeTitle,
required this.multipleLikesTitle,
required this.commentsTitle,
required this.firstComment,
required this.writeComment,
required this.postAt,
required this.postLoadingError,
required this.timelineSelectionDescription,
required this.searchHint,
@ -37,40 +48,65 @@ class TimelineTranslations {
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,
});
const TimelineTranslations.empty()
: anonymousUser = 'Anonymous user',
noPosts = 'No posts yet',
noPostsWithFilter = 'No posts with this filter',
title = 'Title',
content = 'Content',
contentDescription = 'What do you want to share?',
uploadImage = 'Upload image',
uploadImageDescription = 'Upload an image to your message (optional)',
allowComments = 'Are people allowed to comment?',
allowCommentsDescription =
'Indicate whether people are allowed to respond',
commentsTitleOnPost = 'Comments',
checkPost = 'Check post overview',
deletePost = 'Delete post',
deleteReaction = 'Delete Reaction',
viewPost = 'View post',
likesTitle = 'Likes',
commentsTitle = 'Are people allowed to comment?',
firstComment = 'Be the first to comment',
writeComment = 'Write your comment here...',
postAt = 'at',
postLoadingError = 'Something went wrong while loading the post',
timelineSelectionDescription = 'Choose a category',
searchHint = 'Search...',
postOverview = 'Post Overview',
postIn = 'Post in',
postCreation = 'Create Post',
titleHintText = 'Title...',
contentHintText = 'Context...',
yes = 'Yes',
no = 'No';
/// 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.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.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.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;
final String noPostsWithFilter;
@ -84,15 +120,22 @@ 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;
@ -107,9 +150,18 @@ class TimelineTranslations {
final String postIn;
final String postCreation;
final String createCategoryPopuptitle;
final String addCategoryTitle;
final String addCategorySubmitButton;
final String addCategoryCancelButtton;
final String addCategoryHintText;
final String addCategoryErrorText;
final String yes;
final String no;
final String timeLineScreenTitle;
/// Method to override the default values of the translations
TimelineTranslations copyWith({
String? noPosts,
String? noPostsWithFilter,
@ -123,11 +175,15 @@ class TimelineTranslations {
String? allowCommentsDescription,
String? commentsTitleOnPost,
String? checkPost,
String? postAt,
String? deletePost,
String? deleteConfirmationTitle,
String? deleteConfirmationMessage,
String? deleteButton,
String? deleteCancelButton,
String? deleteReaction,
String? viewPost,
String? likesTitle,
String? oneLikeTitle,
String? multipleLikesTitle,
String? commentsTitle,
String? writeComment,
String? firstComment,
@ -141,6 +197,15 @@ class TimelineTranslations {
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,
@ -157,11 +222,17 @@ class TimelineTranslations {
allowCommentsDescription ?? this.allowCommentsDescription,
commentsTitleOnPost: commentsTitleOnPost ?? this.commentsTitleOnPost,
checkPost: checkPost ?? this.checkPost,
postAt: postAt ?? this.postAt,
deletePost: deletePost ?? this.deletePost,
deleteConfirmationTitle:
deleteConfirmationTitle ?? this.deleteConfirmationTitle,
deleteConfirmationMessage:
deleteConfirmationMessage ?? this.deleteConfirmationMessage,
deleteButton: deleteButton ?? this.deleteButton,
deleteCancelButton: deleteCancelButton ?? this.deleteCancelButton,
deleteReaction: deleteReaction ?? this.deleteReaction,
viewPost: viewPost ?? this.viewPost,
likesTitle: likesTitle ?? this.likesTitle,
oneLikeTitle: oneLikeTitle ?? this.oneLikeTitle,
multipleLikesTitle: multipleLikesTitle ?? this.multipleLikesTitle,
commentsTitle: commentsTitle ?? this.commentsTitle,
writeComment: writeComment ?? this.writeComment,
firstComment: firstComment ?? this.firstComment,
@ -176,5 +247,17 @@ class TimelineTranslations {
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,
);
}

View file

@ -11,6 +11,8 @@ import 'package:flutter_image_picker/flutter_image_picker.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
import 'package:flutter_timeline_view/flutter_timeline_view.dart';
import 'package:flutter_timeline_view/src/config/timeline_options.dart';
import 'package:flutter_timeline_view/src/widgets/default_filled_button.dart';
import 'package:flutter_timeline_view/src/widgets/post_creation_textfield.dart';
class TimelinePostCreationScreen extends StatefulWidget {
const TimelinePostCreationScreen({
@ -51,52 +53,32 @@ class _TimelinePostCreationScreenState
TextEditingController titleController = TextEditingController();
TextEditingController contentController = TextEditingController();
Uint8List? image;
bool editingDone = false;
bool allowComments = false;
bool titleIsValid = false;
bool contentIsValid = false;
@override
void initState() {
titleController.addListener(_listenForInputs);
contentController.addListener(_listenForInputs);
super.initState();
titleController.addListener(checkIfEditingDone);
contentController.addListener(checkIfEditingDone);
}
@override
void dispose() {
titleController.dispose();
contentController.dispose();
super.dispose();
void _listenForInputs() {
titleIsValid = titleController.text.isNotEmpty;
contentIsValid = contentController.text.isNotEmpty;
setState(() {});
}
void checkIfEditingDone() {
setState(() {
editingDone =
titleController.text.isNotEmpty && contentController.text.isNotEmpty;
if (widget.options.requireImageForPost) {
editingDone = editingDone && image != null;
}
if (widget.options.minTitleLength != null) {
editingDone = editingDone &&
titleController.text.length >= widget.options.minTitleLength!;
}
if (widget.options.maxTitleLength != null) {
editingDone = editingDone &&
titleController.text.length <= widget.options.maxTitleLength!;
}
if (widget.options.minContentLength != null) {
editingDone = editingDone &&
contentController.text.length >= widget.options.minContentLength!;
}
if (widget.options.maxContentLength != null) {
editingDone = editingDone &&
contentController.text.length <= widget.options.maxContentLength!;
}
});
}
var formkey = GlobalKey<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: 'Post${Random().nextInt(1000)}',
creatorId: widget.userId,
@ -104,10 +86,12 @@ class _TimelinePostCreationScreenState
category: widget.postCategory,
content: contentController.text,
likes: 0,
likedBy: const [],
reaction: 0,
createdAt: DateTime.now(),
reactionEnabled: allowComments,
image: image,
creator: user,
);
if (widget.enablePostOverviewScreen) {
@ -118,207 +102,291 @@ class _TimelinePostCreationScreenState
}
var theme = Theme.of(context);
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Padding(
padding: widget.options.padding,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.options.translations.title,
style: theme.textTheme.titleMedium,
),
widget.options.textInputBuilder?.call(
titleController,
null,
'',
) ??
TextField(
controller: titleController,
decoration: InputDecoration(
hintText: widget.options.translations.titleHintText,
),
),
const SizedBox(height: 16),
Text(
widget.options.translations.content,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
widget.options.translations.contentDescription,
style: theme.textTheme.bodyMedium,
),
// input field for the content
SizedBox(
height: 100,
child: TextField(
controller: contentController,
textCapitalization: TextCapitalization.sentences,
expands: true,
maxLines: null,
minLines: null,
decoration: InputDecoration(
hintText: widget.options.translations.contentHintText,
),
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: 16,
),
// input field for the content
Text(
widget.options.translations.uploadImage,
style: theme.textTheme.titleMedium,
),
Text(
widget.options.translations.uploadImageDescription,
style: theme.textTheme.bodyMedium,
),
// image picker field
const SizedBox(
height: 8,
),
Stack(
children: [
GestureDetector(
onTap: () async {
// open a dialog to choose between camera and gallery
var result = await showModalBottomSheet<Uint8List?>(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(8.0),
color: theme.colorScheme.background,
child: ImagePicker(
imagePickerConfig: widget.options.imagePickerConfig,
imagePickerTheme: widget.options.imagePickerTheme,
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;
});
}
checkIfEditingDone();
},
child: ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: image != null
? Image.memory(
image!,
width: double.infinity,
height: 150.0,
fit: BoxFit.cover,
// give it a rounded border
)
: DottedBorder(
dashPattern: const [4, 4],
radius: const Radius.circular(8.0),
color: theme.textTheme.displayMedium?.color ??
Colors.white,
child: const SizedBox(
);
if (result != null) {
setState(() {
image = result;
});
}
},
child: ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: image != null
? Image.memory(
image!,
width: double.infinity,
height: 150.0,
child: Icon(
Icons.image,
size: 50,
fit: BoxFit.cover,
// give it a rounded border
)
: DottedBorder(
dashPattern: const [4, 4],
radius: const Radius.circular(8.0),
color: theme.textTheme.displayMedium?.color ??
Colors.white,
child: const SizedBox(
width: double.infinity,
height: 150.0,
child: Icon(
Icons.image,
size: 50,
),
),
),
),
),
),
),
// if an image is selected, show a delete button
if (image != null) ...[
Positioned(
top: 8,
right: 8,
child: GestureDetector(
onTap: () {
setState(() {
image = null;
});
checkIfEditingDone();
},
child: Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
borderRadius: BorderRadius.circular(8.0),
),
child: const Icon(
Icons.delete,
color: Colors.white,
// if an image is selected, show a delete button
if (image != null) ...[
Positioned(
top: 8,
right: 8,
child: GestureDetector(
onTap: () {
setState(() {
image = null;
});
},
child: Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
borderRadius: BorderRadius.circular(8.0),
),
child: const Icon(
Icons.delete,
color: Colors.white,
),
),
),
),
],
],
),
const SizedBox(height: 16),
Text(
widget.options.translations.commentsTitle,
style: theme.textTheme.titleMedium,
),
Text(
widget.options.translations.allowCommentsDescription,
style: theme.textTheme.bodySmall,
),
const SizedBox(
height: 8,
),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: <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: 16),
Text(
widget.options.translations.commentsTitle,
style: theme.textTheme.titleMedium,
),
Text(
widget.options.translations.allowCommentsDescription,
style: theme.textTheme.bodyMedium,
),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
Checkbox(
value: allowComments,
onChanged: (value) {
setState(() {
allowComments = true;
});
},
),
Text(widget.options.translations.yes),
Checkbox(
value: !allowComments,
onChanged: (value) {
setState(() {
allowComments = false;
});
},
),
Text(widget.options.translations.no),
],
),
Align(
alignment: Alignment.bottomCenter,
child: (widget.options.buttonBuilder != null)
? widget.options.buttonBuilder!(
context,
onPostCreated,
widget.options.translations.checkPost,
enabled: editingDone,
)
: ElevatedButton(
onPressed: editingDone
? () async {
await onPostCreated();
await widget.service.postService
.fetchPosts(null);
}
: null,
child: Text(
widget.enablePostOverviewScreen
? widget.options.translations.checkPost
: widget.options.translations.postCreation,
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,
),
),
],
),
),
),
),
],
),
),
],
),
),
),
),

View file

@ -1,8 +1,7 @@
// ignore_for_file: prefer_expression_function_bodies
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({
@ -19,38 +18,61 @@ class TimelinePostOverviewScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.black,
title: Text(
options.translations.postOverview,
style: TextStyle(color: Theme.of(context).primaryColor),
),
),
body: Column(
children: [
Flexible(
child: TimelinePostScreen(
userId: timelinePost.creatorId,
options: options,
post: timelinePost,
onPostDelete: () async {},
service: service,
),
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,
),
Padding(
padding: const EdgeInsets.only(bottom: 30.0),
child: ElevatedButton(
onPressed: () {
),
options.postOverviewButtonBuilder?.call(
context,
() {
if (isSubmitted) return;
isSubmitted = true;
onPostSubmit(timelinePost);
},
child: Text(
'${options.translations.postIn} ${timelinePost.category}',
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,
),
),
],
),
),
),
),
],
),
],
);
}
}

View file

@ -3,25 +3,26 @@
// SPDX-License-Identifier: BSD-3-Clause
import 'dart:async';
import 'dart:typed_data';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_image_picker/flutter_image_picker.dart';
import 'package:flutter_svg/svg.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
import 'package:flutter_timeline_view/src/config/timeline_options.dart';
import 'package:flutter_timeline_view/src/widgets/reaction_bottom.dart';
import 'package:flutter_timeline_view/src/widgets/tappable_image.dart';
import 'package:flutter_timeline_view/src/widgets/timeline_post_widget.dart';
import 'package:intl/intl.dart';
class TimelinePostScreen extends StatelessWidget {
class TimelinePostScreen extends StatefulWidget {
const TimelinePostScreen({
required this.userId,
required this.service,
required this.options,
required this.post,
required this.onPostDelete,
this.allowAllDeletion = false,
this.isOverviewScreen = false,
this.onUserTap,
super.key,
});
@ -29,6 +30,10 @@ class TimelinePostScreen extends StatelessWidget {
/// The user id of the current user
final String userId;
/// Allow all posts to be deleted instead of
/// only the posts of the current user
final bool allowAllDeletion;
/// The timeline service to fetch the post details
final TimelineService service;
@ -43,63 +48,16 @@ class TimelinePostScreen extends StatelessWidget {
final VoidCallback onPostDelete;
@override
Widget build(BuildContext context) => Scaffold(
body: _TimelinePostScreen(
userId: userId,
service: service,
options: options,
post: post,
onPostDelete: onPostDelete,
onUserTap: onUserTap,
),
);
}
class _TimelinePostScreen extends StatefulWidget {
const _TimelinePostScreen({
required this.userId,
required this.service,
required this.options,
required this.post,
required this.onPostDelete,
this.onUserTap,
});
final String userId;
final TimelineService service;
final TimelineOptions options;
final TimelinePost post;
final Function(String userId)? onUserTap;
final VoidCallback onPostDelete;
final bool? isOverviewScreen;
@override
State<_TimelinePostScreen> createState() => _TimelinePostScreenState();
State<TimelinePostScreen> createState() => _TimelinePostScreenState();
}
class _TimelinePostScreenState extends State<_TimelinePostScreen> {
class _TimelinePostScreenState extends State<TimelinePostScreen> {
TimelinePost? post;
bool isLoading = true;
late var textInputBuilder = widget.options.textInputBuilder ??
(controller, suffixIcon, hintText) => TextField(
textCapitalization: TextCapitalization.sentences,
controller: controller,
decoration: InputDecoration(
hintText: hintText,
suffixIcon: suffixIcon,
border: OutlineInputBorder(
borderRadius:
BorderRadius.circular(20.0), // Adjust the value as needed
),
),
);
@override
void initState() {
super.initState();
@ -116,8 +74,7 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> {
post = loadedPost;
isLoading = false;
});
} on Exception catch (e) {
debugPrint('Error loading post: $e');
} on Exception catch (_) {
setState(() {
isLoading = false;
});
@ -134,12 +91,13 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> {
Widget build(BuildContext context) {
var theme = Theme.of(context);
var dateFormat = widget.options.dateFormat ??
DateFormat('dd/MM/yyyy', Localizations.localeOf(context).languageCode);
var timeFormat = widget.options.timeFormat ?? DateFormat('HH:mm');
DateFormat(
"dd/MM/yyyy 'at' HH:mm",
Localizations.localeOf(context).languageCode,
);
if (isLoading) {
const Center(
child: CircularProgressIndicator(),
return const Center(
child: CircularProgressIndicator.adaptive(),
);
}
if (this.post == null) {
@ -156,10 +114,49 @@ 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.postService.fetchPostDetails(
@ -171,7 +168,7 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> {
},
child: SingleChildScrollView(
child: Padding(
padding: widget.options.padding,
padding: widget.options.paddings.postPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -191,7 +188,7 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> {
28,
) ??
CircleAvatar(
radius: 20,
radius: 14,
backgroundImage:
CachedNetworkImageProvider(
post.creator!.imageUrl!,
@ -203,7 +200,7 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> {
28,
) ??
const CircleAvatar(
radius: 20,
radius: 14,
child: Icon(
Icons.person,
),
@ -217,16 +214,27 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> {
widget.options.translations.anonymousUser,
style: widget.options.theme.textStyles
.postCreatorTitleStyle ??
theme.textTheme.titleMedium,
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) => widget.onPostDelete(),
onSelected: (value) async {
if (value == 'delete') {
await showPostDeletionConfirmationDialog(
widget.options,
context,
widget.onPostDelete,
);
}
},
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
PopupMenuItem<String>(
@ -255,10 +263,11 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> {
color: widget.options.theme.iconColor,
),
),
],
],
),
// image of the post
if (post.imageUrl != null) ...[
// image of the posts
if (post.imageUrl != null || post.image != null) ...[
const SizedBox(height: 8),
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
@ -293,11 +302,17 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> {
false;
},
)
: CachedNetworkImage(
width: double.infinity,
imageUrl: post.imageUrl!,
fit: BoxFit.fitHeight,
),
: 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(
@ -306,66 +321,70 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> {
// post information
Row(
children: [
if (post.likedBy?.contains(widget.userId) ?? false) ...[
InkWell(
onTap: () async {
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () async {
if (widget.isOverviewScreen ?? false) return;
if (isLikedByUser) {
updatePost(
await widget.service.postService.unlikePost(
widget.userId,
post,
),
);
},
child: Container(
color: Colors.transparent,
child: widget.options.theme.likedIcon ??
Icon(
Icons.thumb_up_rounded,
color: widget.options.theme.iconColor,
size: widget.options.iconSize,
),
),
),
] else ...[
InkWell(
onTap: () async {
setState(() {});
} else {
updatePost(
await widget.service.postService.likePost(
widget.userId,
post,
),
);
},
child: Container(
color: Colors.transparent,
child: widget.options.theme.likeIcon ??
setState(() {});
}
},
icon: isLikedByUser
? widget.options.theme.likedIcon ??
Icon(
Icons.thumb_up_alt_outlined,
Icons.favorite_rounded,
color: widget.options.theme.iconColor,
size: widget.options.iconSize,
)
: widget.options.theme.likeIcon ??
Icon(
Icons.favorite_outline_outlined,
color: widget.options.theme.iconColor,
size: widget.options.iconSize,
),
),
),
],
),
const SizedBox(width: 8),
if (post.reactionEnabled)
widget.options.theme.commentIcon ??
Icon(
Icons.chat_bubble_outline_rounded,
SvgPicture.asset(
'assets/Comment.svg',
package: 'flutter_timeline_view',
// ignore: deprecated_member_use
color: widget.options.theme.iconColor,
size: widget.options.iconSize,
width: widget.options.iconSize,
height: widget.options.iconSize,
),
],
),
const SizedBox(height: 8),
Text(
'${post.likes} ${widget.options.translations.likesTitle}',
style: widget
.options.theme.textStyles.postLikeTitleAndAmount ??
theme.textTheme.titleSmall
?.copyWith(color: Colors.black),
),
const SizedBox(height: 4),
// ignore: avoid_bool_literals_in_conditional_expressions
if (widget.isOverviewScreen != null
? !widget.isOverviewScreen!
: false) ...[
Text(
// ignore: lines_longer_than_80_chars
'${post.likes} ${post.likes > 1 ? widget.options.translations.multipleLikesTitle : widget.options.translations.oneLikeTitle}',
style: widget.options.theme.textStyles
.postLikeTitleAndAmount ??
theme.textTheme.titleSmall
?.copyWith(color: Colors.black),
),
],
Text.rich(
TextSpan(
text: widget.options.nameBuilder?.call(post.creator) ??
@ -373,66 +392,46 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> {
widget.options.translations.anonymousUser,
style: widget
.options.theme.textStyles.postCreatorNameStyle ??
theme.textTheme.titleSmall,
theme.textTheme.titleSmall!
.copyWith(color: Colors.black),
children: [
const TextSpan(text: ' '),
TextSpan(
text: post.title,
style:
widget.options.theme.textStyles.postTitleStyle ??
theme.textTheme.bodyMedium,
theme.textTheme.bodySmall,
),
],
),
),
const SizedBox(height: 20),
Html(
data: post.content,
style: {
'body': Style(
padding: HtmlPaddings.zero,
margin: Margins.zero,
),
'#': Style(
maxLines: 3,
textOverflow: TextOverflow.ellipsis,
padding: HtmlPaddings.zero,
margin: Margins.zero,
),
'H1': Style(
padding: HtmlPaddings.zero,
margin: Margins.zero,
),
'H2': Style(
padding: HtmlPaddings.zero,
margin: Margins.zero,
),
'H3': Style(
padding: HtmlPaddings.zero,
margin: Margins.zero,
),
},
),
const SizedBox(height: 4),
Text(
'${dateFormat.format(post.createdAt)} '
'${widget.options.translations.postAt} '
'${timeFormat.format(post.createdAt)}',
post.content,
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 20),
if (post.reactionEnabled) ...[
Text(
'${dateFormat.format(post.createdAt)} ',
style: theme.textTheme.labelSmall?.copyWith(
letterSpacing: 0.5,
),
),
const SizedBox(height: 8),
// ignore: avoid_bool_literals_in_conditional_expressions
if (post.reactionEnabled && widget.isOverviewScreen != null
? !widget.isOverviewScreen!
: false) ...[
Text(
widget.options.translations.commentsTitleOnPost,
style: theme.textTheme.titleMedium,
style: theme.textTheme.titleSmall!
.copyWith(color: Colors.black),
),
for (var reaction
in post.reactions ?? <TimelinePostReaction>[]) ...[
const SizedBox(height: 16),
const SizedBox(height: 4),
GestureDetector(
onLongPressStart: (details) async {
if (reaction.creatorId == widget.userId ||
widget.options.allowAllDeletion) {
widget.allowAllDeletion) {
var overlay = Overlay.of(context)
.context
.findRenderObject()! as RenderBox;
@ -466,18 +465,16 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> {
}
},
child: Row(
crossAxisAlignment: reaction.imageUrl != null
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (reaction.creator?.imageUrl != null &&
reaction.creator!.imageUrl!.isNotEmpty) ...[
widget.options.userAvatarBuilder?.call(
reaction.creator!,
28,
14,
) ??
CircleAvatar(
radius: 20,
radius: 14,
backgroundImage: CachedNetworkImageProvider(
reaction.creator!.imageUrl!,
),
@ -485,10 +482,10 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> {
] else ...[
widget.options.anonymousAvatarBuilder?.call(
reaction.creator!,
28,
14,
) ??
const CircleAvatar(
radius: 20,
radius: 14,
child: Icon(
Icons.person,
),
@ -502,11 +499,12 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> {
children: [
Text(
widget.options.nameBuilder
?.call(post.creator) ??
?.call(reaction.creator) ??
reaction.creator?.fullName ??
widget.options.translations
.anonymousUser,
style: theme.textTheme.titleSmall,
style: theme.textTheme.titleSmall!
.copyWith(color: Colors.black),
),
Padding(
padding: const EdgeInsets.all(8.0),
@ -523,31 +521,95 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> {
child: Text.rich(
TextSpan(
text: widget.options.nameBuilder
?.call(post.creator) ??
?.call(reaction.creator) ??
reaction.creator?.fullName ??
widget
.options.translations.anonymousUser,
style: theme.textTheme.titleSmall,
style: theme.textTheme.titleSmall!
.copyWith(color: Colors.black),
children: [
const TextSpan(text: ' '),
const TextSpan(text: ' '),
TextSpan(
text: reaction.reaction ?? '',
style: theme.textTheme.bodyMedium,
style: theme.textTheme.bodySmall,
),
const TextSpan(text: '\n'),
TextSpan(
text: dateFormat
.format(reaction.createdAt),
style: theme.textTheme.labelSmall!
.copyWith(
color: theme
.textTheme.labelSmall!.color!
.withOpacity(0.5),
letterSpacing: 0.5,
),
),
// text should go to new line
],
),
),
),
],
Builder(
builder: (context) {
var isLikedByUser =
reaction.likedBy?.contains(widget.userId) ??
false;
return IconButton(
padding: const EdgeInsets.only(left: 12),
constraints: const BoxConstraints(),
onPressed: () async {
if (isLikedByUser) {
updatePost(
await widget.service.postService
.unlikeReaction(
widget.userId,
post,
reaction.id,
),
);
setState(() {});
} else {
updatePost(
await widget.service.postService
.likeReaction(
widget.userId,
post,
reaction.id,
),
);
setState(() {});
}
},
icon: isLikedByUser
? widget.options.theme.likedIcon ??
Icon(
Icons.favorite_rounded,
color:
widget.options.theme.iconColor,
size: 14,
)
: widget.options.theme.likeIcon ??
Icon(
Icons.favorite_outline_outlined,
color:
widget.options.theme.iconColor,
size: 14,
),
);
},
),
],
),
),
const SizedBox(height: 4),
],
if (post.reactions?.isEmpty ?? true) ...[
const SizedBox(height: 16),
Text(
widget.options.translations.firstComment,
style: theme.textTheme.bodySmall,
),
],
const SizedBox(height: 120),
@ -557,53 +619,74 @@ class _TimelinePostScreenState extends State<_TimelinePostScreen> {
),
),
),
if (post.reactionEnabled)
if (post.reactionEnabled && !(widget.isOverviewScreen ?? false))
Align(
alignment: Alignment.bottomCenter,
child: ReactionBottom(
messageInputBuilder: textInputBuilder,
onPressSelectImage: () async {
// open the image picker
var result = await showModalBottomSheet<Uint8List?>(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(8.0),
color: theme.colorScheme.background,
child: ImagePicker(
imagePickerConfig: widget.options.imagePickerConfig,
imagePickerTheme: widget.options.imagePickerTheme,
child: Container(
color: theme.scaffoldBackgroundColor,
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width,
),
child: SafeArea(
bottom: true,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 8),
child: post.creator!.imageUrl != null
? widget.options.userAvatarBuilder?.call(
post.creator!,
28,
) ??
CircleAvatar(
radius: 14,
backgroundImage: CachedNetworkImageProvider(
post.creator!.imageUrl!,
),
)
: widget.options.anonymousAvatarBuilder?.call(
post.creator!,
28,
) ??
const CircleAvatar(
radius: 14,
child: Icon(
Icons.person,
),
),
),
),
);
if (result != null) {
updatePost(
await widget.service.postService.reactToPost(
post,
TimelinePostReaction(
id: '',
postId: post.id,
creatorId: widget.userId,
createdAt: DateTime.now(),
Flexible(
child: Padding(
padding: const EdgeInsets.only(
left: 8,
right: 16,
top: 8,
bottom: 8,
),
child: ReactionBottom(
messageInputBuilder: textInputBuilder,
onReactionSubmit: (reaction) async => updatePost(
await widget.service.postService.reactToPost(
post,
TimelinePostReaction(
id: '',
postId: post.id,
reaction: reaction,
creatorId: widget.userId,
createdAt: DateTime.now(),
),
),
),
translations: widget.options.translations,
iconColor: widget.options.theme.iconColor,
),
),
image: result,
),
);
}
},
onReactionSubmit: (reaction) async => updatePost(
await widget.service.postService.reactToPost(
post,
TimelinePostReaction(
id: '',
postId: post.id,
reaction: reaction,
creatorId: widget.userId,
createdAt: DateTime.now(),
),
],
),
),
translations: widget.options.translations,
iconColor: widget.options.theme.iconColor,
),
),
],

View file

@ -9,25 +9,34 @@ import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
import 'package:flutter_timeline_view/flutter_timeline_view.dart';
class TimelineScreen extends StatefulWidget {
const TimelineScreen({
TimelineScreen({
this.userId = 'test_user',
this.service,
TimelineService? service,
this.options = const TimelineOptions(),
this.onPostTap,
this.scrollController,
this.onUserTap,
this.onRefresh,
this.posts,
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;
final TimelineService service;
/// All the configuration options for the timelinescreens and widgets
final TimelineOptions options;
@ -45,6 +54,9 @@ class TimelineScreen extends StatefulWidget {
/// Called when a post is tapped
final Function(TimelinePost)? onPostTap;
/// Called when the timeline is refreshed by pulling down
final Function(BuildContext context, String? category)? onRefresh;
/// If this is not null, the user can tap on the user avatar or name
final Function(String userId)? onUserTap;
@ -63,10 +75,6 @@ class _TimelineScreenState extends State<TimelineScreen> {
late var textFieldController = TextEditingController(
text: widget.options.filterOptions.initialFilterWord,
);
late var service = widget.service ??
TimelineService(
postService: LocalTimelinePostService(),
);
bool isLoading = true;
@ -85,7 +93,7 @@ class _TimelineScreenState extends State<TimelineScreen> {
void _updateIsOnTop() {
setState(() {
_isOnTop = controller.position.pixels < 40;
_isOnTop = controller.position.pixels < 0.1;
});
}
@ -101,22 +109,32 @@ class _TimelineScreenState extends State<TimelineScreen> {
});
}
@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: service.postService,
listenable: widget.service.postService,
builder: (context, _) {
if (!context.mounted) return const SizedBox();
var posts = widget.posts ?? service.postService.getPosts(category);
var posts =
widget.posts ?? widget.service.postService.getPosts(category);
if (widget.filterEnabled && filterWord != null) {
if (service.postService is TimelineFilterService) {
posts = (service.postService as TimelineFilterService)
if (widget.service.postService is TimelineFilterService) {
posts = (widget.service.postService as TimelineFilterService)
.filterPosts(filterWord!, {});
} else {
debugPrint('Timeline service needs to mixin'
@ -139,16 +157,19 @@ class _TimelineScreenState extends State<TimelineScreen> {
);
}
var categories = widget.service.postService.categories;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: widget.options.padding.top,
height: widget.options.paddings.mainPadding.top,
),
if (widget.filterEnabled) ...[
Padding(
padding: EdgeInsets.symmetric(
horizontal: widget.options.padding.horizontal,
padding: EdgeInsets.only(
left: widget.options.paddings.mainPadding.left,
right: widget.options.paddings.mainPadding.right,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
@ -205,11 +226,16 @@ class _TimelineScreenState extends State<TimelineScreen> {
),
],
CategorySelector(
categories: categories,
isOnTop: _isOnTop,
filter: category,
options: widget.options,
onTapCategory: (categoryKey) {
setState(() {
service.postService.selectedCategory =
categories.firstWhereOrNull(
(element) => element.key == categoryKey,
);
category = categoryKey;
});
},
@ -218,74 +244,90 @@ class _TimelineScreenState extends State<TimelineScreen> {
height: 12,
),
Expanded(
child: SingleChildScrollView(
controller: controller,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...posts.map(
(post) => Padding(
padding: widget.options.postPadding,
child: widget.postWidgetBuilder?.call(post) ??
TimelinePostWidget(
service: service,
userId: widget.userId,
options: widget.options,
post: post,
onTap: () async {
if (widget.onPostTap != null) {
widget.onPostTap!.call(post);
child: RefreshIndicator.adaptive(
onRefresh: () async {
await widget.onRefresh?.call(context, category);
await loadPosts();
},
child: SingleChildScrollView(
controller: controller,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
/// Add a optional custom header to the list of posts
widget.options.listHeaderBuilder
?.call(context, category) ??
const SizedBox.shrink(),
...posts.map(
(post) => Padding(
padding: widget.options.paddings.postPadding,
child: widget.postWidgetBuilder?.call(post) ??
TimelinePostWidget(
service: widget.service,
userId: widget.userId,
options: widget.options,
allowAllDeletion: widget.allowAllDeletion,
post: post,
onTap: () async {
if (widget.onPostTap != null) {
widget.onPostTap!.call(post);
return;
}
return;
}
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => Scaffold(
body: TimelinePostScreen(
userId: 'test_user',
service: service,
options: widget.options,
post: post,
onPostDelete: () {
service.postService.deletePost(post);
Navigator.of(context).pop();
},
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => Scaffold(
body: TimelinePostScreen(
userId: 'test_user',
service: widget.service,
options: widget.options,
post: post,
onPostDelete: () {
widget.service.postService
.deletePost(post);
Navigator.of(context).pop();
},
),
),
),
),
);
},
onTapLike: () async => service.postService
.likePost(widget.userId, post),
onTapUnlike: () async => service.postService
.unlikePost(widget.userId, post),
onPostDelete: () async =>
service.postService.deletePost(post),
onUserTap: widget.onUserTap,
),
),
),
if (posts.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
category == null
? widget.options.translations.noPosts
: widget.options.translations.noPostsWithFilter,
style: widget.options.theme.textStyles.noPostsStyle,
),
);
},
onTapLike: () async => 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,
),
],
),
),
),
),
SizedBox(
height: widget.options.padding.bottom,
),
],
);
},
@ -295,7 +337,8 @@ class _TimelineScreenState extends State<TimelineScreen> {
Future<void> loadPosts() async {
if (widget.posts != null || !context.mounted) return;
try {
await service.postService.fetchPosts(category);
await widget.service.postService.fetchCategories();
await widget.service.postService.fetchPosts(category);
setState(() {
isLoading = false;
});

View file

@ -1,12 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
import 'package:flutter_timeline_view/src/config/timeline_options.dart';
import 'package:flutter_timeline_view/src/widgets/default_filled_button.dart';
import 'package:flutter_timeline_view/src/widgets/post_creation_textfield.dart';
class TimelineSelectionScreen extends StatelessWidget {
class TimelineSelectionScreen extends StatefulWidget {
const TimelineSelectionScreen({
required this.options,
required this.categories,
required this.onCategorySelected,
required this.postService,
super.key,
});
@ -16,52 +19,196 @@ class TimelineSelectionScreen extends StatelessWidget {
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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(top: size.height * 0.05, bottom: 8),
child: Text(
options.translations.timelineSelectionDescription,
style:
options.theme.textStyles.categorySelectionDescriptionStyle ??
theme.textTheme.displayMedium,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 20, bottom: 12),
child: Text(
widget.options.translations.timelineSelectionDescription,
style: theme.textTheme.titleLarge,
),
),
),
const SizedBox(height: 4),
for (var category in categories.where(
(element) => element.canCreate,
)) ...[
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: () => onCategorySelected.call(category),
onTap: showCategoryPopup,
child: Container(
height: 60,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: widget
.options.theme.categorySelectionButtonBorderColor ??
const Color(0xFF9E9E9E),
width: 2,
),
color: widget
.options.theme.categorySelectionButtonBackgroundColor,
),
padding: const EdgeInsets.symmetric(
vertical: 26,
horizontal: 16,
),
margin: const EdgeInsets.symmetric(vertical: 8),
child: Text(
category.title,
style: options.theme.textStyles.categorySelectionTitleStyle ??
theme.textTheme.displaySmall,
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),
),
),
),
],
),
),
);
}

View file

@ -13,6 +13,12 @@ class LocalTimelinePostService
@override
List<TimelinePost> posts = [];
@override
List<TimelineCategory> categories = [];
@override
TimelineCategory? selectedCategory;
@override
Future<TimelinePost> createPost(TimelinePost post) async {
posts.add(
@ -21,8 +27,8 @@ class LocalTimelinePostService
userId: 'test_user',
imageUrl:
'https://cdn.britannica.com/68/143568-050-5246474F/Donkey.jpg?w=400&h=300&c=crop',
firstName: 'Dirk',
lastName: 'lukassen',
firstName: 'Ico',
lastName: 'Nica',
),
),
);
@ -86,7 +92,9 @@ class LocalTimelinePostService
@override
Future<List<TimelinePost>> fetchPosts(String? category) async {
posts = getMockedPosts();
if (posts.isEmpty) {
posts = getMockedPosts();
}
notifyListeners();
return posts;
}
@ -116,10 +124,11 @@ class LocalTimelinePostService
}
@override
TimelinePost? getPost(String postId) =>
(posts.any((element) => element.id == postId))
? posts.firstWhere((element) => element.id == postId)
: null;
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
@ -172,8 +181,8 @@ class LocalTimelinePostService
userId: 'test_user',
imageUrl:
'https://cdn.britannica.com/68/143568-050-5246474F/Donkey.jpg?w=400&h=300&c=crop',
firstName: 'Dirk',
lastName: 'lukassen',
firstName: 'Ico',
lastName: 'Nica',
),
);
@ -195,64 +204,129 @@ class LocalTimelinePostService
TimelinePost(
id: 'Post0',
creatorId: 'test_user',
title: 'Post 0',
category: null,
title: 'De topper van de maand september',
category: 'Category',
imageUrl:
'https://t4.ftcdn.net/jpg/02/77/71/45/240_F_277714513_fQ0akmI3TQxa0wkPCLeO12Rx3cL2AuIf.jpg',
content: 'Standard post without image made by the current user',
likes: 0,
'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: false,
reactionEnabled: true,
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',
'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: 'Post 1',
category: null,
content: 'Standard post with image made by a different user and '
'reactions enabled',
likes: 0,
reaction: 0,
createdAt: DateTime.now(),
reactionEnabled: false,
imageUrl:
'https://t4.ftcdn.net/jpg/02/77/71/45/240_F_277714513_fQ0akmI3TQxa0wkPCLeO12Rx3cL2AuIf.jpg',
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',
),
),
TimelinePost(
id: 'Post2',
creatorId: 'test_user',
title: 'Post 2',
category: null,
content: 'Standard post with image made by the current user and'
' reactions enabled',
likes: 0,
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://t4.ftcdn.net/jpg/02/77/71/45/240_F_277714513_fQ0akmI3TQxa0wkPCLeO12Rx3cL2AuIf.jpg',
'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://cdn.britannica.com/68/143568-050-5246474F/Donkey.jpg?w=400&h=300&c=crop',
firstName: 'Dirk',
lastName: 'lukassen',
'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;
}
}

View file

@ -1,6 +1,7 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
import 'package:flutter_timeline_view/flutter_timeline_view.dart';
class CategorySelector extends StatefulWidget {
@ -9,6 +10,7 @@ class CategorySelector extends StatefulWidget {
required this.options,
required this.onTapCategory,
required this.isOnTop,
required this.categories,
super.key,
});
@ -16,6 +18,7 @@ class CategorySelector extends StatefulWidget {
final TimelineOptions options;
final void Function(String? categoryKey) onTapCategory;
final bool isOnTop;
final List<TimelineCategory> categories;
@override
State<CategorySelector> createState() => _CategorySelectorState();
@ -23,47 +26,42 @@ class CategorySelector extends StatefulWidget {
class _CategorySelectorState extends State<CategorySelector> {
@override
Widget build(BuildContext context) {
if (widget.options.categoriesOptions.categoriesBuilder == null) {
return const SizedBox.shrink();
}
var categories =
widget.options.categoriesOptions.categoriesBuilder!(context);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
SizedBox(
width: widget.options.categoriesOptions
.categorySelectorHorizontalPadding ??
max(widget.options.padding.horizontal - 20, 0),
Widget build(BuildContext context) => SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
SizedBox(
width: widget.options.categoriesOptions
.categorySelectorHorizontalPadding ??
max(widget.options.paddings.mainPadding.left - 20, 0),
),
for (var category in widget.categories) ...[
widget.options.categoriesOptions.categoryButtonBuilder?.call(
category,
() => widget.onTapCategory(category.key),
widget.filter == category.key,
widget.isOnTop,
) ??
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: CategorySelectorButton(
isOnTop: widget.isOnTop,
category: category,
selected: widget.filter == category.key,
onTap: () => widget.onTapCategory(category.key),
options: widget.options,
),
),
],
SizedBox(
width: widget.options.categoriesOptions
.categorySelectorHorizontalPadding ??
max(widget.options.paddings.mainPadding.right - 4, 0),
),
],
),
for (var category in categories) ...[
widget.options.categoriesOptions.categoryButtonBuilder?.call(
categoryKey: category.key,
categoryName: category.title,
onTap: () => widget.onTapCategory(category.key),
selected: widget.filter == category.key,
) ??
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.padding.horizontal - 4, 0),
),
],
),
);
}
),
);
}

View file

@ -14,65 +14,130 @@ class CategorySelectorButton extends StatelessWidget {
final TimelineCategory category;
final bool selected;
final void Function() onTap;
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(
height: isOnTop ? 140 : 40,
duration: const Duration(milliseconds: 100),
height: isOnTop ? 140 : 40,
child: TextButton(
onPressed: onTap,
style: ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const MaterialStatePropertyAll(
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(
vertical: 5,
horizontal: 12,
),
),
fixedSize: MaterialStatePropertyAll(Size(140, isOnTop ? 140 : 20)),
backgroundColor: MaterialStatePropertyAll(
selected ? theme.colorScheme.primary : Colors.transparent,
fixedSize: WidgetStatePropertyAll(Size(140, isOnTop ? 140 : 20)),
backgroundColor: WidgetStatePropertyAll(
selected
? theme.colorScheme.primary
: options.theme.categorySelectionButtonBackgroundColor ??
Colors.transparent,
),
shape: MaterialStatePropertyAll(
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
side: BorderSide(
color: theme.colorScheme.primary,
color: options.theme.categorySelectionButtonBorderColor ??
theme.colorScheme.primary,
width: 2,
),
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Column(
mainAxisAlignment:
isOnTop ? MainAxisAlignment.end : MainAxisAlignment.center,
children: [
Text(
category.title,
style: (options.theme.textStyles.categoryTitleStyle ??
theme.textTheme.labelLarge)
?.copyWith(
color: selected
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface,
),
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,
);
}

View file

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

View file

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

View file

@ -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;
@ -30,44 +29,30 @@ class _ReactionBottomState extends State<ReactionBottom> {
final TextEditingController _textEditingController = TextEditingController();
@override
Widget build(BuildContext context) => SafeArea(
bottom: true,
child: Container(
color: Theme.of(context).colorScheme.background,
child: Container(
margin: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
Widget build(BuildContext context) => Container(
child: widget.messageInputBuilder(
_textEditingController,
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
height: 48,
child: widget.messageInputBuilder(
_textEditingController,
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () async {
var value = _textEditingController.text;
if (value.isNotEmpty) {
await widget.onReactionSubmit(value);
_textEditingController.clear();
}
},
icon: Icon(
Icons.send,
color: widget.iconColor,
),
),
],
),
child: IconButton(
onPressed: () async {
var value = _textEditingController.text;
if (value.isNotEmpty) {
await widget.onReactionSubmit(value);
_textEditingController.clear();
}
},
icon: SvgPicture.asset(
'assets/send.svg',
package: 'flutter_timeline_view',
// ignore: deprecated_member_use
color: widget.iconColor,
),
widget.translations.writeComment,
),
),
widget.translations.writeComment,
),
);
}

View file

@ -99,11 +99,17 @@ class _TappableImageState extends State<TappableImage>
offset: Offset(0, animation.value * -32),
child: Transform.scale(
scale: 1 + animation.value * 0.1,
child: CachedNetworkImage(
imageUrl: widget.post.imageUrl ?? '',
width: double.infinity,
fit: BoxFit.fitHeight,
),
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,
),
),
),
);
@ -135,6 +141,7 @@ class _HeartAnimationState extends State<HeartAnimation> {
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();

View file

@ -4,8 +4,10 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
import 'package:flutter_timeline_view/src/config/timeline_options.dart';
import 'package:flutter_timeline_view/src/widgets/default_filled_button.dart';
import 'package:flutter_timeline_view/src/widgets/tappable_image.dart';
class TimelinePostWidget extends StatefulWidget {
@ -18,12 +20,18 @@ class TimelinePostWidget extends StatefulWidget {
required this.onTapUnlike,
required this.onPostDelete,
required this.service,
required this.allowAllDeletion,
this.onUserTap,
super.key,
});
/// The user id of the current user
final String userId;
/// Allow all posts to be deleted instead of
/// only the posts of the current user
final bool allowAllDeletion;
final TimelineOptions options;
final TimelinePost post;
@ -46,282 +54,391 @@ class _TimelinePostWidgetState extends State<TimelinePostWidget> {
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return InkWell(
onTap: widget.onTap,
child: SizedBox(
height: widget.post.imageUrl != 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: 20,
backgroundImage: CachedNetworkImageProvider(
widget.post.creator!.imageUrl!,
),
var isLikedByUser = widget.post.likedBy?.contains(widget.userId) ?? false;
return SizedBox(
height: widget.post.imageUrl != null || widget.post.image != null
? widget.options.postWidgetHeight
: null,
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (widget.post.creator != null) ...[
InkWell(
onTap: widget.onUserTap != null
? () =>
widget.onUserTap?.call(widget.post.creator!.userId)
: null,
child: Row(
children: [
if (widget.post.creator!.imageUrl != null) ...[
widget.options.userAvatarBuilder?.call(
widget.post.creator!,
28,
) ??
CircleAvatar(
radius: 14,
backgroundImage: CachedNetworkImageProvider(
widget.post.creator!.imageUrl!,
),
] else ...[
widget.options.anonymousAvatarBuilder?.call(
widget.post.creator!,
40,
) ??
const CircleAvatar(
radius: 20,
child: Icon(
Icons.person,
),
),
],
const SizedBox(width: 10),
Text(
widget.options.nameBuilder
?.call(widget.post.creator) ??
widget.post.creator?.fullName ??
widget.options.translations.anonymousUser,
style: widget.options.theme.textStyles
.postCreatorTitleStyle ??
theme.textTheme.titleMedium,
),
],
),
),
const Spacer(),
if (widget.options.allowAllDeletion ||
widget.post.creator?.userId == widget.userId)
PopupMenuButton(
onSelected: (value) {
if (value == 'delete') {
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,
),
],
),
] else ...[
widget.options.anonymousAvatarBuilder?.call(
widget.post.creator!,
28,
) ??
const CircleAvatar(
radius: 14,
child: Icon(
Icons.person,
),
),
],
const SizedBox(width: 10),
Text(
widget.options.nameBuilder?.call(widget.post.creator) ??
widget.post.creator?.fullName ??
widget.options.translations.anonymousUser,
style: widget.options.theme.textStyles
.postCreatorTitleStyle ??
theme.textTheme.titleSmall!.copyWith(
color: Colors.black,
),
),
],
child: widget.options.theme.moreIcon ??
Icon(
Icons.more_horiz_rounded,
),
),
],
const Spacer(),
if (widget.allowAllDeletion ||
widget.post.creator?.userId == widget.userId) ...[
PopupMenuButton(
onSelected: (value) async {
if (value == 'delete') {
await showPostDeletionConfirmationDialog(
widget.options,
context,
widget.onPostDelete,
);
}
},
itemBuilder: (BuildContext context) =>
<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,
),
],
),
),
],
child: widget.options.theme.moreIcon ??
Icon(
Icons.more_horiz_rounded,
color: widget.options.theme.iconColor,
),
),
],
],
),
// image of the post
if (widget.post.imageUrl != null || widget.post.image != null) ...[
const SizedBox(height: 8),
Flexible(
flex: widget.options.postWidgetHeight != null ? 1 : 0,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: widget.options.doubleTapTolike
? TappableImage(
likeAndDislikeIcon:
widget.options.likeAndDislikeIconsForDoubleTap,
post: widget.post,
userId: widget.userId,
onLike: ({required bool liked}) async {
var userId = widget.userId;
late TimelinePost result;
if (!liked) {
result = await widget.service.postService.likePost(
userId,
widget.post,
);
} else {
result =
await widget.service.postService.unlikePost(
userId,
widget.post,
);
}
return result.likedBy?.contains(userId) ?? false;
},
)
: widget.post.imageUrl != null
? CachedNetworkImage(
width: double.infinity,
imageUrl: widget.post.imageUrl!,
fit: BoxFit.fitWidth,
)
: Image.memory(
width: double.infinity,
widget.post.image!,
fit: BoxFit.fitWidth,
),
),
),
],
const SizedBox(
height: 8,
),
// post information
if (widget.options.iconsWithValues) ...[
Row(
children: [
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () async {
var userId = widget.userId;
if (!isLikedByUser) {
await widget.service.postService.likePost(
userId,
widget.post,
);
} else {
await widget.service.postService.unlikePost(
userId,
widget.post,
);
}
},
icon: widget.options.theme.likeIcon ??
Icon(
isLikedByUser
? Icons.favorite_rounded
: Icons.favorite_outline_outlined,
color: widget.options.theme.iconColor,
size: widget.options.iconSize,
),
),
const SizedBox(
width: 4,
),
Text('${widget.post.likes}'),
if (widget.post.reactionEnabled) ...[
const SizedBox(
width: 8,
),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: widget.onTap,
icon: widget.options.theme.commentIcon ??
SvgPicture.asset(
'assets/Comment.svg',
package: 'flutter_timeline_view',
// ignore: deprecated_member_use
color: widget.options.theme.iconColor,
width: widget.options.iconSize,
height: widget.options.iconSize,
),
),
const SizedBox(
width: 4,
),
Text('${widget.post.reaction}'),
],
],
),
// image of the post
if (widget.post.imageUrl != null) ...[
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;
},
)
: CachedNetworkImage(
width: double.infinity,
imageUrl: widget.post.imageUrl!,
fit: BoxFit.fitWidth,
),
] else ...[
Row(
children: [
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed:
isLikedByUser ? widget.onTapUnlike : widget.onTapLike,
icon: (isLikedByUser
? widget.options.theme.likedIcon
: widget.options.theme.likeIcon) ??
Icon(
isLikedByUser
? Icons.favorite_rounded
: Icons.favorite_outline,
color: widget.options.theme.iconColor,
size: widget.options.iconSize,
),
),
),
],
const SizedBox(
height: 8,
),
// post information
if (widget.options.iconsWithValues)
Row(
children: [
TextButton.icon(
onPressed: () async {
var userId = widget.userId;
var liked =
widget.post.likedBy?.contains(userId) ?? false;
if (!liked) {
await widget.service.postService.likePost(
userId,
widget.post,
);
} else {
await widget.service.postService.unlikePost(
userId,
widget.post,
);
}
},
icon: widget.options.theme.likeIcon ??
Icon(
widget.post.likedBy?.contains(widget.userId) ?? false
? Icons.favorite_rounded
: Icons.favorite_outline_outlined,
const SizedBox(width: 8),
if (widget.post.reactionEnabled) ...[
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: widget.onTap,
icon: widget.options.theme.commentIcon ??
SvgPicture.asset(
'assets/Comment.svg',
package: 'flutter_timeline_view',
// ignore: deprecated_member_use
color: widget.options.theme.iconColor,
width: widget.options.iconSize,
height: widget.options.iconSize,
),
label: Text('${widget.post.likes}'),
),
if (widget.post.reactionEnabled)
TextButton.icon(
onPressed: widget.onTap,
icon: widget.options.theme.commentIcon ??
const Icon(
Icons.chat_bubble_outline_outlined,
),
label: Text('${widget.post.reaction}'),
),
],
)
else
Row(
children: [
if (widget.post.likedBy?.contains(widget.userId) ??
false) ...[
InkWell(
onTap: widget.onTapUnlike,
child: Container(
color: Colors.transparent,
child: widget.options.theme.likedIcon ??
Icon(
Icons.favorite_rounded,
color: widget.options.theme.iconColor,
size: widget.options.iconSize,
),
),
),
] else ...[
InkWell(
onTap: widget.onTapLike,
child: Container(
color: Colors.transparent,
child: widget.options.theme.likedIcon ??
Icon(
Icons.favorite_outline,
color: widget.options.theme.iconColor,
size: widget.options.iconSize,
),
),
),
],
const SizedBox(width: 8),
if (widget.post.reactionEnabled) ...[
Container(
color: Colors.transparent,
child: widget.options.theme.commentIcon ??
Icon(
Icons.chat_bubble_outline_rounded,
color: widget.options.theme.iconColor,
size: widget.options.iconSize,
),
),
],
],
),
const SizedBox(
height: 8,
],
),
],
if (widget.options.itemInfoBuilder != null) ...[
widget.options.itemInfoBuilder!(
post: widget.post,
),
] else ...[
Text(
'${widget.post.likes} '
'${widget.options.translations.likesTitle}',
style: widget
.options.theme.textStyles.listPostLikeTitleAndAmount ??
theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
Text.rich(
TextSpan(
text: widget.options.nameBuilder?.call(widget.post.creator) ??
widget.post.creator?.fullName ??
widget.options.translations.anonymousUser,
style: widget.options.theme.textStyles.listCreatorNameStyle ??
theme.textTheme.titleSmall,
children: [
const TextSpan(text: ' '),
TextSpan(
text: widget.post.title,
style:
widget.options.theme.textStyles.listPostTitleStyle ??
theme.textTheme.bodyMedium,
const SizedBox(
height: 8,
),
if (widget.options.itemInfoBuilder != null) ...[
widget.options.itemInfoBuilder!(
post: widget.post,
),
] else ...[
_PostLikeCountText(
post: widget.post,
options: widget.options,
),
Text.rich(
TextSpan(
text: widget.options.nameBuilder?.call(widget.post.creator) ??
widget.post.creator?.fullName ??
widget.options.translations.anonymousUser,
style: widget.options.theme.textStyles.listCreatorNameStyle ??
theme.textTheme.titleSmall!.copyWith(
color: Colors.black,
),
],
),
children: [
TextSpan(
text: widget.post.title,
style: widget.options.theme.textStyles.listPostTitleStyle ??
theme.textTheme.bodySmall,
),
],
),
const SizedBox(height: 4),
Text(
),
const SizedBox(height: 4),
InkWell(
onTap: widget.onTap,
child: Text(
widget.options.translations.viewPost,
style: widget.options.theme.textStyles.viewPostStyle ??
theme.textTheme.bodySmall,
theme.textTheme.titleSmall!.copyWith(
color: const Color(0xFF8D8D8D),
),
),
],
if (widget.options.dividerBuilder != null)
widget.options.dividerBuilder!(),
),
],
),
if (widget.options.dividerBuilder != null)
widget.options.dividerBuilder!(),
],
),
);
}
}
class _PostLikeCountText extends StatelessWidget {
const _PostLikeCountText({
required this.post,
required this.options,
});
final TimelineOptions options;
final TimelinePost post;
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
var likeTranslation = post.likes > 1
? options.translations.multipleLikesTitle
: options.translations.oneLikeTitle;
return Text(
'${post.likes} '
'$likeTranslation',
style: options.theme.textStyles.listPostLikeTitleAndAmount ??
theme.textTheme.titleSmall!.copyWith(
color: Colors.black,
),
);
}
}
Future<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();
}
}

View file

@ -4,38 +4,35 @@
name: flutter_timeline_view
description: Visual elements of the Flutter Timeline Component
version: 2.3.0
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
flutter_html: ^3.0.0-beta.2
collection: ^1.18.0
flutter_svg: ^2.0.10+1
flutter_timeline_interface:
git:
url: https://github.com/Iconica-Development/flutter_timeline
path: packages/flutter_timeline_interface
ref: 2.3.0
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.5
collection: any
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/

View file

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