Compare commits

..

26 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
43 changed files with 1764 additions and 1535 deletions

2
.fvmrc
View file

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

View file

@ -9,6 +9,4 @@ jobs:
call-global-iconica-workflow: call-global-iconica-workflow:
uses: Iconica-Development/.github/.github/workflows/melos-ci.yml@master uses: Iconica-Development/.github/.github/workflows/melos-ci.yml@master
secrets: inherit secrets: inherit
permissions: write-all permissions: write-all
with:
flutter_version: 3.19.6

View file

@ -1,9 +1,12 @@
## 4.1.2 ## 5.1.1
- Get posts from the updated service when a new TimelineService has been passed - Be honest about which Dart and Flutter versions we support
- Relax our Firebase version constraint, we also support the current newest versions
## 4.1.1 ## 5.1.0
- Update Firebase dependencies to their latest versions
* Added `routeToPostDetail` to the `TimelineUserStory` to allow for navigation to the post detail screen.
* Fixed design issues.
## 4.1.0 ## 4.1.0
- Migrate to flutter 3.22 which deprecates the background and onBackground properties in the ThemeData and also removes MaterialStatePropertyAll - Migrate to flutter 3.22 which deprecates the background and onBackground properties in the ThemeData and also removes MaterialStatePropertyAll

View file

@ -35,45 +35,7 @@ And import this package: import 'package:intl/date_symbol_data_local.dart';
``` ```
## How to use ## How to use
To use the module within your Flutter-application with predefined `Go_router` routes you should add the following: To use the userstory add the following code somewhere in your widget tree:
Add go_router as dependency to your project.
Add the following configuration to your flutter_application:
```
List<GoRoute> getTimelineStoryRoutes() =>
getTimelineStoryRoutes(
TimelineUserStoryConfiguration(
service: TimelineService(
postService: LocalTimelinePostService(),
),
optionsBuilder: (context) {
return const TimelineOptions();
},
),
);
```
Add the `getTimelineStoryRoutes()` to your go_router routes like so:
```
final GoRouter _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (BuildContext context, GoRouterState state) {
return const MyHomePage(
title: "home",
);
},
),
...getTimelineStoryRoutes(configuration: configuration);
],
);
```
The user story can also be used without go router:
Add the following code somewhere in your widget tree:
```` ````
timeLineNavigatorUserStory(TimelineUserStoryConfiguration, context), timeLineNavigatorUserStory(TimelineUserStoryConfiguration, context),

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(
surface: const Color(0XFFFAF9F6),
),
useMaterial3: true,
),
);
}
}

View file

@ -16,25 +16,6 @@ var options = TimelineOptions(
paddings: TimelinePaddingOptions( paddings: TimelinePaddingOptions(
mainPadding: const EdgeInsets.all(20).copyWith(top: 28), mainPadding: const EdgeInsets.all(20).copyWith(top: 28),
), ),
categoriesOptions: CategoriesOptions(
categoriesBuilder: (context) => [
const TimelineCategory(
key: null,
title: 'All',
icon: SizedBox.shrink(),
),
const TimelineCategory(
key: 'category1',
title: 'Category 1',
icon: SizedBox.shrink(),
),
const TimelineCategory(
key: 'category2',
title: 'Category 2',
icon: SizedBox.shrink(),
),
],
),
); );
void navigateToOverview( void navigateToOverview(

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/navigator/app.dart';
import 'package:example/apps/go_router/app.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/date_symbol_data_local.dart';
void main() { void main() {
initializeDateFormatting(); initializeDateFormatting();
// Uncomment any, but only one, of these lines to run the example with specific navigation. runApp(const NavigatorApp());
// runApp(const WidgetApp());
// runApp(const NavigatorApp());
runApp(const GoRouterApp());
} }

View file

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

View file

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

View file

@ -1,311 +0,0 @@
// SPDX-FileCopyrightText: 2023 Iconica
//
// SPDX-License-Identifier: BSD-3-Clause
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_timeline/flutter_timeline.dart';
import 'package:flutter_timeline/src/go_router.dart';
import 'package:go_router/go_router.dart';
/// Retrieves a list of GoRouter routes for timeline stories.
///
/// This function retrieves a list of GoRouter routes for displaying timeline
/// stories. It takes an optional [TimelineUserStoryConfiguration] as parameter.
/// If no configuration is provided, default values will be used.
List<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 service = config.serviceBuilder?.call(context) ?? config.service;
var timelineScreen = TimelineScreen(
userId: config.getUserId?.call(context) ?? config.userId,
onUserTap: (user) => config.onUserTap?.call(context, user),
allowAllDeletion: config.canDeleteAllPosts?.call(context) ?? false,
onRefresh: config.onRefresh,
service: service,
options: config.optionsBuilder(context),
onPostTap: (post) async =>
config.onPostTap?.call(context, post) ??
await context.push(
TimelineUserStoryRoutes.timelineViewPath(post.id),
),
filterEnabled: config.filterEnabled,
postWidgetBuilder: config.postWidgetBuilder,
);
var button = FloatingActionButton(
backgroundColor: config
.optionsBuilder(context)
.theme
.postCreationFloatingActionButtonColor ??
Theme.of(context).primaryColor,
onPressed: () async => context.push(
TimelineUserStoryRoutes.timelineCategorySelection,
),
shape: const CircleBorder(),
child: const Icon(
Icons.add,
color: Colors.white,
size: 30,
),
);
return buildScreenWithoutTransition(
context: context,
state: state,
child: config.homeOpenPageBuilder
?.call(context, timelineScreen, button) ??
Scaffold(
appBar: AppBar(
backgroundColor: const Color(0xff212121),
title: Text(
config
.optionsBuilder(context)
.translations
.timeLineScreenTitle,
style: TextStyle(
color: Theme.of(context).primaryColor,
fontSize: 24,
fontWeight: FontWeight.w800,
),
),
),
body: timelineScreen,
floatingActionButton: button,
),
);
},
),
GoRoute(
path: TimelineUserStoryRoutes.timelineCategorySelection,
pageBuilder: (context, state) {
var timelineSelectionScreen = TimelineSelectionScreen(
options: config.optionsBuilder(context),
categories: config
.optionsBuilder(context)
.categoriesOptions
.categoriesBuilder
?.call(context) ??
[],
onCategorySelected: (category) async {
await context.push(
TimelineUserStoryRoutes.timelinepostCreation(category.key ?? ''),
);
},
);
var backButton = IconButton(
color: Colors.white,
icon: const Icon(Icons.arrow_back_ios),
onPressed: () => context.go(TimelineUserStoryRoutes.timelineHome),
);
return buildScreenWithoutTransition(
context: context,
state: state,
child: config.categorySelectionOpenPageBuilder
?.call(context, timelineSelectionScreen) ??
Scaffold(
appBar: AppBar(
leading: backButton,
backgroundColor: const Color(0xff212121),
title: Text(
config.optionsBuilder(context).translations.postCreation,
style: TextStyle(
color: Theme.of(context).primaryColor,
fontSize: 24,
fontWeight: FontWeight.w800,
),
),
),
body: timelineSelectionScreen,
),
);
},
),
GoRoute(
path: TimelineUserStoryRoutes.timelineView,
pageBuilder: (context, state) {
var service = config.serviceBuilder?.call(context) ?? config.service;
var post = service.postService.getPost(state.pathParameters['post']!);
var category = config.optionsBuilder
.call(context)
.categoriesOptions
.categoriesBuilder
?.call(context)
.firstWhereOrNull(
(element) => element.key == post?.category,
);
var timelinePostWidget = TimelinePostScreen(
userId: config.getUserId?.call(context) ?? config.userId,
allowAllDeletion: config.canDeleteAllPosts?.call(context) ?? false,
options: config.optionsBuilder(context),
service: service,
post: post!,
onPostDelete: () async =>
config.onPostDelete?.call(context, post) ??
() async {
await service.postService.deletePost(post);
if (!context.mounted) return;
context.go(TimelineUserStoryRoutes.timelineHome);
}.call(),
onUserTap: (user) => config.onUserTap?.call(context, user),
);
var backButton = IconButton(
color: Colors.white,
icon: const Icon(Icons.arrow_back_ios),
onPressed: () => context.go(TimelineUserStoryRoutes.timelineHome),
);
return buildScreenWithoutTransition(
context: context,
state: state,
child: config.postViewOpenPageBuilder?.call(
context,
timelinePostWidget,
backButton,
post,
category,
) ??
Scaffold(
appBar: AppBar(
leading: backButton,
backgroundColor: const Color(0xff212121),
title: Text(
category?.title ?? post.category ?? 'Category',
style: TextStyle(
color: Theme.of(context).primaryColor,
fontSize: 24,
fontWeight: FontWeight.w800,
),
),
),
body: timelinePostWidget,
),
);
},
),
GoRoute(
path: TimelineUserStoryRoutes.timelinePostCreation,
pageBuilder: (context, state) {
var category = state.pathParameters['category'];
var service = config.serviceBuilder?.call(context) ?? config.service;
var timelinePostCreationWidget = TimelinePostCreationScreen(
userId: config.getUserId?.call(context) ?? config.userId,
options: config.optionsBuilder(context),
service: service,
onPostCreated: (post) async {
var newPost = await service.postService.createPost(post);
if (!context.mounted) return;
if (config.afterPostCreationGoHome) {
context.go(TimelineUserStoryRoutes.timelineHome);
} else {
await context
.push(TimelineUserStoryRoutes.timelineViewPath(newPost.id));
}
},
onPostOverview: (post) async => context.push(
TimelineUserStoryRoutes.timelinePostOverview,
extra: post,
),
enablePostOverviewScreen: config.enablePostOverviewScreen,
postCategory: category,
);
var backButton = IconButton(
icon: const Icon(
Icons.arrow_back_ios,
color: Colors.white,
),
onPressed: () =>
context.go(TimelineUserStoryRoutes.timelineCategorySelection),
);
return buildScreenWithoutTransition(
context: context,
state: state,
child: config.postCreationOpenPageBuilder
?.call(context, timelinePostCreationWidget, backButton) ??
Scaffold(
appBar: AppBar(
backgroundColor: const Color(0xff212121),
leading: backButton,
title: Text(
config.optionsBuilder(context).translations.postCreation,
style: TextStyle(
color: Theme.of(context).primaryColor,
fontSize: 24,
fontWeight: FontWeight.w800,
),
),
),
body: timelinePostCreationWidget,
),
);
},
),
GoRoute(
path: TimelineUserStoryRoutes.timelinePostOverview,
pageBuilder: (context, state) {
var post = state.extra! as TimelinePost;
var service = config.serviceBuilder?.call(context) ?? config.service;
var timelinePostOverviewWidget = TimelinePostOverviewScreen(
options: config.optionsBuilder(context),
service: service,
timelinePost: post,
onPostSubmit: (post) async {
await service.postService.createPost(post);
if (!context.mounted) return;
context.go(TimelineUserStoryRoutes.timelineHome);
},
);
var backButton = IconButton(
icon: const Icon(
Icons.arrow_back_ios,
color: Colors.white,
),
onPressed: () async => context.pop(),
);
return buildScreenWithoutTransition(
context: context,
state: state,
child: config.postOverviewOpenPageBuilder?.call(
context,
timelinePostOverviewWidget,
) ??
Scaffold(
appBar: AppBar(
leading: backButton,
backgroundColor: const Color(0xff212121),
title: Text(
config.optionsBuilder(context).translations.postOverview,
style: TextStyle(
color: Theme.of(context).primaryColor,
fontSize: 24,
fontWeight: FontWeight.w800,
),
),
),
body: timelinePostOverviewWidget,
),
);
},
),
];
}

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. /// This function creates a navigator for displaying user stories on a timeline.
/// It takes a [BuildContext] and an optional [TimelineUserStoryConfiguration] /// It takes a [BuildContext] and an optional [TimelineUserStoryConfiguration]
/// as parameters. If no configuration is provided, default values will be used. /// as parameters. If no configuration is provided, default values will be used.
late TimelineUserStoryConfiguration timelineUserStoryConfiguration;
Widget timeLineNavigatorUserStory({ Widget timeLineNavigatorUserStory({
required BuildContext context, required BuildContext context,
TimelineUserStoryConfiguration? configuration, TimelineUserStoryConfiguration? configuration,
}) { }) {
var config = configuration ?? timelineUserStoryConfiguration = configuration ??
TimelineUserStoryConfiguration( TimelineUserStoryConfiguration(
userId: 'test_user', userId: 'test_user',
service: TimelineService( service: TimelineService(
@ -23,7 +25,10 @@ Widget timeLineNavigatorUserStory({
optionsBuilder: (context) => const TimelineOptions(), 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. /// A widget function that creates a timeline screen route.
@ -33,18 +38,11 @@ Widget timeLineNavigatorUserStory({
/// parameters. If no configuration is provided, default values will be used. /// parameters. If no configuration is provided, default values will be used.
Widget _timelineScreenRoute({ Widget _timelineScreenRoute({
required BuildContext context, required BuildContext context,
TimelineUserStoryConfiguration? configuration, required TimelineUserStoryConfiguration config,
String? initalCategory,
}) { }) {
var config = configuration ??
TimelineUserStoryConfiguration(
userId: 'test_user',
service: TimelineService(
postService: LocalTimelinePostService(),
),
optionsBuilder: (context) => const TimelineOptions(),
);
var timelineScreen = TimelineScreen( var timelineScreen = TimelineScreen(
timelineCategory: initalCategory,
userId: config.getUserId?.call(context) ?? config.userId, userId: config.getUserId?.call(context) ?? config.userId,
allowAllDeletion: config.canDeleteAllPosts?.call(context) ?? false, allowAllDeletion: config.canDeleteAllPosts?.call(context) ?? false,
onUserTap: (user) => config.onUserTap?.call(context, user), onUserTap: (user) => config.onUserTap?.call(context, user),
@ -55,7 +53,7 @@ Widget _timelineScreenRoute({
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => _postDetailScreenRoute( builder: (context) => _postDetailScreenRoute(
configuration: config, config: config,
context: context, context: context,
post: post, post: post,
), ),
@ -65,40 +63,50 @@ Widget _timelineScreenRoute({
filterEnabled: config.filterEnabled, filterEnabled: config.filterEnabled,
postWidgetBuilder: config.postWidgetBuilder, postWidgetBuilder: config.postWidgetBuilder,
); );
var theme = Theme.of(context);
var button = FloatingActionButton( var button = FloatingActionButton(
backgroundColor: config backgroundColor: config
.optionsBuilder(context) .optionsBuilder(context)
.theme .theme
.postCreationFloatingActionButtonColor ?? .postCreationFloatingActionButtonColor ??
Theme.of(context).primaryColor, theme.colorScheme.primary,
onPressed: () async => Navigator.of(context).push( onPressed: () async {
MaterialPageRoute( var selectedCategory = config.service.postService.selectedCategory;
builder: (context) => _postCategorySelectionScreen( if (selectedCategory != null && selectedCategory.key != null) {
configuration: config, await Navigator.of(context).push(
context: context, 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(), shape: const CircleBorder(),
child: const Icon( child: const Icon(
Icons.add, Icons.add,
color: Colors.white, color: Colors.white,
size: 30, size: 24,
), ),
); );
return config.homeOpenPageBuilder?.call(context, timelineScreen, button) ?? return config.homeOpenPageBuilder?.call(context, timelineScreen, button) ??
Scaffold( Scaffold(
appBar: AppBar( appBar: AppBar(
backgroundColor: const Color(0xff212121),
title: Text( title: Text(
config.optionsBuilder(context).translations.timeLineScreenTitle, config.optionsBuilder(context).translations.timeLineScreenTitle,
style: TextStyle( style: theme.textTheme.headlineLarge,
color: Theme.of(context).primaryColor,
fontSize: 24,
fontWeight: FontWeight.w800,
),
), ),
), ),
body: timelineScreen, body: timelineScreen,
@ -115,17 +123,8 @@ Widget _timelineScreenRoute({
Widget _postDetailScreenRoute({ Widget _postDetailScreenRoute({
required BuildContext context, required BuildContext context,
required TimelinePost post, required TimelinePost post,
TimelineUserStoryConfiguration? configuration, required TimelineUserStoryConfiguration config,
}) { }) {
var config = configuration ??
TimelineUserStoryConfiguration(
userId: 'test_user',
service: TimelineService(
postService: LocalTimelinePostService(),
),
optionsBuilder: (context) => const TimelineOptions(),
);
var timelinePostScreen = TimelinePostScreen( var timelinePostScreen = TimelinePostScreen(
userId: config.getUserId?.call(context) ?? config.userId, userId: config.getUserId?.call(context) ?? config.userId,
allowAllDeletion: config.canDeleteAllPosts?.call(context) ?? false, allowAllDeletion: config.canDeleteAllPosts?.call(context) ?? false,
@ -143,11 +142,7 @@ Widget _postDetailScreenRoute({
onUserTap: (user) => config.onUserTap?.call(context, user), onUserTap: (user) => config.onUserTap?.call(context, user),
); );
var category = config var category = config.service.postService.categories
.optionsBuilder(context)
.categoriesOptions
.categoriesBuilder
?.call(context)
.firstWhere((element) => element.key == post.category); .firstWhere((element) => element.key == post.category);
var backButton = IconButton( var backButton = IconButton(
@ -160,10 +155,9 @@ Widget _postDetailScreenRoute({
?.call(context, timelinePostScreen, backButton, post, category) ?? ?.call(context, timelinePostScreen, backButton, post, category) ??
Scaffold( Scaffold(
appBar: AppBar( appBar: AppBar(
leading: backButton, iconTheme: Theme.of(context).appBarTheme.iconTheme,
backgroundColor: const Color(0xff212121),
title: Text( title: Text(
category?.title ?? post.category ?? 'Category', category.title.toLowerCase(),
style: TextStyle( style: TextStyle(
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
fontSize: 24, fontSize: 24,
@ -183,31 +177,24 @@ Widget _postDetailScreenRoute({
Widget _postCreationScreenRoute({ Widget _postCreationScreenRoute({
required BuildContext context, required BuildContext context,
required TimelineCategory category, required TimelineCategory category,
TimelineUserStoryConfiguration? configuration, required TimelineUserStoryConfiguration config,
}) { }) {
var config = configuration ??
TimelineUserStoryConfiguration(
userId: 'test_user',
service: TimelineService(
postService: LocalTimelinePostService(),
),
optionsBuilder: (context) => const TimelineOptions(),
);
var timelinePostCreationScreen = TimelinePostCreationScreen( var timelinePostCreationScreen = TimelinePostCreationScreen(
userId: config.getUserId?.call(context) ?? config.userId, userId: config.getUserId?.call(context) ?? config.userId,
options: config.optionsBuilder(context), options: config.optionsBuilder(context),
service: config.service, service: config.service,
onPostCreated: (post) async { onPostCreated: (post) async {
var newPost = await config.service.postService.createPost(post); var newPost = await config.service.postService.createPost(post);
if (!context.mounted) return; if (!context.mounted) return;
if (config.afterPostCreationGoHome) { if (config.afterPostCreationGoHome) {
await Navigator.pushReplacement( await Navigator.pushReplacement(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => _timelineScreenRoute( builder: (context) => _timelineScreenRoute(
configuration: config, config: config,
context: context, context: context,
initalCategory: category.title,
), ),
), ),
); );
@ -216,7 +203,7 @@ Widget _postCreationScreenRoute({
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => _postOverviewScreenRoute( builder: (context) => _postOverviewScreenRoute(
configuration: config, config: config,
context: context, context: context,
post: newPost, post: newPost,
), ),
@ -227,7 +214,7 @@ Widget _postCreationScreenRoute({
onPostOverview: (post) async => Navigator.of(context).push( onPostOverview: (post) async => Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => _postOverviewScreenRoute( builder: (context) => _postOverviewScreenRoute(
configuration: config, config: config,
context: context, context: context,
post: post, post: post,
), ),
@ -249,7 +236,7 @@ Widget _postCreationScreenRoute({
?.call(context, timelinePostCreationScreen, backButton) ?? ?.call(context, timelinePostCreationScreen, backButton) ??
Scaffold( Scaffold(
appBar: AppBar( appBar: AppBar(
backgroundColor: const Color(0xff212121), iconTheme: Theme.of(context).appBarTheme.iconTheme,
leading: backButton, leading: backButton,
title: Text( title: Text(
config.optionsBuilder(context).translations.postCreation, config.optionsBuilder(context).translations.postCreation,
@ -273,28 +260,23 @@ Widget _postCreationScreenRoute({
Widget _postOverviewScreenRoute({ Widget _postOverviewScreenRoute({
required BuildContext context, required BuildContext context,
required TimelinePost post, required TimelinePost post,
TimelineUserStoryConfiguration? configuration, required TimelineUserStoryConfiguration config,
}) { }) {
var config = configuration ??
TimelineUserStoryConfiguration(
userId: 'test_user',
service: TimelineService(
postService: LocalTimelinePostService(),
),
optionsBuilder: (context) => const TimelineOptions(),
);
var timelinePostOverviewWidget = TimelinePostOverviewScreen( var timelinePostOverviewWidget = TimelinePostOverviewScreen(
options: config.optionsBuilder(context), options: config.optionsBuilder(context),
service: config.service, service: config.service,
timelinePost: post, timelinePost: post,
onPostSubmit: (post) async { onPostSubmit: (post) async {
await config.service.postService.createPost(post); var createdPost = await config.service.postService.createPost(post);
config.onPostCreate?.call(createdPost);
if (context.mounted) { if (context.mounted) {
await Navigator.of(context).pushAndRemoveUntil( await Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => builder: (context) => _timelineScreenRoute(
_timelineScreenRoute(configuration: config, context: context), config: config,
context: context,
initalCategory: post.category,
),
), ),
(route) => false, (route) => false,
); );
@ -316,10 +298,10 @@ Widget _postOverviewScreenRoute({
) ?? ) ??
Scaffold( Scaffold(
appBar: AppBar( appBar: AppBar(
iconTheme: Theme.of(context).appBarTheme.iconTheme,
leading: backButton, leading: backButton,
backgroundColor: const Color(0xff212121),
title: Text( title: Text(
config.optionsBuilder(context).translations.postOverview, config.optionsBuilder(context).translations.postCreation,
style: TextStyle( style: TextStyle(
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
fontSize: 24, fontSize: 24,
@ -333,30 +315,17 @@ Widget _postOverviewScreenRoute({
Widget _postCategorySelectionScreen({ Widget _postCategorySelectionScreen({
required BuildContext context, required BuildContext context,
TimelineUserStoryConfiguration? configuration, required TimelineUserStoryConfiguration config,
}) { }) {
var config = configuration ??
TimelineUserStoryConfiguration(
userId: 'test_user',
service: TimelineService(
postService: LocalTimelinePostService(),
),
optionsBuilder: (context) => const TimelineOptions(),
);
var timelineSelectionScreen = TimelineSelectionScreen( var timelineSelectionScreen = TimelineSelectionScreen(
postService: config.service.postService,
options: config.optionsBuilder(context), options: config.optionsBuilder(context),
categories: config categories: config.service.postService.categories,
.optionsBuilder(context)
.categoriesOptions
.categoriesBuilder
?.call(context) ??
[],
onCategorySelected: (category) async { onCategorySelected: (category) async {
await Navigator.of(context).push( await Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => _postCreationScreenRoute( builder: (context) => _postCreationScreenRoute(
configuration: config, config: config,
context: context, context: context,
category: category, category: category,
), ),
@ -377,8 +346,8 @@ Widget _postCategorySelectionScreen({
?.call(context, timelineSelectionScreen) ?? ?.call(context, timelineSelectionScreen) ??
Scaffold( Scaffold(
appBar: AppBar( appBar: AppBar(
iconTheme: Theme.of(context).appBarTheme.iconTheme,
leading: backButton, leading: backButton,
backgroundColor: const Color(0xff212121),
title: Text( title: Text(
config.optionsBuilder(context).translations.postCreation, config.optionsBuilder(context).translations.postCreation,
style: TextStyle( style: TextStyle(
@ -391,3 +360,15 @@ Widget _postCategorySelectionScreen({
body: timelineSelectionScreen, 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

@ -65,6 +65,7 @@ class TimelineUserStoryConfiguration {
this.afterPostCreationGoHome = false, this.afterPostCreationGoHome = false,
this.enablePostOverviewScreen = true, this.enablePostOverviewScreen = true,
this.categorySelectionOpenPageBuilder, this.categorySelectionOpenPageBuilder,
this.onPostCreate,
}); });
/// The ID of the user associated with this user story configuration. /// The ID of the user associated with this user story configuration.
@ -159,4 +160,6 @@ class TimelineUserStoryConfiguration {
BuildContext context, BuildContext context,
Widget child, Widget child,
)? categorySelectionOpenPageBuilder; )? categorySelectionOpenPageBuilder;
final Function(TimelinePost post)? onPostCreate;
} }

View file

@ -3,30 +3,27 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
name: flutter_timeline name: flutter_timeline
description: Visual elements and interface combined into one package description: Visual elements and interface combined into one package
version: 4.1.2 version: 5.1.1
repository: https://github.com/Iconica-Development/flutter_timeline homepage: https://github.com/Iconica-Development/flutter_timeline
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub
environment: environment:
sdk: ">=3.1.3 <4.0.0" sdk: ">=3.4.3 <4.0.0"
flutter: '>=3.22.2'
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
go_router: ^12.1.3
collection: ^1.18.0
flutter_timeline_view: flutter_timeline_view:
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
version: ^4.1.1 version: ^5.1.1
flutter_timeline_interface: flutter_timeline_interface:
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
version: ^4.1.1 version: ^5.1.1
collection: ^1.18.0
dev_dependencies: dev_dependencies:
flutter_lints: ^2.0.0
flutter_iconica_analysis: flutter_iconica_analysis:
git: git:
url: https://github.com/Iconica-Development/flutter_iconica_analysis url: https://github.com/Iconica-Development/flutter_iconica_analysis

View file

@ -2,15 +2,14 @@
// //
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
import 'package:flutter/material.dart';
@immutable
class FirebaseTimelineOptions { class FirebaseTimelineOptions {
const FirebaseTimelineOptions({ const FirebaseTimelineOptions({
this.usersCollectionName = 'users', this.usersCollectionName = 'users',
this.timelineCollectionName = 'timeline', this.timelineCollectionName = 'timeline',
this.timelineCategoryCollectionName = 'timeline_categories',
}); });
final String usersCollectionName; final String usersCollectionName;
final String timelineCollectionName; final String timelineCollectionName;
final String timelineCategoryCollectionName;
} }

View file

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

View file

@ -5,6 +5,7 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:collection/collection.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_storage/firebase_storage.dart'; import 'package:firebase_storage/firebase_storage.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -38,6 +39,12 @@ class FirebaseTimelinePostService
@override @override
List<TimelinePost> posts = []; List<TimelinePost> posts = [];
@override
List<TimelineCategory> categories = [];
@override
TimelineCategory? selectedCategory;
@override @override
Future<TimelinePost> createPost(TimelinePost post) async { Future<TimelinePost> createPost(TimelinePost post) async {
var postId = const Uuid().v4(); var postId = const Uuid().v4();
@ -118,7 +125,6 @@ class FirebaseTimelinePostService
@override @override
Future<List<TimelinePost>> fetchPosts(String? category) async { Future<List<TimelinePost>> fetchPosts(String? category) async {
debugPrint('fetching posts from firebase with category: $category');
var snapshot = (category != null) var snapshot = (category != null)
? await _db ? await _db
.collection(_options.timelineCollectionName) .collection(_options.timelineCollectionName)
@ -239,10 +245,20 @@ class FirebaseTimelinePostService
} }
@override @override
TimelinePost? getPost(String postId) => Future<TimelinePost?> getPost(String postId) async {
(posts.any((element) => element.id == postId)) var post = await _db
? posts.firstWhere((element) => element.id == postId) .collection(_options.timelineCollectionName)
: null; .doc(postId)
.withConverter<TimelinePost>(
fromFirestore: (snapshot, _) => TimelinePost.fromJson(
snapshot.id,
snapshot.data()!,
),
toFirestore: (user, _) => user.toJson(),
)
.get();
return post.data();
}
@override @override
List<TimelinePost> getPosts(String? category) => posts List<TimelinePost> getPosts(String? category) => posts
@ -358,4 +374,122 @@ class FirebaseTimelinePostService
return user; return user;
} }
@override
Future<bool> addCategory(TimelineCategory category) async {
var exists = categories.firstWhereOrNull(
(element) => element.title.toLowerCase() == category.title.toLowerCase(),
);
if (exists != null) return false;
try {
await _db
.collection(_options.timelineCategoryCollectionName)
.add(category.toJson());
categories.add(category);
notifyListeners();
return true;
} on Exception catch (_) {
return false;
}
}
@override
Future<List<TimelineCategory>> fetchCategories() async {
categories.clear();
categories.add(
const TimelineCategory(
key: null,
title: 'All',
),
);
var categoriesSnapshot = await _db
.collection(_options.timelineCategoryCollectionName)
.withConverter(
fromFirestore: (snapshot, _) =>
TimelineCategory.fromJson(snapshot.data()!),
toFirestore: (model, _) => model.toJson(),
)
.get();
categories.addAll(categoriesSnapshot.docs.map((e) => e.data()));
notifyListeners();
return categories;
}
@override
Future<TimelinePost> likeReaction(
String userId,
TimelinePost post,
String reactionId,
) async {
// update the post with the new like
var updatedPost = post.copyWith(
reactions: post.reactions?.map(
(r) {
if (r.id == reactionId) {
return r.copyWith(
likedBy: (r.likedBy ?? [])..add(userId),
);
}
return r;
},
).toList(),
);
posts = posts
.map(
(p) => p.id == post.id ? updatedPost : p,
)
.toList();
var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
await postRef.update({
'reactions': post.reactions
?.map(
(r) =>
r.id == reactionId ? r.copyWith(likedBy: r.likedBy ?? []) : r,
)
.map((e) => e.toJson())
.toList(),
});
notifyListeners();
return updatedPost;
}
@override
Future<TimelinePost> unlikeReaction(
String userId,
TimelinePost post,
String reactionId,
) async {
// update the post with the new like
var updatedPost = post.copyWith(
reactions: post.reactions?.map(
(r) {
if (r.id == reactionId) {
return r.copyWith(
likedBy: r.likedBy?..remove(userId),
);
}
return r;
},
).toList(),
);
posts = posts
.map(
(p) => p.id == post.id ? updatedPost : p,
)
.toList();
var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
await postRef.update({
'reactions': post.reactions
?.map(
(r) => r.id == reactionId
? r.copyWith(likedBy: r.likedBy?..remove(userId))
: r,
)
.map((e) => e.toJson())
.toList(),
});
notifyListeners();
return updatedPost;
}
} }

View file

@ -4,31 +4,30 @@
name: flutter_timeline_firebase name: flutter_timeline_firebase
description: Implementation of the Flutter Timeline interface for Firebase. description: Implementation of the Flutter Timeline interface for Firebase.
version: 4.1.2 version: 5.1.1
repository: https://github.com/Iconica-Development/flutter_timeline homepage: https://github.com/Iconica-Development/flutter_timeline
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub
environment: environment:
sdk: '>=3.1.3 <4.0.0' sdk: ">=3.4.3 <4.0.0"
flutter: '>=3.22.2'
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
cloud_firestore: ^5.6.6 cloud_firestore: '>=4.13.1 <6.0.0'
firebase_core: ^3.13.0 firebase_core: '>=2.22.0 <4.0.0'
firebase_storage: ^12.4.5 firebase_storage: '>=11.5.1 <13.0.0'
uuid: ^4.2.1 uuid: ^4.2.1
collection: ^1.18.0
flutter_timeline_interface: flutter_timeline_interface:
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
version: ^4.1.1 version: ^5.1.1
dev_dependencies: dev_dependencies:
flutter_lints: ^2.0.0
flutter_iconica_analysis: flutter_iconica_analysis:
git: git:
url: https://github.com/Iconica-Development/flutter_iconica_analysis url: https://github.com/Iconica-Development/flutter_iconica_analysis
ref: 6.0.0 ref: 6.0.0
flutter: flutter:

View file

@ -5,13 +5,44 @@ class TimelineCategory {
const TimelineCategory({ const TimelineCategory({
required this.key, required this.key,
required this.title, required this.title,
required this.icon, this.icon,
this.canCreate = true, this.canCreate = true,
this.canView = 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? key;
final String title; final String title;
final Widget icon; final Widget? icon;
final bool canCreate; final bool canCreate;
final bool canView; 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, 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 userId;
final String? firstName; final String? firstName;
final String? lastName; final String? lastName;
final String? imageUrl; final String? imageUrl;
Map<String, dynamic> toJson() => {
'first_name': firstName,
'last_name': lastName,
'image_url': imageUrl,
};
String? get fullName { String? get fullName {
var fullName = ''; var fullName = '';

View file

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

View file

@ -5,11 +5,12 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_timeline_interface/src/model/timeline_post.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
import 'package:flutter_timeline_interface/src/model/timeline_reaction.dart';
abstract class TimelinePostService with ChangeNotifier { abstract class TimelinePostService with ChangeNotifier {
List<TimelinePost> posts = []; List<TimelinePost> posts = [];
List<TimelineCategory> categories = [];
TimelineCategory? selectedCategory;
Future<void> deletePost(TimelinePost post); Future<void> deletePost(TimelinePost post);
Future<TimelinePost> deletePostReaction(TimelinePost post, String reactionId); Future<TimelinePost> deletePostReaction(TimelinePost post, String reactionId);
@ -17,7 +18,7 @@ abstract class TimelinePostService with ChangeNotifier {
Future<List<TimelinePost>> fetchPosts(String? category); Future<List<TimelinePost>> fetchPosts(String? category);
Future<TimelinePost> fetchPost(TimelinePost post); Future<TimelinePost> fetchPost(TimelinePost post);
Future<List<TimelinePost>> fetchPostsPaginated(String? category, int limit); Future<List<TimelinePost>> fetchPostsPaginated(String? category, int limit);
TimelinePost? getPost(String postId); Future<TimelinePost?> getPost(String postId);
List<TimelinePost> getPosts(String? category); List<TimelinePost> getPosts(String? category);
Future<List<TimelinePost>> refreshPosts(String? category); Future<List<TimelinePost>> refreshPosts(String? category);
Future<TimelinePost> fetchPostDetails(TimelinePost post); Future<TimelinePost> fetchPostDetails(TimelinePost post);
@ -28,4 +29,17 @@ abstract class TimelinePostService with ChangeNotifier {
}); });
Future<TimelinePost> likePost(String userId, TimelinePost post); Future<TimelinePost> likePost(String userId, TimelinePost post);
Future<TimelinePost> unlikePost(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 name: flutter_timeline_interface
description: Interface for the service of the Flutter Timeline component description: Interface for the service of the Flutter Timeline component
version: 4.1.2 version: 5.1.1
repository: https://github.com/Iconica-Development/flutter_timeline homepage: https://github.com/Iconica-Development/flutter_timeline
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub
environment: environment:
sdk: '>=3.1.3 <4.0.0' sdk: '>=3.4.3 <4.0.0'
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
dev_dependencies: dev_dependencies:
flutter_lints: ^2.0.0
flutter_iconica_analysis: flutter_iconica_analysis:
git: git:
url: https://github.com/Iconica-Development/flutter_iconica_analysis url: https://github.com/Iconica-Development/flutter_iconica_analysis

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

@ -16,10 +16,10 @@ class TimelineOptions {
this.translations = const TimelineTranslations.empty(), this.translations = const TimelineTranslations.empty(),
this.paddings = const TimelinePaddingOptions(), this.paddings = const TimelinePaddingOptions(),
this.imagePickerConfig = const ImagePickerConfig(), this.imagePickerConfig = const ImagePickerConfig(),
this.imagePickerTheme = const ImagePickerTheme(), this.imagePickerTheme,
this.timelinePostHeight, this.timelinePostHeight,
this.sortCommentsAscending = true, this.sortCommentsAscending = true,
this.sortPostsAscending, this.sortPostsAscending = false,
this.doubleTapTolike = false, this.doubleTapTolike = false,
this.iconsWithValues = false, this.iconsWithValues = false,
this.likeAndDislikeIconsForDoubleTap = const ( this.likeAndDislikeIconsForDoubleTap = const (
@ -38,7 +38,7 @@ class TimelineOptions {
this.userAvatarBuilder, this.userAvatarBuilder,
this.anonymousAvatarBuilder, this.anonymousAvatarBuilder,
this.nameBuilder, this.nameBuilder,
this.iconSize = 26, this.iconSize = 24,
this.postWidgetHeight, this.postWidgetHeight,
this.filterOptions = const FilterOptions(), this.filterOptions = const FilterOptions(),
this.categoriesOptions = const CategoriesOptions(), this.categoriesOptions = const CategoriesOptions(),
@ -93,7 +93,7 @@ class TimelineOptions {
/// ImagePickerTheme can be used to change the UI of the /// ImagePickerTheme can be used to change the UI of the
/// Image Picker Widget to change the text/icons to your liking. /// Image Picker Widget to change the text/icons to your liking.
final ImagePickerTheme imagePickerTheme; final ImagePickerTheme? imagePickerTheme;
/// ImagePickerConfig can be used to define the /// ImagePickerConfig can be used to define the
/// size and quality for the uploaded image. /// size and quality for the uploaded image.
@ -160,6 +160,7 @@ class TimelineOptions {
BuildContext context, BuildContext context,
Function() onPressed, Function() onPressed,
String text, String text,
TimelinePost post,
)? postOverviewButtonBuilder; )? postOverviewButtonBuilder;
/// Optional builder to override the default alertdialog for post deletion /// Optional builder to override the default alertdialog for post deletion
@ -174,53 +175,14 @@ class TimelineOptions {
final InputDecoration? contentInputDecoration; final InputDecoration? contentInputDecoration;
} }
List<TimelineCategory> _getDefaultCategories(context) => [
const TimelineCategory(
key: null,
title: 'All',
icon: Padding(
padding: EdgeInsets.only(right: 8.0),
child: Icon(
Icons.apps,
color: Colors.black,
),
),
),
const TimelineCategory(
key: 'Category',
title: 'Category',
icon: Padding(
padding: EdgeInsets.only(right: 8.0),
child: Icon(
Icons.category,
color: Colors.black,
),
),
),
const TimelineCategory(
key: 'Category with two lines',
title: 'Category with two lines',
icon: Padding(
padding: EdgeInsets.only(right: 8.0),
child: Icon(
Icons.category,
color: Colors.black,
),
),
),
];
class CategoriesOptions { class CategoriesOptions {
const CategoriesOptions({ const CategoriesOptions({
this.categoriesBuilder = _getDefaultCategories,
this.categoryButtonBuilder, this.categoryButtonBuilder,
this.categorySelectorHorizontalPadding, this.categorySelectorHorizontalPadding,
}); });
/// List of categories that the user can select. /// List of categories that the user can select.
/// If this is null no categories will be shown. /// If this is null no categories will be shown.
final List<TimelineCategory> Function(BuildContext context)?
categoriesBuilder;
/// Abilty to override the standard category selector /// Abilty to override the standard category selector
final Widget Function( final Widget Function(
@ -235,17 +197,11 @@ class CategoriesOptions {
final double? categorySelectorHorizontalPadding; final double? categorySelectorHorizontalPadding;
TimelineCategory? getCategoryByKey( TimelineCategory? getCategoryByKey(
List<TimelineCategory> categories,
BuildContext context, BuildContext context,
String? key, String? key,
) { ) =>
if (categoriesBuilder == null) { categories.firstWhereOrNull((category) => category.key == key);
return null;
}
return categoriesBuilder!
.call(context)
.firstWhereOrNull((category) => category.key == key);
}
} }
class FilterOptions { class FilterOptions {

View file

@ -4,9 +4,9 @@ import 'package:flutter/material.dart';
class TimelinePaddingOptions { class TimelinePaddingOptions {
const TimelinePaddingOptions({ const TimelinePaddingOptions({
this.mainPadding = this.mainPadding =
const EdgeInsets.only(left: 12.0, top: 24.0, right: 12.0, bottom: 12.0), const EdgeInsets.only(left: 32, top: 20, right: 32, bottom: 40),
this.postPadding = this.postPadding =
const EdgeInsets.symmetric(vertical: 12.0, horizontal: 12.0), const EdgeInsets.only(left: 12.0, top: 12, right: 12.0, bottom: 8),
this.postOverviewButtonBottomPadding = 30.0, this.postOverviewButtonBottomPadding = 30.0,
this.categoryButtonTextPadding, this.categoryButtonTextPadding,
}); });

View file

@ -28,7 +28,6 @@ class TimelineTranslations {
required this.allowCommentsDescription, required this.allowCommentsDescription,
required this.commentsTitleOnPost, required this.commentsTitleOnPost,
required this.checkPost, required this.checkPost,
required this.postAt,
required this.deletePost, required this.deletePost,
required this.deleteReaction, required this.deleteReaction,
required this.deleteConfirmationMessage, required this.deleteConfirmationMessage,
@ -36,7 +35,8 @@ class TimelineTranslations {
required this.deleteCancelButton, required this.deleteCancelButton,
required this.deleteButton, required this.deleteButton,
required this.viewPost, required this.viewPost,
required this.likesTitle, required this.oneLikeTitle,
required this.multipleLikesTitle,
required this.commentsTitle, required this.commentsTitle,
required this.firstComment, required this.firstComment,
required this.writeComment, required this.writeComment,
@ -49,6 +49,14 @@ class TimelineTranslations {
required this.yes, required this.yes,
required this.no, required this.no,
required this.timeLineScreenTitle, required this.timeLineScreenTitle,
required this.createCategoryPopuptitle,
required this.addCategoryTitle,
required this.addCategorySubmitButton,
required this.addCategoryCancelButtton,
required this.addCategoryHintText,
required this.addCategoryErrorText,
required this.titleErrorText,
required this.contentErrorText,
}); });
/// Default translations for the timeline component view /// Default translations for the timeline component view
@ -67,7 +75,7 @@ class TimelineTranslations {
this.allowCommentsDescription = this.allowCommentsDescription =
'Indicate whether people are allowed to respond', 'Indicate whether people are allowed to respond',
this.commentsTitleOnPost = 'Comments', this.commentsTitleOnPost = 'Comments',
this.checkPost = 'Check post overview', this.checkPost = 'Overview',
this.deletePost = 'Delete post', this.deletePost = 'Delete post',
this.deleteConfirmationTitle = 'Delete Post', this.deleteConfirmationTitle = 'Delete Post',
this.deleteConfirmationMessage = this.deleteConfirmationMessage =
@ -76,20 +84,28 @@ class TimelineTranslations {
this.deleteCancelButton = 'Cancel', this.deleteCancelButton = 'Cancel',
this.deleteReaction = 'Delete Reaction', this.deleteReaction = 'Delete Reaction',
this.viewPost = 'View post', this.viewPost = 'View post',
this.likesTitle = 'Likes', this.oneLikeTitle = 'like',
this.multipleLikesTitle = 'likes',
this.commentsTitle = 'Are people allowed to comment?', this.commentsTitle = 'Are people allowed to comment?',
this.firstComment = 'Be the first to comment', this.firstComment = 'Be the first to comment',
this.writeComment = 'Write your comment here...', this.writeComment = 'Write your comment here...',
this.postAt = 'at',
this.postLoadingError = 'Something went wrong while loading the post', this.postLoadingError = 'Something went wrong while loading the post',
this.timelineSelectionDescription = 'Choose a category', this.timelineSelectionDescription = 'Choose a category',
this.searchHint = 'Search...', this.searchHint = 'Search...',
this.postOverview = 'Post Overview', this.postOverview = 'Post Overview',
this.postIn = 'Post in', this.postIn = 'Post',
this.postCreation = 'add post', this.postCreation = 'add post',
this.yes = 'Yes', this.yes = 'Yes',
this.no = 'No', this.no = 'No',
this.timeLineScreenTitle = 'iconinstagram', 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 noPosts;
@ -104,10 +120,11 @@ class TimelineTranslations {
final String allowComments; final String allowComments;
final String allowCommentsDescription; final String allowCommentsDescription;
final String checkPost; final String checkPost;
final String postAt;
final String titleHintText; final String titleHintText;
final String contentHintText; final String contentHintText;
final String titleErrorText;
final String contentErrorText;
final String deletePost; final String deletePost;
final String deleteConfirmationTitle; final String deleteConfirmationTitle;
@ -117,7 +134,8 @@ class TimelineTranslations {
final String deleteReaction; final String deleteReaction;
final String viewPost; final String viewPost;
final String likesTitle; final String oneLikeTitle;
final String multipleLikesTitle;
final String commentsTitle; final String commentsTitle;
final String commentsTitleOnPost; final String commentsTitleOnPost;
final String writeComment; final String writeComment;
@ -132,6 +150,13 @@ class TimelineTranslations {
final String postIn; final String postIn;
final String postCreation; 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 yes;
final String no; final String no;
final String timeLineScreenTitle; final String timeLineScreenTitle;
@ -150,7 +175,6 @@ class TimelineTranslations {
String? allowCommentsDescription, String? allowCommentsDescription,
String? commentsTitleOnPost, String? commentsTitleOnPost,
String? checkPost, String? checkPost,
String? postAt,
String? deletePost, String? deletePost,
String? deleteConfirmationTitle, String? deleteConfirmationTitle,
String? deleteConfirmationMessage, String? deleteConfirmationMessage,
@ -158,7 +182,8 @@ class TimelineTranslations {
String? deleteCancelButton, String? deleteCancelButton,
String? deleteReaction, String? deleteReaction,
String? viewPost, String? viewPost,
String? likesTitle, String? oneLikeTitle,
String? multipleLikesTitle,
String? commentsTitle, String? commentsTitle,
String? writeComment, String? writeComment,
String? firstComment, String? firstComment,
@ -173,6 +198,14 @@ class TimelineTranslations {
String? yes, String? yes,
String? no, String? no,
String? timeLineScreenTitle, String? timeLineScreenTitle,
String? createCategoryPopuptitle,
String? addCategoryTitle,
String? addCategorySubmitButton,
String? addCategoryCancelButtton,
String? addCategoryHintText,
String? addCategoryErrorText,
String? titleErrorText,
String? contentErrorText,
}) => }) =>
TimelineTranslations( TimelineTranslations(
noPosts: noPosts ?? this.noPosts, noPosts: noPosts ?? this.noPosts,
@ -189,7 +222,6 @@ class TimelineTranslations {
allowCommentsDescription ?? this.allowCommentsDescription, allowCommentsDescription ?? this.allowCommentsDescription,
commentsTitleOnPost: commentsTitleOnPost ?? this.commentsTitleOnPost, commentsTitleOnPost: commentsTitleOnPost ?? this.commentsTitleOnPost,
checkPost: checkPost ?? this.checkPost, checkPost: checkPost ?? this.checkPost,
postAt: postAt ?? this.postAt,
deletePost: deletePost ?? this.deletePost, deletePost: deletePost ?? this.deletePost,
deleteConfirmationTitle: deleteConfirmationTitle:
deleteConfirmationTitle ?? this.deleteConfirmationTitle, deleteConfirmationTitle ?? this.deleteConfirmationTitle,
@ -199,7 +231,8 @@ class TimelineTranslations {
deleteCancelButton: deleteCancelButton ?? this.deleteCancelButton, deleteCancelButton: deleteCancelButton ?? this.deleteCancelButton,
deleteReaction: deleteReaction ?? this.deleteReaction, deleteReaction: deleteReaction ?? this.deleteReaction,
viewPost: viewPost ?? this.viewPost, viewPost: viewPost ?? this.viewPost,
likesTitle: likesTitle ?? this.likesTitle, oneLikeTitle: oneLikeTitle ?? this.oneLikeTitle,
multipleLikesTitle: multipleLikesTitle ?? this.multipleLikesTitle,
commentsTitle: commentsTitle ?? this.commentsTitle, commentsTitle: commentsTitle ?? this.commentsTitle,
writeComment: writeComment ?? this.writeComment, writeComment: writeComment ?? this.writeComment,
firstComment: firstComment ?? this.firstComment, firstComment: firstComment ?? this.firstComment,
@ -215,5 +248,16 @@ class TimelineTranslations {
yes: yes ?? this.yes, yes: yes ?? this.yes,
no: no ?? this.no, no: no ?? this.no,
timeLineScreenTitle: timeLineScreenTitle ?? this.timeLineScreenTitle, 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_interface/flutter_timeline_interface.dart';
import 'package:flutter_timeline_view/flutter_timeline_view.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/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 { class TimelinePostCreationScreen extends StatefulWidget {
const TimelinePostCreationScreen({ const TimelinePostCreationScreen({
@ -51,52 +53,32 @@ class _TimelinePostCreationScreenState
TextEditingController titleController = TextEditingController(); TextEditingController titleController = TextEditingController();
TextEditingController contentController = TextEditingController(); TextEditingController contentController = TextEditingController();
Uint8List? image; Uint8List? image;
bool editingDone = false;
bool allowComments = false; bool allowComments = false;
bool titleIsValid = false;
bool contentIsValid = false;
@override @override
void initState() { void initState() {
titleController.addListener(_listenForInputs);
contentController.addListener(_listenForInputs);
super.initState(); super.initState();
titleController.addListener(checkIfEditingDone);
contentController.addListener(checkIfEditingDone);
} }
@override void _listenForInputs() {
void dispose() { titleIsValid = titleController.text.isNotEmpty;
titleController.dispose(); contentIsValid = contentController.text.isNotEmpty;
contentController.dispose(); setState(() {});
super.dispose();
} }
void checkIfEditingDone() { var formkey = GlobalKey<FormState>();
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!;
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var imageRequired = widget.options.requireImageForPost;
Future<void> onPostCreated() async { Future<void> onPostCreated() async {
var user = await widget.service.userService?.getUser(widget.userId);
var post = TimelinePost( var post = TimelinePost(
id: 'Post${Random().nextInt(1000)}', id: 'Post${Random().nextInt(1000)}',
creatorId: widget.userId, creatorId: widget.userId,
@ -109,6 +91,7 @@ class _TimelinePostCreationScreenState
createdAt: DateTime.now(), createdAt: DateTime.now(),
reactionEnabled: allowComments, reactionEnabled: allowComments,
image: image, image: image,
creator: user,
); );
if (widget.enablePostOverviewScreen) { if (widget.enablePostOverviewScreen) {
@ -119,234 +102,291 @@ class _TimelinePostCreationScreenState
} }
var theme = Theme.of(context); var theme = Theme.of(context);
return GestureDetector( return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(), onTap: () => FocusScope.of(context).unfocus(),
child: Padding( child: SingleChildScrollView(
padding: widget.options.paddings.mainPadding, child: Padding(
child: SingleChildScrollView( padding: widget.options.paddings.mainPadding,
child: Column( child: Form(
mainAxisSize: MainAxisSize.max, key: formkey,
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ mainAxisSize: MainAxisSize.max,
Text( crossAxisAlignment: CrossAxisAlignment.start,
widget.options.translations.title, children: [
style: const TextStyle( Text(
fontWeight: FontWeight.w800, widget.options.translations.title,
fontSize: 20, style: theme.textTheme.titleMedium,
), ),
), const SizedBox(
widget.options.textInputBuilder?.call( height: 4,
titleController,
null,
'',
) ??
TextField(
maxLength: widget.options.maxTitleLength,
controller: titleController,
decoration: widget.options.contentInputDecoration ??
InputDecoration(
hintText: widget.options.translations.titleHintText,
),
),
const SizedBox(height: 16),
Text(
widget.options.translations.content,
style: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 20,
), ),
), widget.options.textInputBuilder?.call(
const SizedBox(height: 4), titleController,
Text( null,
widget.options.translations.contentDescription, '',
style: theme.textTheme.bodyMedium, ) ??
), PostCreationTextfield(
// input field for the content fieldKey: const ValueKey('title'),
TextField( controller: titleController,
controller: contentController, hintText: widget.options.translations.titleHintText,
textCapitalization: TextCapitalization.sentences, textMaxLength: widget.options.maxTitleLength,
expands: false, decoration: widget.options.titleInputDecoration,
maxLines: null, textCapitalization: TextCapitalization.sentences,
minLines: null, expands: null,
decoration: widget.options.contentInputDecoration ?? minLines: null,
InputDecoration( maxLines: 1,
hintText: widget.options.translations.contentHintText, 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),
const SizedBox( Text(
height: 16, widget.options.translations.content,
), style: theme.textTheme.titleMedium,
// input field for the content
Text(
widget.options.translations.uploadImage,
style: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 20,
), ),
), Text(
Text( widget.options.translations.contentDescription,
widget.options.translations.uploadImageDescription, style: theme.textTheme.bodySmall,
style: theme.textTheme.bodyMedium, ),
), const SizedBox(
// image picker field height: 4,
const SizedBox( ),
height: 8, PostCreationTextfield(
), fieldKey: const ValueKey('content'),
Stack( controller: contentController,
children: [ hintText: widget.options.translations.contentHintText,
GestureDetector( textMaxLength: null,
onTap: () async { decoration: widget.options.contentInputDecoration,
// open a dialog to choose between camera and gallery textCapitalization: TextCapitalization.sentences,
var result = await showModalBottomSheet<Uint8List?>( expands: false,
context: context, minLines: null,
builder: (context) => Container( maxLines: null,
padding: const EdgeInsets.all(8.0), validator: (value) {
color: theme.colorScheme.surface, if (value == null || value.isEmpty) {
child: ImagePicker( return widget.options.translations.contentErrorText;
imagePickerConfig: widget.options.imagePickerConfig, }
imagePickerTheme: widget.options.imagePickerTheme, 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) {
if (result != null) { setState(() {
setState(() { image = result;
image = result; });
}); }
} },
checkIfEditingDone(); child: ClipRRect(
}, borderRadius: BorderRadius.circular(8.0),
child: ClipRRect( child: image != null
borderRadius: BorderRadius.circular(8.0), ? Image.memory(
child: image != null image!,
? Image.memory(
image!,
width: double.infinity,
height: 150.0,
fit: BoxFit.cover,
// give it a rounded border
)
: DottedBorder(
dashPattern: const [4, 4],
radius: const Radius.circular(8.0),
color: theme.textTheme.displayMedium?.color ??
Colors.white,
child: const SizedBox(
width: double.infinity, width: double.infinity,
height: 150.0, height: 150.0,
child: Icon( fit: BoxFit.cover,
Icons.image, // give it a rounded border
size: 50, )
: 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 an image is selected, show a delete button if (image != null) ...[
if (image != null) ...[ Positioned(
Positioned( top: 8,
top: 8, right: 8,
right: 8, child: GestureDetector(
child: GestureDetector( onTap: () {
onTap: () { setState(() {
setState(() { image = null;
image = null; });
}); },
checkIfEditingDone(); child: Container(
}, decoration: BoxDecoration(
child: Container( color: Colors.black.withOpacity(0.5),
decoration: BoxDecoration( borderRadius: BorderRadius.circular(8.0),
color: Colors.black.withOpacity(0.5), ),
borderRadius: BorderRadius.circular(8.0), child: const Icon(
), Icons.delete,
child: const Icon( color: Colors.white,
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: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 20,
), ),
), const SizedBox(height: 120),
Text( SafeArea(
widget.options.translations.allowCommentsDescription, bottom: true,
style: theme.textTheme.bodyMedium, child: Align(
), alignment: Alignment.bottomCenter,
Row( child: widget.options.buttonBuilder?.call(
mainAxisAlignment: MainAxisAlignment.start, context,
children: <Widget>[ onPostCreated,
Checkbox( widget.options.translations.checkPost,
activeColor: theme.colorScheme.primary, enabled: formkey.currentState!.validate(),
value: allowComments, ) ??
onChanged: (value) { Padding(
setState(() { padding: const EdgeInsets.symmetric(horizontal: 48),
allowComments = true; child: Row(
}); children: [
}, Expanded(
), child: DefaultFilledButton(
Text(widget.options.translations.yes), onPressed: titleIsValid &&
Checkbox( contentIsValid &&
activeColor: theme.colorScheme.primary, (!imageRequired || image != null)
value: !allowComments, ? () async {
onChanged: (value) { if (formkey.currentState!
setState(() { .validate()) {
allowComments = false; await onPostCreated();
}); await widget.service.postService
}, .fetchPosts(null);
), }
Text(widget.options.translations.no), }
], : null,
), buttonText: widget.enablePostOverviewScreen
const SizedBox(height: 120), ? widget.options.translations.checkPost
: widget
Align( .options.translations.postCreation,
alignment: Alignment.bottomCenter, ),
child: (widget.options.buttonBuilder != null) ),
? widget.options.buttonBuilder!( ],
context,
onPostCreated,
widget.options.translations.checkPost,
enabled: editingDone,
)
: ElevatedButton(
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
theme.colorScheme.primary,
), ),
), ),
onPressed: editingDone ),
? () async { ),
await onPostCreated(); ],
await widget.service.postService ),
.fetchPosts(null);
}
: null,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
widget.enablePostOverviewScreen
? widget.options.translations.checkPost
: widget.options.translations.postCreation,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
),
),
),
],
), ),
), ),
), ),

View file

@ -1,9 +1,7 @@
// ignore_for_file: prefer_expression_function_bodies
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
import 'package:flutter_timeline_view/flutter_timeline_view.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 { class TimelinePostOverviewScreen extends StatelessWidget {
const TimelinePostOverviewScreen({ const TimelinePostOverviewScreen({
@ -20,13 +18,7 @@ class TimelinePostOverviewScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// the timelinePost.category is a key so we need to get the category object var isSubmitted = false;
var timelineCategoryName = options.categoriesOptions.categoriesBuilder
?.call(context)
.firstWhereOrNull((element) => element.key == timelinePost.category)
?.title ??
timelinePost.category;
var buttonText = '${options.translations.postIn} $timelineCategoryName';
return Column( return Column(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
children: [ children: [
@ -43,39 +35,43 @@ class TimelinePostOverviewScreen extends StatelessWidget {
options.postOverviewButtonBuilder?.call( options.postOverviewButtonBuilder?.call(
context, context,
() { () {
if (isSubmitted) return;
isSubmitted = true;
onPostSubmit(timelinePost); onPostSubmit(timelinePost);
}, },
buttonText, options.translations.postIn,
timelinePost,
) ?? ) ??
options.buttonBuilder?.call( options.buttonBuilder?.call(
context, context,
() { () {
if (isSubmitted) return;
isSubmitted = true;
onPostSubmit(timelinePost); onPostSubmit(timelinePost);
}, },
buttonText, options.translations.postIn,
enabled: true, enabled: true,
) ?? ) ??
ElevatedButton( SafeArea(
style: ButtonStyle( bottom: true,
backgroundColor:
WidgetStatePropertyAll(Theme.of(context).primaryColor),
),
onPressed: () {
onPostSubmit(timelinePost);
},
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.symmetric(horizontal: 80),
child: Text( child: Row(
buttonText, children: [
style: const TextStyle( Expanded(
color: Colors.white, child: DefaultFilledButton(
fontSize: 20, onPressed: () async {
fontWeight: FontWeight.w800, if (isSubmitted) return;
), isSubmitted = true;
onPostSubmit(timelinePost);
},
buttonText: options.translations.postIn,
),
),
],
), ),
), ),
), ),
SizedBox(height: options.paddings.postOverviewButtonBottomPadding),
], ],
); );
} }

View file

@ -3,12 +3,10 @@
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
import 'dart:async'; import 'dart:async';
import 'dart:typed_data';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_svg/svg.dart';
import 'package:flutter_image_picker/flutter_image_picker.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
import 'package:flutter_timeline_view/src/config/timeline_options.dart'; import 'package:flutter_timeline_view/src/config/timeline_options.dart';
import 'package:flutter_timeline_view/src/widgets/reaction_bottom.dart'; import 'package:flutter_timeline_view/src/widgets/reaction_bottom.dart';
@ -60,20 +58,6 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
TimelinePost? post; TimelinePost? post;
bool isLoading = true; bool isLoading = true;
late var textInputBuilder = widget.options.textInputBuilder ??
(controller, suffixIcon, hintText) => TextField(
textCapitalization: TextCapitalization.sentences,
controller: controller,
decoration: InputDecoration(
hintText: hintText,
suffixIcon: suffixIcon,
border: OutlineInputBorder(
borderRadius:
BorderRadius.circular(20.0), // Adjust the value as needed
),
),
);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -90,8 +74,7 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
post = loadedPost; post = loadedPost;
isLoading = false; isLoading = false;
}); });
} on Exception catch (e) { } on Exception catch (_) {
debugPrint('Error loading post: $e');
setState(() { setState(() {
isLoading = false; isLoading = false;
}); });
@ -108,9 +91,10 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
var theme = Theme.of(context); var theme = Theme.of(context);
var dateFormat = widget.options.dateFormat ?? var dateFormat = widget.options.dateFormat ??
DateFormat('dd/MM/yyyy', Localizations.localeOf(context).languageCode); DateFormat(
var timeFormat = widget.options.timeFormat ?? DateFormat('HH:mm'); "dd/MM/yyyy 'at' HH:mm",
Localizations.localeOf(context).languageCode,
);
if (isLoading) { if (isLoading) {
return const Center( return const Center(
child: CircularProgressIndicator.adaptive(), child: CircularProgressIndicator.adaptive(),
@ -130,6 +114,45 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
? a.createdAt.compareTo(b.createdAt) ? a.createdAt.compareTo(b.createdAt)
: b.createdAt.compareTo(a.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( return Stack(
children: [ children: [
@ -145,7 +168,7 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
}, },
child: SingleChildScrollView( child: SingleChildScrollView(
child: Padding( child: Padding(
padding: widget.options.paddings.mainPadding, padding: widget.options.paddings.postPadding,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -191,7 +214,9 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
widget.options.translations.anonymousUser, widget.options.translations.anonymousUser,
style: widget.options.theme.textStyles style: widget.options.theme.textStyles
.postCreatorTitleStyle ?? .postCreatorTitleStyle ??
theme.textTheme.titleMedium, theme.textTheme.titleSmall!.copyWith(
color: Colors.black,
),
), ),
], ],
), ),
@ -199,7 +224,7 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
const Spacer(), const Spacer(),
if (!(widget.isOverviewScreen ?? false) && if (!(widget.isOverviewScreen ?? false) &&
(widget.allowAllDeletion || (widget.allowAllDeletion ||
post.creator?.userId == widget.userId)) post.creator?.userId == widget.userId)) ...[
PopupMenuButton( PopupMenuButton(
onSelected: (value) async { onSelected: (value) async {
if (value == 'delete') { if (value == 'delete') {
@ -238,6 +263,7 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
color: widget.options.theme.iconColor, color: widget.options.theme.iconColor,
), ),
), ),
],
], ],
), ),
// image of the posts // image of the posts
@ -295,72 +321,70 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
// post information // post information
Row( Row(
children: [ children: [
if (post.likedBy?.contains(widget.userId) ?? false) ...[ IconButton(
InkWell( padding: EdgeInsets.zero,
onTap: () async { constraints: const BoxConstraints(),
onPressed: () async {
if (widget.isOverviewScreen ?? false) return;
if (isLikedByUser) {
updatePost( updatePost(
await widget.service.postService.unlikePost( await widget.service.postService.unlikePost(
widget.userId, widget.userId,
post, post,
), ),
); );
}, setState(() {});
child: Container( } else {
color: Colors.transparent,
child: widget.options.theme.likedIcon ??
Icon(
widget.post.likedBy
?.contains(widget.userId) ??
false
? Icons.favorite_rounded
: Icons.favorite_outline_outlined,
),
),
),
] else ...[
InkWell(
onTap: () async {
if (widget.isOverviewScreen ?? false) return;
updatePost( updatePost(
await widget.service.postService.likePost( await widget.service.postService.likePost(
widget.userId, widget.userId,
post, post,
), ),
); );
}, setState(() {});
child: Container( }
color: Colors.transparent, },
child: widget.options.theme.likeIcon ?? icon: isLikedByUser
? widget.options.theme.likedIcon ??
Icon( Icon(
widget.post.likedBy Icons.favorite_rounded,
?.contains(widget.userId) ?? color: widget.options.theme.iconColor,
false size: widget.options.iconSize,
? Icons.favorite_rounded )
: Icons.favorite_outline_outlined, : widget.options.theme.likeIcon ??
Icon(
Icons.favorite_outline_outlined,
color: widget.options.theme.iconColor,
size: widget.options.iconSize, size: widget.options.iconSize,
), ),
), ),
),
],
const SizedBox(width: 8), const SizedBox(width: 8),
if (post.reactionEnabled) if (post.reactionEnabled)
widget.options.theme.commentIcon ?? widget.options.theme.commentIcon ??
Icon( SvgPicture.asset(
Icons.chat_bubble_outline_rounded, 'assets/Comment.svg',
package: 'flutter_timeline_view',
// ignore: deprecated_member_use
color: widget.options.theme.iconColor, color: widget.options.theme.iconColor,
size: widget.options.iconSize, width: widget.options.iconSize,
height: widget.options.iconSize,
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( // ignore: avoid_bool_literals_in_conditional_expressions
'${post.likes} ${widget.options.translations.likesTitle}', if (widget.isOverviewScreen != null
style: widget ? !widget.isOverviewScreen!
.options.theme.textStyles.postLikeTitleAndAmount ?? : false) ...[
theme.textTheme.titleSmall Text(
?.copyWith(color: Colors.black), // ignore: lines_longer_than_80_chars
), '${post.likes} ${post.likes > 1 ? widget.options.translations.multipleLikesTitle : widget.options.translations.oneLikeTitle}',
const SizedBox(height: 4), style: widget.options.theme.textStyles
.postLikeTitleAndAmount ??
theme.textTheme.titleSmall
?.copyWith(color: Colors.black),
),
],
Text.rich( Text.rich(
TextSpan( TextSpan(
text: widget.options.nameBuilder?.call(post.creator) ?? text: widget.options.nameBuilder?.call(post.creator) ??
@ -368,62 +392,42 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
widget.options.translations.anonymousUser, widget.options.translations.anonymousUser,
style: widget style: widget
.options.theme.textStyles.postCreatorNameStyle ?? .options.theme.textStyles.postCreatorNameStyle ??
theme.textTheme.titleSmall, theme.textTheme.titleSmall!
.copyWith(color: Colors.black),
children: [ children: [
const TextSpan(text: ' '),
TextSpan( TextSpan(
text: post.title, text: post.title,
style: style:
widget.options.theme.textStyles.postTitleStyle ?? widget.options.theme.textStyles.postTitleStyle ??
theme.textTheme.bodyMedium, theme.textTheme.bodySmall,
), ),
], ],
), ),
), ),
const SizedBox(height: 20), 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( Text(
'${dateFormat.format(post.createdAt)} ' post.content,
'${widget.options.translations.postAt} '
'${timeFormat.format(post.createdAt)}',
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
), ),
const SizedBox(height: 20), Text(
if (post.reactionEnabled) ...[ '${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( Text(
widget.options.translations.commentsTitleOnPost, widget.options.translations.commentsTitleOnPost,
style: theme.textTheme.titleMedium, style: theme.textTheme.titleSmall!
.copyWith(color: Colors.black),
), ),
for (var reaction for (var reaction
in post.reactions ?? <TimelinePostReaction>[]) ...[ in post.reactions ?? <TimelinePostReaction>[]) ...[
const SizedBox(height: 16), const SizedBox(height: 4),
GestureDetector( GestureDetector(
onLongPressStart: (details) async { onLongPressStart: (details) async {
if (reaction.creatorId == widget.userId || if (reaction.creatorId == widget.userId ||
@ -461,15 +465,13 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
} }
}, },
child: Row( child: Row(
crossAxisAlignment: reaction.imageUrl != null crossAxisAlignment: CrossAxisAlignment.start,
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
children: [ children: [
if (reaction.creator?.imageUrl != null && if (reaction.creator?.imageUrl != null &&
reaction.creator!.imageUrl!.isNotEmpty) ...[ reaction.creator!.imageUrl!.isNotEmpty) ...[
widget.options.userAvatarBuilder?.call( widget.options.userAvatarBuilder?.call(
reaction.creator!, reaction.creator!,
28, 14,
) ?? ) ??
CircleAvatar( CircleAvatar(
radius: 14, radius: 14,
@ -480,7 +482,7 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
] else ...[ ] else ...[
widget.options.anonymousAvatarBuilder?.call( widget.options.anonymousAvatarBuilder?.call(
reaction.creator!, reaction.creator!,
28, 14,
) ?? ) ??
const CircleAvatar( const CircleAvatar(
radius: 14, radius: 14,
@ -501,7 +503,8 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
reaction.creator?.fullName ?? reaction.creator?.fullName ??
widget.options.translations widget.options.translations
.anonymousUser, .anonymousUser,
style: theme.textTheme.titleSmall, style: theme.textTheme.titleSmall!
.copyWith(color: Colors.black),
), ),
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
@ -522,27 +525,91 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
reaction.creator?.fullName ?? reaction.creator?.fullName ??
widget widget
.options.translations.anonymousUser, .options.translations.anonymousUser,
style: theme.textTheme.titleSmall, style: theme.textTheme.titleSmall!
.copyWith(color: Colors.black),
children: [ children: [
const TextSpan(text: ' '), const TextSpan(text: ' '),
TextSpan( TextSpan(
text: reaction.reaction ?? '', 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 // 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) ...[ if (post.reactions?.isEmpty ?? true) ...[
const SizedBox(height: 16),
Text( Text(
widget.options.translations.firstComment, widget.options.translations.firstComment,
style: theme.textTheme.bodySmall,
), ),
], ],
const SizedBox(height: 120), const SizedBox(height: 120),
@ -555,50 +622,71 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
if (post.reactionEnabled && !(widget.isOverviewScreen ?? false)) if (post.reactionEnabled && !(widget.isOverviewScreen ?? false))
Align( Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: ReactionBottom( child: Container(
messageInputBuilder: textInputBuilder, color: theme.scaffoldBackgroundColor,
onPressSelectImage: () async { constraints: BoxConstraints(
// open the image picker maxWidth: MediaQuery.of(context).size.width,
var result = await showModalBottomSheet<Uint8List?>( ),
context: context, child: SafeArea(
builder: (context) => Container( bottom: true,
padding: const EdgeInsets.all(8.0), child: Row(
color: theme.colorScheme.surface, crossAxisAlignment: CrossAxisAlignment.center,
child: ImagePicker( mainAxisSize: MainAxisSize.min,
imagePickerConfig: widget.options.imagePickerConfig, children: [
imagePickerTheme: widget.options.imagePickerTheme, 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,
),
),
), ),
), Flexible(
); child: Padding(
if (result != null) { padding: const EdgeInsets.only(
updatePost( left: 8,
await widget.service.postService.reactToPost( right: 16,
post, top: 8,
TimelinePostReaction( bottom: 8,
id: '', ),
postId: post.id, child: ReactionBottom(
creatorId: widget.userId, messageInputBuilder: textInputBuilder,
createdAt: DateTime.now(), 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

@ -93,7 +93,7 @@ class _TimelineScreenState extends State<TimelineScreen> {
void _updateIsOnTop() { void _updateIsOnTop() {
setState(() { setState(() {
_isOnTop = controller.position.pixels < 40; _isOnTop = controller.position.pixels < 0.1;
}); });
} }
@ -157,6 +157,8 @@ class _TimelineScreenState extends State<TimelineScreen> {
); );
} }
var categories = widget.service.postService.categories;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -224,11 +226,16 @@ class _TimelineScreenState extends State<TimelineScreen> {
), ),
], ],
CategorySelector( CategorySelector(
categories: categories,
isOnTop: _isOnTop, isOnTop: _isOnTop,
filter: category, filter: category,
options: widget.options, options: widget.options,
onTapCategory: (categoryKey) { onTapCategory: (categoryKey) {
setState(() { setState(() {
service.postService.selectedCategory =
categories.firstWhereOrNull(
(element) => element.key == categoryKey,
);
category = categoryKey; category = categoryKey;
}); });
}, },
@ -313,14 +320,14 @@ class _TimelineScreenState extends State<TimelineScreen> {
), ),
), ),
), ),
SizedBox(
height: widget.options.paddings.mainPadding.bottom,
),
], ],
), ),
), ),
), ),
), ),
SizedBox(
height: widget.options.paddings.mainPadding.bottom,
),
], ],
); );
}, },
@ -330,6 +337,7 @@ class _TimelineScreenState extends State<TimelineScreen> {
Future<void> loadPosts() async { Future<void> loadPosts() async {
if (widget.posts != null || !context.mounted) return; if (widget.posts != null || !context.mounted) return;
try { try {
await widget.service.postService.fetchCategories();
await widget.service.postService.fetchPosts(category); await widget.service.postService.fetchPosts(category);
setState(() { setState(() {
isLoading = false; isLoading = false;

View file

@ -1,12 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
import 'package:flutter_timeline_view/src/config/timeline_options.dart'; import 'package:flutter_timeline_view/src/config/timeline_options.dart';
import 'package:flutter_timeline_view/src/widgets/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({ const TimelineSelectionScreen({
required this.options, required this.options,
required this.categories, required this.categories,
required this.onCategorySelected, required this.onCategorySelected,
required this.postService,
super.key, super.key,
}); });
@ -16,74 +19,196 @@ class TimelineSelectionScreen extends StatelessWidget {
final Function(TimelineCategory) onCategorySelected; final Function(TimelineCategory) onCategorySelected;
final TimelinePostService postService;
@override
State<TimelineSelectionScreen> createState() =>
_TimelineSelectionScreenState();
}
class _TimelineSelectionScreenState extends State<TimelineSelectionScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var size = MediaQuery.of(context).size; var size = MediaQuery.of(context).size;
var theme = Theme.of(context);
return Padding( return Padding(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: size.width * 0.05, horizontal: size.width * 0.05,
), ),
child: Column( child: SingleChildScrollView(
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Padding( children: [
padding: EdgeInsets.only(top: size.height * 0.05, bottom: 8), Padding(
child: Text( padding: const EdgeInsets.only(top: 20, bottom: 12),
options.translations.timelineSelectionDescription, child: Text(
style: const TextStyle( widget.options.translations.timelineSelectionDescription,
fontWeight: FontWeight.w800, style: theme.textTheme.titleLarge,
fontSize: 20,
), ),
), ),
), for (var category in widget.categories.where(
const SizedBox(height: 4), (element) => element.canCreate && element.key != null,
for (var category in categories.where( )) ...[
(element) => element.canCreate && element.key != null, widget.options.categorySelectorButtonBuilder?.call(
)) ...[ context,
options.categorySelectorButtonBuilder?.call( () {
context, widget.onCategorySelected.call(category);
() { },
onCategorySelected.call(category); category.title,
}, ) ??
category.title, InkWell(
) ?? onTap: () => widget.onCategorySelected.call(category),
InkWell( child: Container(
onTap: () => onCategorySelected.call(category), height: 60,
child: Container( width: double.infinity,
height: 60, decoration: BoxDecoration(
width: double.infinity, borderRadius: BorderRadius.circular(10),
decoration: BoxDecoration( border: Border.all(
borderRadius: BorderRadius.circular(10), color: widget.options.theme
border: Border.all( .categorySelectionButtonBorderColor ??
color: Theme.of(context).primaryColor,
options.theme.categorySelectionButtonBorderColor ?? width: 2,
Theme.of(context).primaryColor, ),
width: 2, color: widget.options.theme
.categorySelectionButtonBackgroundColor,
), ),
color: margin: const EdgeInsets.symmetric(vertical: 4),
options.theme.categorySelectionButtonBackgroundColor, child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
margin: const EdgeInsets.symmetric(vertical: 4), mainAxisAlignment: MainAxisAlignment.center,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, Padding(
mainAxisAlignment: MainAxisAlignment.center, padding:
children: [ const EdgeInsets.symmetric(horizontal: 12.0),
Padding( child: Text(
padding: const EdgeInsets.symmetric(horizontal: 12.0), category.title,
child: Text( style: theme.textTheme.titleMedium,
category.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
), ),
), ),
), ],
], ),
),
),
],
InkWell(
onTap: showCategoryPopup,
child: Container(
height: 60,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: widget
.options.theme.categorySelectionButtonBorderColor ??
const Color(0xFF9E9E9E),
width: 2,
),
color: widget
.options.theme.categorySelectionButtonBackgroundColor,
),
margin: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
children: [
Icon(
Icons.add,
color: theme.textTheme.titleMedium?.color!
.withOpacity(0.5),
),
const SizedBox(width: 8),
Text(
widget.options.translations.addCategoryTitle,
style: theme.textTheme.titleMedium!.copyWith(
color: theme.textTheme.titleMedium?.color!
.withOpacity(0.5),
),
),
],
),
),
],
),
),
),
],
),
),
);
}
Future<void> showCategoryPopup() async {
var theme = Theme.of(context);
var controller = TextEditingController();
await showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: theme.scaffoldBackgroundColor,
insetPadding: const EdgeInsets.symmetric(
horizontal: 16,
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 50, vertical: 24),
titlePadding: const EdgeInsets.only(left: 44, right: 44, top: 32),
title: Text(
widget.options.translations.createCategoryPopuptitle,
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
PostCreationTextfield(
controller: controller,
hintText: widget.options.translations.addCategoryHintText,
validator: (p0) => p0!.isEmpty
? widget.options.translations.addCategoryErrorText
: null,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: DefaultFilledButton(
onPressed: () async {
if (controller.text.isEmpty) return;
await widget.postService.addCategory(
TimelineCategory(
key: controller.text,
title: controller.text,
),
);
setState(() {});
if (context.mounted) Navigator.pop(context);
},
buttonText:
widget.options.translations.addCategorySubmitButton,
), ),
), ),
), ),
],
),
TextButton(
onPressed: () {
Navigator.of(context).pop(false);
},
child: Text(
widget.options.translations.addCategoryCancelButtton,
style: theme.textTheme.bodyMedium!.copyWith(
decoration: TextDecoration.underline,
color: theme.textTheme.bodyMedium?.color!.withOpacity(0.5),
),
),
),
], ],
], ),
), ),
); );
} }

View file

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

View file

@ -23,7 +23,8 @@ class CategorySelectorButton extends StatelessWidget {
var theme = Theme.of(context); var theme = Theme.of(context);
var size = MediaQuery.of(context).size; var size = MediaQuery.of(context).size;
return SizedBox( return AnimatedContainer(
duration: const Duration(milliseconds: 100),
height: isOnTop ? 140 : 40, height: isOnTop ? 140 : 40,
child: TextButton( child: TextButton(
onPressed: onTap, onPressed: onTap,
@ -81,11 +82,13 @@ class CategorySelectorButton extends StatelessWidget {
Flexible( Flexible(
child: Row( child: Row(
children: [ children: [
category.icon, if (category.icon != null) ...[
SizedBox( category.icon!,
width: SizedBox(
options.paddings.categoryButtonTextPadding ?? 8, width:
), options.paddings.categoryButtonTextPadding ?? 8,
),
],
Expanded( Expanded(
child: _CategoryButtonText( child: _CategoryButtonText(
category: category, category: category,
@ -124,7 +127,9 @@ class _CategoryButtonText extends StatelessWidget {
Widget build(BuildContext context) => Text( Widget build(BuildContext context) => Text(
category.title, category.title,
style: (options.theme.textStyles.categoryTitleStyle ?? style: (options.theme.textStyles.categoryTitleStyle ??
theme.textTheme.labelLarge) (selected
? theme.textTheme.titleMedium
: theme.textTheme.bodyMedium))
?.copyWith( ?.copyWith(
color: selected color: selected
? options.theme.categorySelectionButtonSelectedTextColor ?? ? options.theme.categorySelectionButtonSelectedTextColor ??

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 // SPDX-License-Identifier: BSD-3-Clause
import 'package:flutter/material.dart'; 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_options.dart';
import 'package:flutter_timeline_view/src/config/timeline_translations.dart'; import 'package:flutter_timeline_view/src/config/timeline_translations.dart';
@ -11,14 +12,12 @@ class ReactionBottom extends StatefulWidget {
required this.onReactionSubmit, required this.onReactionSubmit,
required this.messageInputBuilder, required this.messageInputBuilder,
required this.translations, required this.translations,
this.onPressSelectImage,
this.iconColor, this.iconColor,
super.key, super.key,
}); });
final Future<void> Function(String text) onReactionSubmit; final Future<void> Function(String text) onReactionSubmit;
final TextInputBuilder messageInputBuilder; final TextInputBuilder messageInputBuilder;
final VoidCallback? onPressSelectImage;
final TimelineTranslations translations; final TimelineTranslations translations;
final Color? iconColor; final Color? iconColor;
@ -30,56 +29,30 @@ class _ReactionBottomState extends State<ReactionBottom> {
final TextEditingController _textEditingController = TextEditingController(); final TextEditingController _textEditingController = TextEditingController();
@override @override
Widget build(BuildContext context) => SafeArea( Widget build(BuildContext context) => Container(
bottom: true, child: widget.messageInputBuilder(
child: Container( _textEditingController,
color: Theme.of(context).colorScheme.surface, Padding(
child: Container( padding: const EdgeInsets.symmetric(
margin: const EdgeInsets.symmetric( horizontal: 8,
horizontal: 12,
vertical: 8,
), ),
height: 48, child: IconButton(
child: widget.messageInputBuilder( onPressed: () async {
_textEditingController, var value = _textEditingController.text;
Padding( if (value.isNotEmpty) {
padding: const EdgeInsets.symmetric( await widget.onReactionSubmit(value);
horizontal: 4, _textEditingController.clear();
), }
child: Row( },
mainAxisSize: MainAxisSize.min, icon: SvgPicture.asset(
children: [ 'assets/send.svg',
if (widget.onPressSelectImage != null) ...[ package: 'flutter_timeline_view',
IconButton( // ignore: deprecated_member_use
onPressed: () async { color: widget.iconColor,
_textEditingController.clear();
widget.onPressSelectImage?.call();
},
icon: Icon(
Icons.image,
color: widget.iconColor,
),
),
],
IconButton(
onPressed: () async {
var value = _textEditingController.text;
if (value.isNotEmpty) {
await widget.onReactionSubmit(value);
_textEditingController.clear();
}
},
icon: Icon(
Icons.send,
color: widget.iconColor,
),
),
],
),
), ),
widget.translations.writeComment,
), ),
), ),
widget.translations.writeComment,
), ),
); );
} }

View file

@ -141,6 +141,7 @@ class _HeartAnimationState extends State<HeartAnimation> {
unawaited( unawaited(
Future.delayed(const Duration(milliseconds: 100)).then((value) async { Future.delayed(const Duration(milliseconds: 100)).then((value) async {
active = widget.liked; active = widget.liked;
// ignore: use_build_context_synchronously
var navigator = Navigator.of(context); var navigator = Navigator.of(context);
await Future.delayed(widget.duration); await Future.delayed(widget.duration);
navigator.pop(); navigator.pop();

View file

@ -4,8 +4,10 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.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_interface/flutter_timeline_interface.dart';
import 'package:flutter_timeline_view/src/config/timeline_options.dart'; import 'package:flutter_timeline_view/src/config/timeline_options.dart';
import 'package:flutter_timeline_view/src/widgets/default_filled_button.dart';
import 'package:flutter_timeline_view/src/widgets/tappable_image.dart'; import 'package:flutter_timeline_view/src/widgets/tappable_image.dart';
class TimelinePostWidget extends StatefulWidget { class TimelinePostWidget extends StatefulWidget {
@ -52,324 +54,387 @@ class _TimelinePostWidgetState extends State<TimelinePostWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var theme = Theme.of(context); var theme = Theme.of(context);
return InkWell( var isLikedByUser = widget.post.likedBy?.contains(widget.userId) ?? false;
onTap: widget.onTap,
child: SizedBox( return SizedBox(
height: widget.post.imageUrl != null || widget.post.image != null height: widget.post.imageUrl != null || widget.post.image != null
? widget.options.postWidgetHeight ? widget.options.postWidgetHeight
: null, : null,
width: double.infinity, width: double.infinity,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
if (widget.post.creator != null) if (widget.post.creator != null) ...[
InkWell( InkWell(
onTap: widget.onUserTap != null onTap: widget.onUserTap != null
? () => ? () =>
widget.onUserTap?.call(widget.post.creator!.userId) widget.onUserTap?.call(widget.post.creator!.userId)
: null, : null,
child: Row( child: Row(
children: [ children: [
if (widget.post.creator!.imageUrl != null) ...[ if (widget.post.creator!.imageUrl != null) ...[
widget.options.userAvatarBuilder?.call( widget.options.userAvatarBuilder?.call(
widget.post.creator!, widget.post.creator!,
28, 28,
) ?? ) ??
CircleAvatar( CircleAvatar(
radius: 14, radius: 14,
backgroundImage: CachedNetworkImageProvider( backgroundImage: CachedNetworkImageProvider(
widget.post.creator!.imageUrl!, widget.post.creator!.imageUrl!,
),
), ),
] else ...[
widget.options.anonymousAvatarBuilder?.call(
widget.post.creator!,
28,
) ??
const CircleAvatar(
radius: 14,
child: Icon(
Icons.person,
),
),
],
const SizedBox(width: 10),
Text(
widget.options.nameBuilder
?.call(widget.post.creator) ??
widget.post.creator?.fullName ??
widget.options.translations.anonymousUser,
style: widget.options.theme.textStyles
.postCreatorTitleStyle ??
theme.textTheme.titleMedium,
),
],
),
),
const Spacer(),
if (widget.allowAllDeletion ||
widget.post.creator?.userId == widget.userId)
PopupMenuButton(
onSelected: (value) async {
if (value == 'delete') {
await showPostDeletionConfirmationDialog(
widget.options,
context,
widget.onPostDelete,
);
}
},
itemBuilder: (BuildContext context) =>
<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), ] else ...[
widget.options.theme.deleteIcon ?? widget.options.anonymousAvatarBuilder?.call(
Icon( widget.post.creator!,
Icons.delete, 28,
color: widget.options.theme.iconColor, ) ??
), 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, 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 ] else ...[
if (widget.post.imageUrl != null || widget.post.image != null) ...[ Row(
const SizedBox(height: 8), children: [
Flexible( IconButton(
flex: widget.options.postWidgetHeight != null ? 1 : 0, padding: EdgeInsets.zero,
child: ClipRRect( constraints: const BoxConstraints(),
borderRadius: const BorderRadius.all(Radius.circular(8)), onPressed:
child: widget.options.doubleTapTolike isLikedByUser ? widget.onTapUnlike : widget.onTapLike,
? TappableImage( icon: (isLikedByUser
likeAndDislikeIcon: ? widget.options.theme.likedIcon
widget.options.likeAndDislikeIconsForDoubleTap, : widget.options.theme.likeIcon) ??
post: widget.post, Icon(
userId: widget.userId, isLikedByUser
onLike: ({required bool liked}) async { ? Icons.favorite_rounded
var userId = widget.userId; : Icons.favorite_outline,
color: widget.options.theme.iconColor,
late TimelinePost result; size: widget.options.iconSize,
),
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(width: 8),
], if (widget.post.reactionEnabled) ...[
const SizedBox( IconButton(
height: 8, padding: EdgeInsets.zero,
), constraints: const BoxConstraints(),
// post information onPressed: widget.onTap,
if (widget.options.iconsWithValues) icon: widget.options.theme.commentIcon ??
Row( SvgPicture.asset(
children: [ 'assets/Comment.svg',
TextButton.icon( package: 'flutter_timeline_view',
onPressed: () async { // ignore: deprecated_member_use
var userId = widget.userId; color: widget.options.theme.iconColor,
width: widget.options.iconSize,
var liked = height: widget.options.iconSize,
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,
), ),
label: Text('${widget.post.likes}'),
), ),
if (widget.post.reactionEnabled)
TextButton.icon(
onPressed: widget.onTap,
icon: widget.options.theme.commentIcon ??
const Icon(
Icons.chat_bubble_outline_outlined,
),
label: Text('${widget.post.reaction}'),
),
], ],
) ],
else
Row(
children: [
if (widget.post.likedBy?.contains(widget.userId) ??
false) ...[
InkWell(
onTap: widget.onTapUnlike,
child: Container(
color: Colors.transparent,
child: widget.options.theme.likedIcon ??
Icon(
Icons.favorite_rounded,
color: widget.options.theme.iconColor,
size: widget.options.iconSize,
),
),
),
] else ...[
InkWell(
onTap: widget.onTapLike,
child: Container(
color: Colors.transparent,
child: widget.options.theme.likeIcon ??
Icon(
Icons.favorite_outline,
color: widget.options.theme.iconColor,
size: widget.options.iconSize,
),
),
),
],
const SizedBox(width: 8),
if (widget.post.reactionEnabled) ...[
Container(
color: Colors.transparent,
child: widget.options.theme.commentIcon ??
Icon(
Icons.chat_bubble_outline_rounded,
color: widget.options.theme.iconColor,
size: widget.options.iconSize,
),
),
],
],
),
const SizedBox(
height: 8,
), ),
],
if (widget.options.itemInfoBuilder != null) ...[ const SizedBox(
widget.options.itemInfoBuilder!( height: 8,
post: widget.post, ),
),
] else ...[ if (widget.options.itemInfoBuilder != null) ...[
Text( widget.options.itemInfoBuilder!(
'${widget.post.likes} ' post: widget.post,
'${widget.options.translations.likesTitle}', ),
style: widget ] else ...[
.options.theme.textStyles.listPostLikeTitleAndAmount ?? _PostLikeCountText(
theme.textTheme.titleSmall, post: widget.post,
), options: widget.options,
const SizedBox(height: 4), ),
Text.rich( Text.rich(
TextSpan( TextSpan(
text: widget.options.nameBuilder?.call(widget.post.creator) ?? text: widget.options.nameBuilder?.call(widget.post.creator) ??
widget.post.creator?.fullName ?? widget.post.creator?.fullName ??
widget.options.translations.anonymousUser, widget.options.translations.anonymousUser,
style: widget.options.theme.textStyles.listCreatorNameStyle ?? style: widget.options.theme.textStyles.listCreatorNameStyle ??
theme.textTheme.titleSmall, theme.textTheme.titleSmall!.copyWith(
children: [ color: Colors.black,
const TextSpan(text: ' '),
TextSpan(
text: widget.post.title,
style:
widget.options.theme.textStyles.listPostTitleStyle ??
theme.textTheme.bodyMedium,
), ),
], 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, widget.options.translations.viewPost,
style: widget.options.theme.textStyles.viewPostStyle ?? 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( Future<void> showPostDeletionConfirmationDialog(
TimelineOptions options, TimelineOptions options,
BuildContext context, BuildContext context,
Function() onPostDelete, Function() onPostDelete,
) async { ) async {
var theme = Theme.of(context);
var result = await showDialog( var result = await showDialog(
context: context, context: context,
builder: (BuildContext context) => builder: (BuildContext context) =>
options.deletionDialogBuilder?.call(context) ?? options.deletionDialogBuilder?.call(context) ??
AlertDialog( AlertDialog(
title: Text(options.translations.deleteConfirmationTitle), insetPadding: const EdgeInsets.symmetric(
content: Text(options.translations.deleteConfirmationMessage), horizontal: 16,
actions: <Widget>[ ),
TextButton( contentPadding:
onPressed: () { const EdgeInsets.symmetric(horizontal: 64, vertical: 24),
Navigator.of(context).pop(false); titlePadding: const EdgeInsets.only(left: 44, right: 44, top: 32),
}, title: Text(
child: Text(options.translations.deleteCancelButton), options.translations.deleteConfirmationMessage,
), style: theme.textTheme.titleMedium,
TextButton( textAlign: TextAlign.center,
onPressed: () { ),
Navigator.of(context).pop(true); content: Column(
}, mainAxisSize: MainAxisSize.min,
child: Text( children: [
options.translations.deleteButton, 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),
),
),
),
],
),
), ),
); );

View file

@ -4,12 +4,13 @@
name: flutter_timeline_view name: flutter_timeline_view
description: Visual elements of the Flutter Timeline Component description: Visual elements of the Flutter Timeline Component
version: 4.1.2 version: 5.1.1
repository: https://github.com/Iconica-Development/flutter_timeline homepage: https://github.com/Iconica-Development/flutter_timeline
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub
environment: environment:
sdk: '>=3.4.1 <4.0.0' sdk: ">=3.4.3 <4.0.0"
flutter: '>=3.22.2'
dependencies: dependencies:
flutter: flutter:
@ -17,22 +18,21 @@ dependencies:
intl: ^0.19.0 intl: ^0.19.0
cached_network_image: ^3.2.2 cached_network_image: ^3.2.2
dotted_border: ^2.1.0 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: flutter_timeline_interface:
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
version: ^4.1.1 version: ^5.1.1
flutter_image_picker: flutter_image_picker:
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
version: ^1.0.5 version: ^4.0.0
collection: ^1.18.0
dev_dependencies: dev_dependencies:
flutter_lints: ^2.0.0
flutter_iconica_analysis: flutter_iconica_analysis:
git: git:
url: https://github.com/Iconica-Development/flutter_iconica_analysis url: https://github.com/Iconica-Development/flutter_iconica_analysis
ref: 6.0.0 ref: 6.0.0
flutter: flutter:
assets:
- assets/

View file

@ -5,7 +5,6 @@
name: flutter_timeline_workspace name: flutter_timeline_workspace
environment: environment:
sdk: '>=3.1.3 <4.0.0' sdk: '>=3.4.3 <4.0.0'
dev_dependencies: dev_dependencies:
melos: ^3.0.1 melos: ^3.0.1