diff --git a/packages/flutter_timeline_view/example/lib/main.dart b/packages/flutter_timeline_view/example/lib/main.dart index 7fbc6ad..568731b 100644 --- a/packages/flutter_timeline_view/example/lib/main.dart +++ b/packages/flutter_timeline_view/example/lib/main.dart @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + import 'package:flutter/material.dart'; void main() { diff --git a/packages/flutter_timeline_view/lib/flutter_timeline_view.dart b/packages/flutter_timeline_view/lib/flutter_timeline_view.dart index fcd9330..196be36 100644 --- a/packages/flutter_timeline_view/lib/flutter_timeline_view.dart +++ b/packages/flutter_timeline_view/lib/flutter_timeline_view.dart @@ -5,7 +5,9 @@ library flutter_timeline_view; export 'src/config/timeline_options.dart'; +export 'src/config/timeline_theme.dart'; export 'src/config/timeline_translations.dart'; export 'src/screens/timeline_post_creation_screen.dart'; export 'src/screens/timeline_post_screen.dart'; export 'src/screens/timeline_screen.dart'; +export 'src/widgets/timeline_post_widget.dart'; diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart index d62890f..ced3f32 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart @@ -16,6 +16,7 @@ class TimelineOptions { this.imagePickerConfig = const ImagePickerConfig(), this.imagePickerTheme = const ImagePickerTheme(), this.sortCommentsAscending = false, + this.sortPostsAscending = false, this.dateformat, this.timeFormat, this.buttonBuilder, @@ -35,6 +36,9 @@ class TimelineOptions { /// Whether to sort comments ascending or descending final bool sortCommentsAscending; + /// Whether to sort posts ascending or descending + final bool sortPostsAscending; + final TimelineTranslations translations; final ButtonBuilder? buttonBuilder; diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart b/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart index 85d4cea..d880031 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart @@ -12,6 +12,8 @@ class TimelineTheme { this.commentIcon, this.likedIcon, this.sendIcon, + this.moreIcon, + this.deleteIcon, }); final Color? iconColor; @@ -27,4 +29,10 @@ class TimelineTheme { /// The icon to display to submit a comment final Widget? sendIcon; + + /// The icon for more actions (open delete menu) + final Widget? moreIcon; + + /// The icon for delete action (delete post) + final Widget? deleteIcon; } diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart index 2526115..68ef907 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart @@ -20,6 +20,7 @@ class TimelinePostScreen extends StatefulWidget { required this.userService, required this.options, required this.post, + this.onUserTap, this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 16), super.key, }); @@ -42,6 +43,9 @@ class TimelinePostScreen extends StatefulWidget { /// 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; + @override State createState() => _TimelinePostScreenState(); } @@ -110,34 +114,44 @@ class _TimelinePostScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (post.creator != null) - Row( - children: [ - if (post.creator!.imageUrl != null) ...[ - widget.options.userAvatarBuilder?.call( - post.creator!, - 40, - ) ?? - CircleAvatar( - radius: 20, - backgroundImage: CachedNetworkImageProvider( - post.creator!.imageUrl!, + Row( + children: [ + if (post.creator != null) + InkWell( + onTap: widget.onUserTap != null + ? () => widget.onUserTap?.call(post.creator!.userId) + : null, + child: Row( + children: [ + if (post.creator!.imageUrl != null) ...[ + widget.options.userAvatarBuilder?.call( + post.creator!, + 40, + ) ?? + CircleAvatar( + radius: 20, + backgroundImage: CachedNetworkImageProvider( + post.creator!.imageUrl!, + ), + ), + ], + const SizedBox(width: 10), + if (post.creator!.fullName != null) ...[ + Text( + post.creator!.fullName!, + style: theme.textTheme.titleMedium, ), - ), - ], - const SizedBox(width: 10), - if (post.creator!.fullName != null) ...[ - Text( - post.creator!.fullName!, - style: theme.textTheme.titleMedium, + ], + ], ), - ], - - // three small dots at the end - const Spacer(), - const Icon(Icons.more_horiz), - ], - ), + ), + const Spacer(), + widget.options.theme.moreIcon ?? + const Icon( + Icons.more_horiz_rounded, + ), + ], + ), const SizedBox(height: 8), // image of the post if (post.imageUrl != null) ...[ diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart index 6d01f82..09409c8 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart @@ -13,32 +13,47 @@ class TimelineScreen extends StatefulWidget { const TimelineScreen({ required this.userId, required this.options, - required this.posts, required this.onPostTap, required this.service, + this.onUserTap, + this.posts, this.controller, this.timelineCategoryFilter, this.timelinePostHeight = 100.0, + this.padding = const EdgeInsets.symmetric(vertical: 12.0), super.key, }); /// The user id of the current user final String userId; + /// The service to use for fetching and manipulating posts final TimelineService service; + /// All the configuration options for the timelinescreens and widgets final TimelineOptions options; + /// The controller for the scroll view final ScrollController? controller; final String? timelineCategoryFilter; + /// The height of a post in the timeline final double timelinePostHeight; - final List posts; + /// This is used if you want to pass in a list of posts instead + /// of fetching them from the service + final List? posts; + /// Called when a post is tapped final Function(TimelinePost) onPostTap; + /// 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 State createState() => _TimelineScreenState(); } @@ -55,7 +70,71 @@ class _TimelineScreenState extends State { unawaited(loadPosts()); } + @override + Widget build(BuildContext context) { + if (isLoading && widget.posts == null) { + // Show loading indicator while data is being fetched + return const Center(child: CircularProgressIndicator()); + } + + var posts = widget.posts ?? this.posts; + posts = posts + .where( + (p) => + widget.timelineCategoryFilter == null || + p.category == widget.timelineCategoryFilter, + ) + .toList(); + + // sort posts by date + posts.sort( + (a, b) => widget.options.sortPostsAscending + ? b.createdAt.compareTo(a.createdAt) + : a.createdAt.compareTo(b.createdAt), + ); + + // Build the list of posts + return SingleChildScrollView( + controller: controller, + child: Column( + children: [ + ...posts.map( + (post) => Padding( + padding: widget.padding, + child: TimelinePostWidget( + userId: widget.userId, + options: widget.options, + post: post, + height: widget.timelinePostHeight, + onTap: () => widget.onPostTap.call(post), + onTapLike: () async => updatePostInList( + await widget.service.likePost(widget.userId, post), + ), + onTapUnlike: () async => updatePostInList( + await widget.service.unlikePost(widget.userId, post), + ), + onUserTap: widget.onUserTap, + ), + ), + ), + if (posts.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + widget.timelineCategoryFilter == null + ? widget.options.translations.noPosts + : widget.options.translations.noPostsWithFilter, + ), + ), + ), + ], + ), + ); + } + Future loadPosts() async { + if (widget.posts != null) return; try { // Fetching posts from the service var fetchedPosts = @@ -86,39 +165,4 @@ class _TimelineScreenState extends State { }); } } - - @override - Widget build(BuildContext context) { - if (isLoading) { - // Show loading indicator while data is being fetched - return const Center(child: CircularProgressIndicator()); - } - - // Build the list of posts - return SingleChildScrollView( - controller: controller, - child: Column( - children: posts - .map( - (post) => Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: TimelinePostWidget( - userId: widget.userId, - options: widget.options, - post: post, - height: widget.timelinePostHeight, - onTap: () => widget.onPostTap.call(post), - onTapLike: () async => updatePostInList( - await widget.service.likePost(widget.userId, post), - ), - onTapUnlike: () async => updatePostInList( - await widget.service.unlikePost(widget.userId, post), - ), - ), - ), - ) - .toList(), - ), - ); - } } diff --git a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart index 49be26a..4733230 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart +++ b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; @@ -12,6 +16,7 @@ class TimelinePostWidget extends StatelessWidget { required this.onTapLike, required this.onTapUnlike, required this.onTap, + this.onUserTap, super.key, }); @@ -25,6 +30,9 @@ class TimelinePostWidget extends StatelessWidget { final VoidCallback onTapLike; final VoidCallback onTapUnlike; + /// If this is not null, the user can tap on the user avatar or name + final Function(String userId)? onUserTap; + @override Widget build(BuildContext context) { var theme = Theme.of(context); @@ -36,34 +44,44 @@ class TimelinePostWidget extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (post.creator != null) - Row( - children: [ - if (post.creator!.imageUrl != null) ...[ - options.userAvatarBuilder?.call( - post.creator!, - 40, - ) ?? - CircleAvatar( - radius: 20, - backgroundImage: CachedNetworkImageProvider( - post.creator!.imageUrl!, + Row( + children: [ + if (post.creator != null) + InkWell( + onTap: onUserTap != null + ? () => onUserTap?.call(post.creator!.userId) + : null, + child: Row( + children: [ + if (post.creator!.imageUrl != null) ...[ + options.userAvatarBuilder?.call( + post.creator!, + 40, + ) ?? + CircleAvatar( + radius: 20, + backgroundImage: CachedNetworkImageProvider( + post.creator!.imageUrl!, + ), + ), + ], + const SizedBox(width: 10), + if (post.creator!.fullName != null) ...[ + Text( + post.creator!.fullName!, + style: theme.textTheme.titleMedium, ), - ), - ], - const SizedBox(width: 10), - if (post.creator!.fullName != null) ...[ - Text( - post.creator!.fullName!, - style: theme.textTheme.titleMedium, + ], + ], ), - ], - - // three small dots at the end - const Spacer(), - const Icon(Icons.more_horiz), - ], - ), + ), + const Spacer(), + options.theme.moreIcon ?? + const Icon( + Icons.more_horiz_rounded, + ), + ], + ), const SizedBox(height: 8), // image of the post if (post.imageUrl != null) ...[