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

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

133
README.md
View file

@ -1,5 +1,6 @@
# Flutter Timeline
Flutter Timeline is a package which shows a list posts by a user. This package also has additional features like liking a post and leaving comments. Default this package adds support for a Firebase back-end. You can add your custom back-end (like a Websocket-API) by extending the `CommunityChatInterface` interface from the `flutter_community_chat_interface` package.
![Flutter Timeline GIF](example.gif)
@ -23,7 +24,137 @@ If you are going to use Firebase as the back-end of the Timeline, you should als
```
## How to use
To use the module within your Flutter-application you should add the following code to the build-method of a chosen widget.
To use the module within your Flutter-application with predefined `Go_router` routes you should add the following:
Add go_router as dependency to your project.
Add the following configuration to your flutter_application:
```
List<GoRoute> getTimelineStoryRoutes() => getTimelineStoryRoutes(
TimelineUserStoryConfiguration(
service: FirebaseTimelineService(),
userService: FirebaseUserService(),
userId: currentUserId,
optionsBuilder: (context) {},
),
);
```
Add the `getTimelineStoryRoutes()` to your go_router routes like so:
```
final GoRouter _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (BuildContext context, GoRouterState state) {
return const MyHomePage(
title: "home",
);
},
),
...getTimelineStoryRoutes(timelineUserStoryConfiguration)
],
);
```
The user story can also be used without go router:
Add the following code somewhere in your widget tree:
````
timeLineNavigatorUserStory(TimelineUserStoryConfiguration, context),
````
Or create your own routing using the Screens:
To add the `TimelineScreen` add the following code:
````
TimelineScreen(
userId: currentUserId,
service: timelineService,
options: timelineOptions,
onPostTap: (post) {}
),
````
`TimelineScreen` is supplied with a standard `TimelinePostScreen` which opens the detail page of the selected post. Needed parameter like `TimelineService` and `TimelineOptions` will be the same as the ones supplied to the `TimelineScreen`.
The standard `TimelinePostScreen` can be overridden by supplying `onPostTap` as shown below.
```
TimelineScreen(
userId: currentUserId,
service: timelineService,
options: timelineOptions,
onPostTap: (tappedPost) {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => Scaffold(
body: TimelinePostScreen(
userId: currentUserId,
service: timelineService,
options: timelineOptions,
post: post,
onPostDelete: () {
service.deletePost(post);
},
),
),
),
);
},
),
```
A standard post creation is provided named: `TimelinePostCreationScreen`. Routing to this has to be done manually.
```
TimelinePostCreationScreen(
postCategory: selectedCategory,
userId: currentUserId,
service: timelineService,
options: timelineOptions,
onPostCreated: (post) {
Navigator.of(context).pop();
},
),
```
The `TimelineOptions` has its own parameters, as specified below:
| Parameter | Explanation |
|-----------|-------------|
| theme | Used to set icon colors and textstyles |
| translations | Ability to provide desired text and tanslations. |
| imagePickerConfig | Config for the image picker in the post creation screen. |
| imagePickerTheme | Theme for the image picker in the post creation screen. |
| timelinePostHeight | Sets the height for each post widget in the list of post. If null, the size depends on the size of the image. |
| allowAllDeletion | Determines of users are allowed to delete thier own posts. |
| sortCommentsAscending | Determines if the comments are sorted from old to new or new to old. |
| sortPostsAscending | Determines if the posts are sorted from old to new or new to old. |
| doubleTapToLike | Enables the abilty to double tap the image to like the post. |
| iconsWithValues | Ability to provide desired text and tanslations. |
| likeAndDislikeIconsForDoubleTap | Ability to override the standard icon which appears on double tap. |
| itemInfoBuilder | Ability to override the bottom of the postwidgets. (Everything under the like and comment icons) |
| dateFormat | Sets the used date format |
| timeFormat | Sets the used time format |
| buttonBuilder | The ability to provide a custom button for the post creation screen. |
| textInputBuilder | The ability to provide a custom text input widget for the post creation screen. |
| dividerBuilder | Ability to provide desired text and tanslations. |
| userAvatarBuilder | The ability to provide a custom avatar. |
| anonymousAvatarBuilder | The ability to provide a custom avatar for anonymous users. |
| nameBuilder | The ability to override the standard way of display the post creator name. |
| padding | Padding used for the whole page. |
| iconSize | Size of icons like the comment and like icons. Dafualts to 26. |
| postWidgetHeight | Ability to provide desired text and tanslations. |
| postPadding | Padding for each post. |
| filterOptions | Options for using the filter to filter posts. |
| categoriesOptions | Options for using the category selector to provide posts of a certain category. |
The `ImagePickerTheme` ans `imagePickerConfig` also have their own parameters, how to use these parameters can be found in [the documentation of the flutter_image_picker package](https://github.com/Iconica-Development/flutter_image_picker).
## Issues

View file

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

View file

@ -0,0 +1,37 @@
import 'package:example/config/config.dart';
import 'package:example/services/timeline_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_timeline/flutter_timeline.dart';
import 'package:go_router/go_router.dart';
List<GoRoute> getTimelineRoutes() => getTimelineStoryRoutes(
getConfig(
TestTimelineService(),
),
);
final _router = GoRouter(
initialLocation: '/timeline',
routes: [
...getTimelineRoutes(),
],
);
class GoRouterApp extends StatelessWidget {
const GoRouterApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: _router,
title: 'Flutter Timeline',
theme: ThemeData(
colorScheme:
ColorScheme.fromSeed(seedColor: Colors.deepPurple).copyWith(
background: const Color(0xFFB8E2E8),
),
useMaterial3: true,
),
);
}
}

View file

@ -0,0 +1,71 @@
import 'package:example/config/config.dart';
import 'package:example/services/timeline_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_timeline/flutter_timeline.dart';
class NavigatorApp extends StatelessWidget {
const NavigatorApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Timeline',
theme: ThemeData(
colorScheme:
ColorScheme.fromSeed(seedColor: Colors.deepPurple).copyWith(
background: const Color(0xFFB8E2E8),
),
useMaterial3: true,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({
super.key,
});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
var timelineService = TestTimelineService();
var timelineOptions = options;
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
heroTag: 'btn1',
onPressed: () =>
createPost(context, timelineService, timelineOptions),
child: const Icon(
Icons.edit,
color: Colors.white,
),
),
const SizedBox(
height: 8,
),
FloatingActionButton(
heroTag: 'btn2',
onPressed: () => generatePost(timelineService),
child: const Icon(
Icons.add,
color: Colors.white,
),
),
],
),
body: SafeArea(
child: timeLineNavigatorUserStory(getConfig(timelineService), context),
),
);
}
}

View file

@ -0,0 +1,95 @@
import 'package:example/config/config.dart';
import 'package:example/services/timeline_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_timeline/flutter_timeline.dart';
class WidgetApp extends StatelessWidget {
const WidgetApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Timeline',
theme: ThemeData(
colorScheme:
ColorScheme.fromSeed(seedColor: Colors.deepPurple).copyWith(
background: const Color(0xFFB8E2E8),
),
useMaterial3: true,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({
super.key,
});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
var timelineService = TestTimelineService();
var timelineOptions = options;
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
onPressed: () {
createPost(context, timelineService, timelineOptions);
},
child: const Icon(
Icons.edit,
color: Colors.white,
),
),
const SizedBox(
height: 8,
),
FloatingActionButton(
onPressed: () {
generatePost(timelineService);
},
child: const Icon(
Icons.add,
color: Colors.white,
),
),
],
),
body: SafeArea(
child: TimelineScreen(
userId: 'test_user',
service: timelineService,
options: timelineOptions,
onPostTap: (post) async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => Scaffold(
body: TimelinePostScreen(
userId: 'test_user',
service: timelineService,
options: timelineOptions,
post: post,
onPostDelete: () {
timelineService.deletePost(post);
Navigator.of(context).pop();
},
),
),
),
);
},
),
),
);
}
}

View file

@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:flutter_timeline/flutter_timeline.dart';
class PostScreen extends StatefulWidget {
const PostScreen({
required this.service,
required this.post,
super.key,
});
final TimelineService service;
final TimelinePost post;
@override
State<PostScreen> createState() => _PostScreenState();
}
class _PostScreenState extends State<PostScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: TimelinePostScreen(
userId: 'test_user',
service: widget.service,
options: TimelineOptions(),
post: widget.post,
onPostDelete: () {
Navigator.of(context).pop();
},
),
);
}
}
class TestUserService implements TimelineUserService {
final Map<String, TimelinePosterUserModel> _users = {
'test_user': const TimelinePosterUserModel(userId: 'test_user')
};
@override
Future<TimelinePosterUserModel?> getUser(String userId) async {
if (_users.containsKey(userId)) {
return _users[userId]!;
}
_users[userId] = TimelinePosterUserModel(userId: userId);
return TimelinePosterUserModel(userId: userId);
}
}

View file

@ -0,0 +1,77 @@
import 'package:example/apps/widgets/screens/post_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_timeline/flutter_timeline.dart';
TimelineUserStoryConfiguration getConfig(TimelineService service) {
return TimelineUserStoryConfiguration(
service: service,
userService: TestUserService(),
userId: 'test_user',
optionsBuilder: (context) => options);
}
var options = TimelineOptions(
textInputBuilder: null,
padding: const EdgeInsets.all(20).copyWith(top: 28),
allowAllDeletion: true,
categoriesOptions: CategoriesOptions(
categoriesBuilder: (context) => [
const TimelineCategory(
key: null,
title: 'All',
icon: SizedBox.shrink(),
),
const TimelineCategory(
key: 'category1',
title: 'Category 1',
icon: SizedBox.shrink(),
),
const TimelineCategory(
key: 'category2',
title: 'Category 2',
icon: SizedBox.shrink(),
),
],
),
);
void createPost(BuildContext context, TimelineService service,
TimelineOptions options) async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => Scaffold(
body: TimelinePostCreationScreen(
postCategory: null,
userId: 'test_user',
service: service,
options: options,
onPostCreated: (post) {
Navigator.of(context).pop();
},
),
),
),
);
}
void generatePost(TimelineService service) {
var amountOfPosts = service.getPosts(null).length;
service.createPost(
TimelinePost(
id: 'Post$amountOfPosts',
creatorId: 'test_user',
title: 'Post $amountOfPosts',
category: amountOfPosts % 2 == 0 ? 'category1' : 'category2',
content: "Post $amountOfPosts content",
likes: 0,
reaction: 0,
createdAt: DateTime.now(),
reactionEnabled: amountOfPosts % 2 == 0 ? false : true,
imageUrl: amountOfPosts % 3 != 0
? 'https://s3-eu-west-1.amazonaws.com/sortlist-core-api/6qpvvqjtmniirpkvp8eg83bicnc2'
: null,
),
);
}

View file

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

View file

@ -0,0 +1,187 @@
// SPDX-FileCopyrightText: 2023 Iconica
//
// SPDX-License-Identifier: BSD-3-Clause
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_timeline/flutter_timeline.dart';
// ignore: depend_on_referenced_packages
import 'package:uuid/uuid.dart';
class TestTimelineService with ChangeNotifier implements TimelineService {
@override
List<TimelinePost> posts = [];
@override
Future<TimelinePost> createPost(TimelinePost post) async {
posts.add(
post.copyWith(
creator: const TimelinePosterUserModel(userId: 'test_user'),
),
);
notifyListeners();
return post;
}
@override
Future<void> deletePost(TimelinePost post) async {
posts = posts.where((element) => element.id != post.id).toList();
notifyListeners();
}
@override
Future<TimelinePost> deletePostReaction(
TimelinePost post,
String reactionId,
) async {
if (post.reactions != null && post.reactions!.isNotEmpty) {
var reaction =
post.reactions!.firstWhere((element) => element.id == reactionId);
var updatedPost = post.copyWith(
reaction: post.reaction - 1,
reactions: (post.reactions ?? [])..remove(reaction),
);
posts = posts
.map(
(p) => p.id == post.id ? updatedPost : p,
)
.toList();
notifyListeners();
return updatedPost;
}
return post;
}
@override
Future<TimelinePost> fetchPostDetails(TimelinePost post) async {
var reactions = post.reactions ?? [];
var updatedReactions = <TimelinePostReaction>[];
for (var reaction in reactions) {
updatedReactions.add(reaction.copyWith(
creator: const TimelinePosterUserModel(userId: 'test_user')));
}
var updatedPost = post.copyWith(reactions: updatedReactions);
posts = posts.map((p) => (p.id == post.id) ? updatedPost : p).toList();
notifyListeners();
return updatedPost;
}
@override
Future<List<TimelinePost>> fetchPosts(String? category) async {
posts = getMockedPosts();
notifyListeners();
return posts;
}
@override
Future<List<TimelinePost>> fetchPostsPaginated(
String? category,
int limit,
) async {
notifyListeners();
return posts;
}
@override
Future<TimelinePost> fetchPost(TimelinePost post) async {
notifyListeners();
return post;
}
@override
Future<List<TimelinePost>> refreshPosts(String? category) async {
var newPosts = <TimelinePost>[];
posts = [...posts, ...newPosts];
notifyListeners();
return posts;
}
@override
TimelinePost? getPost(String postId) =>
(posts.any((element) => element.id == postId))
? posts.firstWhere((element) => element.id == postId)
: null;
@override
List<TimelinePost> getPosts(String? category) => posts
.where((element) => category == null || element.category == category)
.toList();
@override
Future<TimelinePost> likePost(String userId, TimelinePost post) async {
var updatedPost = post.copyWith(
likes: post.likes + 1,
likedBy: (post.likedBy ?? [])..add(userId),
);
posts = posts
.map(
(p) => p.id == post.id ? updatedPost : p,
)
.toList();
notifyListeners();
return updatedPost;
}
@override
Future<TimelinePost> unlikePost(String userId, TimelinePost post) async {
var updatedPost = post.copyWith(
likes: post.likes - 1,
likedBy: post.likedBy?..remove(userId),
);
posts = posts
.map(
(p) => p.id == post.id ? updatedPost : p,
)
.toList();
notifyListeners();
return updatedPost;
}
@override
Future<TimelinePost> reactToPost(
TimelinePost post,
TimelinePostReaction reaction, {
Uint8List? image,
}) async {
var reactionId = const Uuid().v4();
var updatedReaction = reaction.copyWith(
id: reactionId,
creator: const TimelinePosterUserModel(userId: 'test_user'));
var updatedPost = post.copyWith(
reaction: post.reaction + 1,
reactions: post.reactions?..add(updatedReaction),
);
posts = posts
.map(
(p) => p.id == post.id ? updatedPost : p,
)
.toList();
notifyListeners();
return updatedPost;
}
List<TimelinePost> getMockedPosts() {
return [
TimelinePost(
id: 'Post0',
creatorId: 'test_user',
title: 'Post 0',
category: null,
content: "Post 0 content",
likes: 0,
reaction: 0,
createdAt: DateTime.now(),
reactionEnabled: false,
)
];
}
}

View file

@ -0,0 +1,93 @@
name: example
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: '>=3.2.3 <4.0.0'
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.2
flutter_timeline:
path: ../
intl: ^0.19.0
go_router: ^13.0.1
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^2.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - assets/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/assets-and-images/#from-packages
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/custom-fonts/#from-packages

View file

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

View file

@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: 2024 Iconica
//
// SPDX-License-Identifier: BSD-3-Clause
import 'package:flutter/material.dart';
import 'package:flutter_timeline/flutter_timeline.dart';
Widget timeLineNavigatorUserStory(
TimelineUserStoryConfiguration configuration,
BuildContext context,
) =>
_timelineScreenRoute(configuration, context);
Widget _timelineScreenRoute(
TimelineUserStoryConfiguration configuration,
BuildContext context,
) =>
TimelineScreen(
service: configuration.service,
options: configuration.optionsBuilder(context),
userId: configuration.userId,
onPostTap: (post) async =>
configuration.onPostTap?.call(context, post) ??
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) =>
_postDetailScreenRoute(configuration, context, post),
),
),
onUserTap: (userId) {
configuration.onUserTap?.call(context, userId);
},
filterEnabled: configuration.filterEnabled,
postWidgetBuilder: configuration.postWidgetBuilder,
);
Widget _postDetailScreenRoute(
TimelineUserStoryConfiguration configuration,
BuildContext context,
TimelinePost post,
) =>
TimelinePostScreen(
userId: configuration.userId,
service: configuration.service,
options: configuration.optionsBuilder(context),
post: post,
onPostDelete: () async {
configuration.onPostDelete?.call(context, post) ??
await configuration.service.deletePost(post);
},
);

View file

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

View file

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

View file

@ -4,10 +4,6 @@
mixin TimelineUserStoryRoutes {
static const String timelineHome = '/timeline';
static const String timelineCreate = '/timeline-create/:category';
static String timelineCreatePath(String category) =>
'/timeline-create/$category';
static const String timelineSelect = '/timeline-select';
static const String timelineView = '/timeline-view/:post';
static String timelineViewPath(String postId) => '/timeline-view/$postId';
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: 2024 Iconica
//
// SPDX-License-Identifier: BSD-3-Clause
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
mixin TimelineFilterService on TimelineService {
List<TimelinePost> filterPosts(
String filterWord,
Map<String, dynamic> options,
) {
var filteredPosts = posts
.where(
(post) => post.title.toLowerCase().contains(
filterWord.toLowerCase(),
),
)
.toList();
return filteredPosts;
}
}

View file

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

View file

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

View file

@ -1,49 +0,0 @@
// SPDX-FileCopyrightText: 2023 Iconica
//
// SPDX-License-Identifier: BSD-3-Clause
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Timeline Example',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({
super.key,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
],
),
),
);
}
}

View file

@ -1,21 +0,0 @@
name: example
description: Flutter timeline example
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1
environment:
sdk: '>=3.1.3 <4.0.0'
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
flutter:
uses-material-design: true

View file

@ -1,14 +0,0 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter_test/flutter_test.dart';
void main() {
test('blank test', () {
expect(true, isTrue);
});
}

View file

@ -12,4 +12,6 @@ export 'src/screens/timeline_post_creation_screen.dart';
export 'src/screens/timeline_post_screen.dart';
export 'src/screens/timeline_screen.dart';
export 'src/screens/timeline_selection_screen.dart';
export 'src/widgets/category_selector.dart';
export 'src/widgets/category_selector_button.dart';
export 'src/widgets/timeline_post_widget.dart';

View file

@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: 2023 Iconica
//
// SPDX-License-Identifier: BSD-3-Clause
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_image_picker/flutter_image_picker.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
@ -8,7 +9,6 @@ import 'package:flutter_timeline_view/src/config/timeline_theme.dart';
import 'package:flutter_timeline_view/src/config/timeline_translations.dart';
import 'package:intl/intl.dart';
@immutable
class TimelineOptions {
const TimelineOptions({
this.theme = const TimelineTheme(),
@ -18,21 +18,38 @@ class TimelineOptions {
this.timelinePostHeight,
this.allowAllDeletion = false,
this.sortCommentsAscending = true,
this.sortPostsAscending = false,
this.dateformat,
this.sortPostsAscending,
this.doubleTapTolike = false,
this.iconsWithValues = false,
this.likeAndDislikeIconsForDoubleTap = const (
Icon(
Icons.favorite_rounded,
color: Color(0xFFC3007A),
),
null,
),
this.itemInfoBuilder,
this.dateFormat,
this.timeFormat,
this.buttonBuilder,
this.textInputBuilder,
this.dividerBuilder,
this.userAvatarBuilder,
this.anonymousAvatarBuilder,
this.nameBuilder,
this.padding = const EdgeInsets.symmetric(vertical: 12.0),
this.iconSize = 26,
this.postWidgetHeight,
this.postPadding = const EdgeInsets.all(12.0),
this.filterOptions = const FilterOptions(),
this.categoriesOptions = const CategoriesOptions(),
});
/// Theming options for the timeline
final TimelineTheme theme;
/// The format to display the post date in
final DateFormat? dateformat;
final DateFormat? dateFormat;
/// The format to display the post time in
final DateFormat? timeFormat;
@ -41,7 +58,7 @@ class TimelineOptions {
final bool sortCommentsAscending;
/// Whether to sort posts ascending or descending
final bool sortPostsAscending;
final bool? sortPostsAscending;
/// Allow all posts to be deleted instead of
/// only the posts of the current user
@ -71,6 +88,97 @@ class TimelineOptions {
/// ImagePickerConfig can be used to define the
/// size and quality for the uploaded image.
final ImagePickerConfig imagePickerConfig;
/// Whether to allow double tap to like
final bool doubleTapTolike;
/// The icons to display when double tap to like is enabled
final (Icon?, Icon?) likeAndDislikeIconsForDoubleTap;
/// Whether to display the icons with values
final bool iconsWithValues;
/// The builder for the item info, all below the like and comment buttons
final Widget Function({required TimelinePost post})? itemInfoBuilder;
/// The builder for the divider
final Widget Function()? dividerBuilder;
/// The padding between posts in the timeline
final EdgeInsets padding;
/// Size of icons like the comment and like icons. Dafualts to 26
final double iconSize;
/// Sets a predefined height for the postWidget.
final double? postWidgetHeight;
/// Padding of each post
final EdgeInsets postPadding;
/// Options for filtering
final FilterOptions filterOptions;
/// Options for using the category selector.
final CategoriesOptions categoriesOptions;
}
class CategoriesOptions {
const CategoriesOptions({
this.categoriesBuilder,
this.categoryButtonBuilder,
this.categorySelectorHorizontalPadding,
});
/// List of categories that the user can select.
/// If this is null no categories will be shown.
final List<TimelineCategory> Function(BuildContext context)?
categoriesBuilder;
/// Abilty to override the standard category selector
final Widget Function({
required String? categoryKey,
required String categoryName,
required Function onTap,
required bool selected,
})? categoryButtonBuilder;
/// Overides the standard horizontal padding of the whole category selector.
final double? categorySelectorHorizontalPadding;
TimelineCategory? getCategoryByKey(
BuildContext context,
String? key,
) {
if (categoriesBuilder == null) {
return null;
}
return categoriesBuilder!
.call(context)
.firstWhereOrNull((category) => category.key == key);
}
}
class FilterOptions {
const FilterOptions({
this.initialFilterWord,
this.searchBarBuilder,
this.onFilterEnabledChange,
});
/// Set a value to search through posts. When set the searchbar is shown.
/// If null no searchbar is shown.
final String? initialFilterWord;
// Possibilty to override the standard search bar.
final Widget Function(
Future<List<TimelinePost>> Function(
String filterWord,
) search,
)? searchBarBuilder;
final void Function({required bool filterEnabled})? onFilterEnabledChange;
}
typedef ButtonBuilder = Widget Function(

View file

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

View file

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

View file

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

View file

@ -6,20 +6,20 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
import 'package:flutter_timeline_view/src/config/timeline_options.dart';
import 'package:flutter_timeline_view/src/widgets/timeline_post_widget.dart';
import 'package:flutter_timeline_view/flutter_timeline_view.dart';
class TimelineScreen extends StatefulWidget {
const TimelineScreen({
required this.userId,
required this.service,
required this.options,
required this.onPostTap,
required this.service,
this.scrollController,
this.onUserTap,
this.posts,
this.controller,
this.timelineCategoryFilter,
this.padding = const EdgeInsets.symmetric(vertical: 12.0),
this.timelineCategory,
this.postWidgetBuilder,
this.filterEnabled = false,
super.key,
});
@ -33,10 +33,10 @@ class TimelineScreen extends StatefulWidget {
final TimelineOptions options;
/// The controller for the scroll view
final ScrollController? controller;
final ScrollController? scrollController;
/// The string to filter the timeline by category
final String? timelineCategoryFilter;
final String? timelineCategory;
/// This is used if you want to pass in a list of posts instead
/// of fetching them from the service
@ -48,8 +48,11 @@ class TimelineScreen extends StatefulWidget {
/// If this is not null, the user can tap on the user avatar or name
final Function(String userId)? onUserTap;
/// The padding between posts in the timeline
final EdgeInsets padding;
/// Override the standard postwidget
final Widget Function(TimelinePost post)? postWidgetBuilder;
/// if true the filter textfield is enabled.
final bool filterEnabled;
@override
State<TimelineScreen> createState() => _TimelineScreenState();
@ -57,12 +60,21 @@ class TimelineScreen extends StatefulWidget {
class _TimelineScreenState extends State<TimelineScreen> {
late ScrollController controller;
late var textFieldController = TextEditingController(
text: widget.options.filterOptions.initialFilterWord,
);
late var service = widget.service;
bool isLoading = true;
late var category = widget.timelineCategory;
late var filterWord = widget.options.filterOptions.initialFilterWord;
@override
void initState() {
super.initState();
controller = widget.controller ?? ScrollController();
controller = widget.scrollController ?? ScrollController();
unawaited(loadPosts());
}
@ -74,60 +86,158 @@ class _TimelineScreenState extends State<TimelineScreen> {
// Build the list of posts
return ListenableBuilder(
listenable: widget.service,
listenable: service,
builder: (context, _) {
var posts = widget.posts ??
widget.service.getPosts(widget.timelineCategoryFilter);
var posts = widget.posts ?? service.getPosts(category);
if (widget.filterEnabled && filterWord != null) {
if (service is TimelineFilterService?) {
posts =
(service as TimelineFilterService).filterPosts(filterWord!, {});
} else {
debugPrint('Timeline service needs to mixin'
' with TimelineFilterService');
}
}
posts = posts
.where(
(p) =>
widget.timelineCategoryFilter == null ||
p.category == widget.timelineCategoryFilter,
(p) => category == null || p.category == category,
)
.toList();
// sort posts by date
posts.sort(
(a, b) => widget.options.sortPostsAscending
? a.createdAt.compareTo(b.createdAt)
: b.createdAt.compareTo(a.createdAt),
);
return SingleChildScrollView(
controller: controller,
child: Column(
children: [
...posts.map(
(post) => Padding(
padding: widget.padding,
child: TimelinePostWidget(
userId: widget.userId,
options: widget.options,
post: post,
height: widget.options.timelinePostHeight,
onTap: () => widget.onPostTap.call(post),
onTapLike: () async =>
widget.service.likePost(widget.userId, post),
onTapUnlike: () async =>
widget.service.unlikePost(widget.userId, post),
onPostDelete: () async => widget.service.deletePost(post),
onUserTap: widget.onUserTap,
),
if (widget.options.sortPostsAscending != null) {
posts.sort(
(a, b) => widget.options.sortPostsAscending!
? a.createdAt.compareTo(b.createdAt)
: b.createdAt.compareTo(a.createdAt),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: widget.options.padding.top,
),
if (widget.filterEnabled) ...[
Padding(
padding: EdgeInsets.symmetric(
horizontal: widget.options.padding.horizontal,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: textFieldController,
onChanged: (value) {
setState(() {
filterWord = value;
});
},
decoration: InputDecoration(
hintText: widget.options.translations.searchHint,
suffixIconConstraints:
const BoxConstraints(maxHeight: 14),
contentPadding: const EdgeInsets.only(
left: 12,
right: 12,
bottom: -10,
),
suffixIcon: const Padding(
padding: EdgeInsets.only(right: 12),
child: Icon(Icons.search),
),
),
),
),
const SizedBox(
width: 8,
),
InkWell(
onTap: () {
setState(() {
textFieldController.clear();
filterWord = null;
widget.options.filterOptions.onFilterEnabledChange
?.call(filterEnabled: false);
});
},
child: const Padding(
padding: EdgeInsets.all(8),
child: Icon(
Icons.close,
color: Color(0xFF000000),
),
),
),
],
),
),
if (posts.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
widget.timelineCategoryFilter == null
? widget.options.translations.noPosts
: widget.options.translations.noPostsWithFilter,
style: widget.options.theme.textStyles.noPostsStyle,
),
),
),
const SizedBox(
height: 24,
),
],
),
CategorySelector(
filter: category,
options: widget.options,
onTapCategory: (categoryKey) {
setState(() {
category = categoryKey;
});
},
),
const SizedBox(
height: 12,
),
Expanded(
child: SingleChildScrollView(
controller: controller,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...posts.map(
(post) => Padding(
padding: widget.options.postPadding,
child: widget.postWidgetBuilder?.call(post) ??
TimelinePostWidget(
service: widget.service,
userId: widget.userId,
options: widget.options,
post: post,
onTap: () => widget.onPostTap(post),
onTapLike: () async =>
service.likePost(widget.userId, post),
onTapUnlike: () async =>
service.unlikePost(widget.userId, post),
onPostDelete: () async =>
service.deletePost(post),
onUserTap: widget.onUserTap,
),
),
),
if (posts.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
category == null
? widget.options.translations.noPosts
: widget.options.translations.noPostsWithFilter,
style: widget.options.theme.textStyles.noPostsStyle,
),
),
),
],
),
),
),
SizedBox(
height: widget.options.padding.bottom,
),
],
);
},
);
@ -136,7 +246,7 @@ class _TimelineScreenState extends State<TimelineScreen> {
Future<void> loadPosts() async {
if (widget.posts != null) return;
try {
await widget.service.fetchPosts(widget.timelineCategoryFilter);
await service.fetchPosts(category);
setState(() {
isLoading = false;
});

View file

@ -0,0 +1,60 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_timeline_view/flutter_timeline_view.dart';
class CategorySelector extends StatelessWidget {
const CategorySelector({
required this.filter,
required this.options,
required this.onTapCategory,
super.key,
});
final String? filter;
final TimelineOptions options;
final void Function(String? categoryKey) onTapCategory;
@override
Widget build(BuildContext context) {
if (options.categoriesOptions.categoriesBuilder == null) {
return const SizedBox.shrink();
}
var categories = options.categoriesOptions.categoriesBuilder!(context);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
SizedBox(
width:
options.categoriesOptions.categorySelectorHorizontalPadding ??
max(options.padding.horizontal - 4, 0),
),
for (var category in categories) ...[
options.categoriesOptions.categoryButtonBuilder?.call(
categoryKey: category.key,
categoryName: category.title,
onTap: () => onTapCategory(category.key),
selected: filter == category.key,
) ??
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: CategorySelectorButton(
category: category,
selected: filter == category.key,
onTap: () => onTapCategory(category.key),
),
),
],
SizedBox(
width:
options.categoriesOptions.categorySelectorHorizontalPadding ??
max(options.padding.horizontal - 4, 0),
),
],
),
);
}
}

View file

@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
class CategorySelectorButton extends StatelessWidget {
const CategorySelectorButton({
required this.category,
required this.selected,
required this.onTap,
super.key,
});
final TimelineCategory category;
final bool selected;
final void Function() onTap;
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return TextButton(
onPressed: onTap,
style: ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const MaterialStatePropertyAll(
EdgeInsets.symmetric(
vertical: 5,
horizontal: 12,
),
),
minimumSize: const MaterialStatePropertyAll(Size.zero),
backgroundColor: MaterialStatePropertyAll(
selected ? theme.colorScheme.primary : theme.colorScheme.surface,
),
shape: const MaterialStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(45),
),
),
),
),
child: Text(
category.title,
style: theme.textTheme.labelMedium?.copyWith(
color: selected
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface,
),
),
);
}
}

View file

@ -0,0 +1,168 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
class TappableImage extends StatefulWidget {
const TappableImage({
required this.post,
required this.onLike,
required this.userId,
required this.likeAndDislikeIcon,
super.key,
});
final TimelinePost post;
final String userId;
final Future<bool> Function({required bool liked}) onLike;
final (Icon?, Icon?) likeAndDislikeIcon;
@override
State<TappableImage> createState() => _TappableImageState();
}
class _TappableImageState extends State<TappableImage>
with SingleTickerProviderStateMixin {
late AnimationController animationController;
late Animation<double> animation;
bool loading = false;
@override
void initState() {
super.initState();
animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 350),
);
animation = CurvedAnimation(
parent: animationController,
curve: Curves.ease,
);
animationController.addListener(listener);
}
void listener() {
setState(() {});
}
@override
void dispose() {
animationController.removeListener(listener);
animationController.dispose();
super.dispose();
}
void startAnimation() {
animationController.forward();
}
void reverseAnimation() {
animationController.reverse();
}
@override
Widget build(BuildContext context) => InkWell(
onDoubleTap: () async {
if (loading) {
return;
}
loading = true;
await animationController.forward();
var liked = await widget.onLike(
liked: widget.post.likedBy?.contains(
widget.userId,
) ??
false,
);
if (context.mounted) {
await showDialog(
barrierDismissible: false,
barrierColor: Colors.transparent,
context: context,
builder: (context) => HeartAnimation(
duration: const Duration(milliseconds: 200),
liked: liked,
likeAndDislikeIcon: widget.likeAndDislikeIcon,
),
);
}
await animationController.reverse();
loading = false;
},
child: Transform.translate(
offset: Offset(0, animation.value * -32),
child: Transform.scale(
scale: 1 + animation.value * 0.1,
child: CachedNetworkImage(
imageUrl: widget.post.imageUrl ?? '',
width: double.infinity,
fit: BoxFit.fitHeight,
),
),
),
);
}
class HeartAnimation extends StatefulWidget {
const HeartAnimation({
required this.duration,
required this.liked,
required this.likeAndDislikeIcon,
super.key,
});
final Duration duration;
final bool liked;
final (Icon?, Icon?) likeAndDislikeIcon;
@override
State<HeartAnimation> createState() => _HeartAnimationState();
}
class _HeartAnimationState extends State<HeartAnimation> {
late bool active;
@override
void initState() {
super.initState();
active = widget.liked;
unawaited(
Future.delayed(const Duration(milliseconds: 100)).then((value) async {
active = widget.liked;
var navigator = Navigator.of(context);
await Future.delayed(widget.duration);
navigator.pop();
}),
);
}
@override
Widget build(BuildContext context) => AnimatedOpacity(
opacity: widget.likeAndDislikeIcon.$1 != null &&
widget.likeAndDislikeIcon.$2 != null
? 1
: active
? 1
: 0,
duration: widget.duration,
curve: Curves.decelerate,
child: AnimatedScale(
scale: widget.likeAndDislikeIcon.$1 != null &&
widget.likeAndDislikeIcon.$2 != null
? 10
: active
? 10
: 1,
duration: widget.duration,
child: active
? widget.likeAndDislikeIcon.$1
: widget.likeAndDislikeIcon.$2,
),
);
}

View file

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

View file

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