diff --git a/pubspec.lock b/pubspec.lock index 6e7d219b..169ec480 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -468,7 +468,7 @@ packages: source: hosted version: "2.1.4" stream_core: - dependency: transitive + dependency: "direct main" description: path: "packages/stream_core" ref: "280b1045e39388668fd060439259831611b51b5a" diff --git a/sample_app/lib/screens/user_feed/comment/user_comments.dart b/sample_app/lib/screens/user_feed/comment/user_comments.dart new file mode 100644 index 00000000..f15a83f7 --- /dev/null +++ b/sample_app/lib/screens/user_feed/comment/user_comments.dart @@ -0,0 +1,358 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_state_notifier/flutter_state_notifier.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../../core/di/di_initializer.dart'; +import '../../../theme/extensions/theme_extensions.dart'; +import 'user_comments_item.dart'; + +class UserComments extends StatefulWidget { + const UserComments({ + required this.activityId, + required this.feed, + this.scrollController, + super.key, + }); + + final String activityId; + final Feed feed; + final ScrollController? scrollController; + + @override + State createState() => _UserCommentsState(); +} + +class _UserCommentsState extends State { + StreamFeedsClient get client => locator(); + + late Activity activity; + RemoveListener? _removeFeedListener; + late List capabilities; + + @override + void initState() { + super.initState(); + _getActivity(); + _observeFeedCapabilities(); + } + + @override + void didUpdateWidget(covariant UserComments oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.activityId != widget.activityId || + oldWidget.feed != widget.feed) { + activity.dispose(); + _getActivity(); + } + if (oldWidget.feed != widget.feed) { + _observeFeedCapabilities(); + } + } + + @override + void dispose() { + _removeFeedListener?.call(); + activity.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StateNotifierBuilder( + stateNotifier: activity.notifier, + builder: (context, state, child) { + final comments = state.comments; + final activity = state.activity; + final canLoadMore = state.canLoadMoreComments; + + return Column( + children: [ + _buildHeader( + context, + activity, + comments, + ), + Expanded( + child: _buildUserCommentsList( + context, + comments, + canLoadMore, + ), + ), + ], + ); + }, + ); + } + + Widget _buildHeader( + BuildContext context, + ActivityData? activity, + List comments, + ) { + final totalComments = activity?.commentCount ?? 0; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: context.appColors.borders), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Comments', + style: context.appTextStyles.headlineBold, + ), + if (totalComments > 0) + Text( + '$totalComments ${totalComments == 1 ? 'comment' : 'comments'}', + style: context.appTextStyles.footnote.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + ], + ), + OutlinedButton.icon( + onPressed: _onReplyClick, + icon: const Icon(Icons.chat_bubble_outline_rounded), + label: const Text('Add'), + style: OutlinedButton.styleFrom( + minimumSize: Size.zero, + iconColor: context.appColors.accentPrimary, + foregroundColor: context.appColors.accentPrimary, + side: BorderSide(color: context.appColors.accentPrimary), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: context.appTextStyles.footnoteBold, + ), + ), + ], + ), + ); + } + + Widget _buildUserCommentsList( + BuildContext context, + List comments, + bool canLoadMore, + ) { + if (comments.isEmpty) return const EmptyComments(); + + return RefreshIndicator( + onRefresh: _getActivity, + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16), + controller: widget.scrollController, + itemCount: comments.length + 1, + separatorBuilder: (context, index) => Divider( + height: 1, + color: context.appColors.borders, + ), + itemBuilder: (context, index) { + if (index == comments.length) { + return switch (canLoadMore) { + true => TextButton( + onPressed: activity.queryMoreComments, + child: const Text('Load more...'), + ), + false => const Padding( + padding: EdgeInsets.all(16), + child: Center( + child: Text('End of comments'), + ), + ), + }; + } + + final comment = comments[index]; + + return UserCommentItem( + comment: comment, + onHeartClick: _onHeartClick, + onReplyClick: _onReplyClick, + onLongPressComment: _onLongPressComment, + ); + }, + ), + ); + } + + void _observeFeedCapabilities() { + _removeFeedListener?.call(); + _removeFeedListener = widget.feed.notifier.addListener(_onFeedStateChange); + } + + void _onFeedStateChange(FeedState state) { + capabilities = state.ownCapabilities; + } + + Future _getActivity() async { + activity = client.activity( + activityId: widget.activityId, + fid: widget.feed.fid, + ); + + await activity.get(); + } + + void _onHeartClick(ThreadedCommentData comment, bool isAdding) { + const type = 'heart'; + + if (isAdding) { + activity.addCommentReaction( + commentId: comment.id, + request: const AddCommentReactionRequest(type: type), + ); + } else { + activity.deleteCommentReaction( + comment.id, + type, + ); + } + } + + Future _onReplyClick([ThreadedCommentData? parentComment]) async { + final text = await _displayTextInputDialog(context, title: 'Add comment'); + if (text == null) return; + + await activity.addComment( + request: ActivityAddCommentRequest( + comment: text, + parentId: parentComment?.id, + activityId: activity.activityId, + ), + ); + } + + void _onLongPressComment(ThreadedCommentData comment) { + final isOwnComment = comment.user.id == client.user.id; + if (!isOwnComment) return; + final canEdit = capabilities.contains(FeedOwnCapability.updateComment); + final canDelete = capabilities.contains(FeedOwnCapability.deleteComment); + if (!canEdit && !canDelete) return; + + final chooseActionDialog = SimpleDialog( + children: [ + if (canEdit) + SimpleDialogOption( + child: const Text('Edit'), + onPressed: () { + Navigator.pop(context); + _editComment(context, comment); + }, + ), + if (canDelete) + SimpleDialogOption( + child: const Text('Delete'), + onPressed: () { + activity.deleteComment(comment.id); + Navigator.pop(context); + }, + ), + ], + ); + + showDialog( + context: context, + builder: (context) { + return chooseActionDialog; + }, + ); + } + + Future _editComment( + BuildContext context, + ThreadedCommentData comment, + ) async { + final text = await _displayTextInputDialog( + context, + title: 'Edit comment', + initialText: comment.text, + positiveAction: 'Edit', + ); + + if (text == null) return; + + await activity.updateComment( + comment.id, + ActivityUpdateCommentRequest(comment: text), + ); + } + + Future _displayTextInputDialog( + BuildContext context, { + required String title, + String? initialText, + String positiveAction = 'Add', + }) async { + final textFieldController = TextEditingController(); + textFieldController.text = initialText ?? ''; + return showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(title), + content: TextField(controller: textFieldController), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.pop(context); + }, + ), + TextButton( + child: Text(positiveAction), + onPressed: () { + Navigator.pop(context, textFieldController.text); + }, + ), + ], + ); + }, + ); + } +} + +class EmptyComments extends StatelessWidget { + const EmptyComments({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + size: 64, + Icons.chat_bubble_outline_rounded, + color: context.appColors.textLowEmphasis, + ), + const SizedBox(height: 16), + Text( + 'No comments yet', + style: context.appTextStyles.headline.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + const SizedBox(height: 8), + Text( + 'Be the first to share your thoughts!', + style: context.appTextStyles.body.copyWith( + color: context.appColors.textLowEmphasis, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/sample_app/lib/screens/user_feed/comment/user_comments_item.dart b/sample_app/lib/screens/user_feed/comment/user_comments_item.dart new file mode 100644 index 00000000..095455c0 --- /dev/null +++ b/sample_app/lib/screens/user_feed/comment/user_comments_item.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../../theme/extensions/theme_extensions.dart'; +import '../../../utils/date_time_extensions.dart'; +import '../../../widgets/action_button.dart'; +import '../../../widgets/user_avatar.dart'; + +class UserCommentItem extends StatelessWidget { + const UserCommentItem({ + super.key, + required this.comment, + required this.onHeartClick, + required this.onReplyClick, + required this.onLongPressComment, + }); + + final ThreadedCommentData comment; + final ValueSetter onReplyClick; + final void Function(ThreadedCommentData comment, bool isAdding) onHeartClick; + final ValueSetter onLongPressComment; + + @override + Widget build(BuildContext context) { + final user = comment.user; + final heartsCount = comment.reactionGroups['heart']?.count ?? 0; + final hasOwnHeart = comment.ownReactions.any((it) => it.type == 'heart'); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + GestureDetector( + onLongPress: () => onLongPressComment(comment), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UserAvatar.appBar( + user: User(id: user.id, name: user.name, image: user.image), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.name ?? user.id, + style: context.appTextStyles.footnoteBold, + ), + Text( + comment.createdAt.displayRelativeTime, + style: context.appTextStyles.footnote, + ), + const SizedBox(height: 8), + Text(comment.text ?? ''), + ], + ), + ), + ], + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () => onReplyClick(comment), + style: TextButton.styleFrom( + foregroundColor: context.appColors.textLowEmphasis, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + 'Reply', + style: context.appTextStyles.footnote.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + ), + ActionButton( + icon: Icon( + switch (hasOwnHeart) { + true => Icons.favorite_rounded, + false => Icons.favorite_outline_rounded, + }, + ), + count: heartsCount, + color: hasOwnHeart ? context.appColors.accentError : null, + onTap: () => onHeartClick(comment, !hasOwnHeart), + ), + ], + ), + for (final reply in comment.replies ?? []) + Padding( + padding: const EdgeInsets.only(left: 32), + child: UserCommentItem( + comment: reply, + onHeartClick: onHeartClick, + onReplyClick: onReplyClick, + onLongPressComment: onLongPressComment, + ), + ), + ], + ); + } +} diff --git a/sample_app/lib/screens/user_feed/feed/user_feed.dart b/sample_app/lib/screens/user_feed/feed/user_feed.dart new file mode 100644 index 00000000..08795cc6 --- /dev/null +++ b/sample_app/lib/screens/user_feed/feed/user_feed.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_state_notifier/flutter_state_notifier.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../../core/di/di_initializer.dart'; +import '../../../theme/theme.dart'; +import '../comment/user_comments.dart'; +import 'user_feed_item.dart'; + +class UserFeed extends StatelessWidget { + const UserFeed({ + super.key, + required this.userFeed, + this.scrollController, + }); + + final Feed userFeed; + final ScrollController? scrollController; + + @override + Widget build(BuildContext context) { + final client = locator(); + + return StateNotifierBuilder( + stateNotifier: userFeed.notifier, + builder: (context, state, child) { + final activities = state.activities; + final canLoadMore = state.canLoadMoreActivities; + + if (activities.isEmpty) return const EmptyActivities(); + + return RefreshIndicator( + onRefresh: userFeed.getOrCreate, + child: ListView.separated( + controller: scrollController, + itemCount: activities.length + 1, + separatorBuilder: (context, index) => Divider( + height: 1, + color: context.appColors.borders, + ), + itemBuilder: (context, index) { + if (index == activities.length) { + return switch (canLoadMore) { + true => TextButton( + onPressed: userFeed.queryMoreActivities, + child: const Text('Load more...'), + ), + false => const Padding( + padding: EdgeInsets.all(16), + child: Center( + child: Text('End of feed'), + ), + ) + }; + } + + final activity = activities[index]; + final parentActivity = activity.parent; + final baseActivity = activity.parent ?? activity; + + return Padding( + padding: const EdgeInsets.all(8), + child: Column( + spacing: 8, + children: [ + if (parentActivity != null) ...[ + ActivityRepostIndicator( + user: activity.user, + data: parentActivity, + ), + ], + UserFeedItem( + data: activity, + user: baseActivity.user, + text: baseActivity.text ?? '', + attachments: baseActivity.attachments, + currentUserId: client.user.id, + onCommentClick: () { + _onCommentClick(context, activity); + }, + onHeartClick: (isAdding) { + _onHeartClick(activity, isAdding); + }, + onRepostClick: (message) { + _onRepostClick(context, activity, message); + }, + onBookmarkClick: () { + _onBookmarkClick(context, activity); + }, + onDeleteClick: () {}, + onEditSave: (text) {}, + ), + ], + ), + ); + }, + ), + ); + }, + ); + } + + void _onCommentClick(BuildContext context, ActivityData activity) { + showModalBottomSheet( + context: context, + useSafeArea: true, + isScrollControlled: true, + backgroundColor: context.appColors.appBg, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + builder: (context) => DraggableScrollableSheet( + snap: true, + expand: false, + snapSizes: const [0.5, 1], + builder: (context, scrollController) { + return UserComments( + feed: userFeed, + activityId: activity.id, + scrollController: scrollController, + ); + }, + ), + ); + } + + void _onHeartClick(ActivityData activity, bool isAdding) { + if (isAdding) { + userFeed.addReaction( + activityId: activity.id, + request: const AddReactionRequest(type: 'heart'), + ); + } else { + userFeed.deleteReaction( + activityId: activity.id, + type: 'heart', + ); + } + } + + void _onRepostClick( + BuildContext context, + ActivityData activity, + String? message, + ) { + userFeed.repost(activityId: activity.id, text: message); + } + + void _onBookmarkClick(BuildContext context, ActivityData activity) { + if (activity.ownBookmarks.isNotEmpty) { + userFeed.deleteBookmark(activityId: activity.id); + } else { + userFeed.addBookmark(activityId: activity.id); + } + } +} + +class ActivityRepostIndicator extends StatelessWidget { + const ActivityRepostIndicator({ + super.key, + required this.user, + required this.data, + }); + + final UserData user; + final ActivityData data; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Icon( + Icons.repeat, + size: 16, + ), + const SizedBox(width: 4), + Text('${user.name} reposted'), + ], + ); + } +} + +class EmptyActivities extends StatelessWidget { + const EmptyActivities({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.dynamic_feed_rounded, + size: 64, + color: context.appColors.textLowEmphasis, + ), + const SizedBox(height: 16), + Text( + 'No activities yet', + style: context.appTextStyles.headline.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + const SizedBox(height: 8), + Text( + 'Your feed is empty. Start by creating a post!', + style: context.appTextStyles.body.copyWith( + color: context.appColors.textLowEmphasis, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/sample_app/lib/screens/user_feed/widgets/activity_content.dart b/sample_app/lib/screens/user_feed/feed/user_feed_item.dart similarity index 97% rename from sample_app/lib/screens/user_feed/widgets/activity_content.dart rename to sample_app/lib/screens/user_feed/feed/user_feed_item.dart index 17c0fe9c..5f71ca8c 100644 --- a/sample_app/lib/screens/user_feed/widgets/activity_content.dart +++ b/sample_app/lib/screens/user_feed/feed/user_feed_item.dart @@ -7,8 +7,8 @@ import '../../../widgets/action_button.dart'; import '../../../widgets/attachments/attachments.dart'; import '../../../widgets/user_avatar.dart'; -class ActivityContent extends StatelessWidget { - const ActivityContent({ +class UserFeedItem extends StatelessWidget { + const UserFeedItem({ super.key, required this.user, required this.text, @@ -176,7 +176,7 @@ class _UserActions extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ ActionButton( - icon: const Icon(Icons.comment_outlined), + icon: const Icon(Icons.chat_bubble_outline_rounded), count: data.commentCount, onTap: onCommentClick, ), diff --git a/sample_app/lib/screens/user_feed/notification/notification_item.dart b/sample_app/lib/screens/user_feed/notification/notification_item.dart index c8bb301b..6c91a016 100644 --- a/sample_app/lib/screens/user_feed/notification/notification_item.dart +++ b/sample_app/lib/screens/user_feed/notification/notification_item.dart @@ -61,12 +61,12 @@ class NotificationItem extends StatelessWidget { final notificationType = activity.notificationType; final iconData = switch (notificationType) { - 'follow' => Icons.person_add, - 'comment' => Icons.chat_bubble_outline, - 'reaction' => Icons.favorite_outline, - 'comment_reaction' => Icons.thumb_up, - 'mention' => Icons.alternate_email, - _ => Icons.notifications_outlined, + 'follow' => Icons.person_add_rounded, + 'comment' => Icons.chat_bubble_rounded, + 'reaction' => Icons.favorite_rounded, + 'comment_reaction' => Icons.thumb_up_rounded, + 'mention' => Icons.alternate_email_rounded, + _ => Icons.notifications_rounded, }; final iconColor = switch (notificationType) { diff --git a/sample_app/lib/screens/user_feed/profile/profile_header.dart b/sample_app/lib/screens/user_feed/profile/profile_header.dart new file mode 100644 index 00000000..d699e539 --- /dev/null +++ b/sample_app/lib/screens/user_feed/profile/profile_header.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../../theme/extensions/theme_extensions.dart'; +import '../../../widgets/user_avatar.dart'; + +class ProfileHeader extends StatelessWidget { + const ProfileHeader({ + super.key, + required this.user, + required this.membersCount, + required this.followingCount, + required this.followersCount, + }); + + final User user; + final int membersCount; + final int followingCount; + final int followersCount; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + // Left side - Avatar and User Info + Column( + children: [ + UserAvatar.large(user: user), + const SizedBox(height: 8), + Text( + user.name, + textAlign: TextAlign.center, + style: context.appTextStyles.bodyBold, + ), + Text( + '@${user.id}', + textAlign: TextAlign.center, + style: context.appTextStyles.footnote.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + ], + ), + const SizedBox(width: 32), + // Right side - Stats Box + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + border: Border.all(color: context.appColors.borders), + borderRadius: BorderRadius.circular(12), + ), + child: IntrinsicHeight( + child: Row( + spacing: 4, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + child: _StatColumn( + count: membersCount, + label: 'Members', + ), + ), + VerticalDivider( + width: 1, + color: context.appColors.borders, + ), + Expanded( + child: _StatColumn( + count: followingCount, + label: 'Following', + ), + ), + VerticalDivider( + width: 1, + color: context.appColors.borders, + ), + Expanded( + child: _StatColumn( + count: followersCount, + label: 'Followers', + ), + ), + ], + ), + ), + ), + ), + ], + ); + } +} + +class _StatColumn extends StatelessWidget { + const _StatColumn({ + required this.count, + required this.label, + }); + + final int count; + final String label; + + @override + Widget build(BuildContext context) { + return Column( + spacing: 4, + children: [ + Text( + count.toString(), + style: context.appTextStyles.headlineBold, + ), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.appTextStyles.footnote.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + ], + ); + } +} diff --git a/sample_app/lib/screens/user_feed/profile/profile_list_item.dart b/sample_app/lib/screens/user_feed/profile/profile_list_item.dart new file mode 100644 index 00000000..d777e8a0 --- /dev/null +++ b/sample_app/lib/screens/user_feed/profile/profile_list_item.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../../theme/extensions/theme_extensions.dart'; +import '../../../widgets/user_avatar.dart'; + +class MemberListItem extends StatelessWidget { + const MemberListItem({super.key, required this.member}); + + final FeedMemberData member; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: UserAvatar.listTile( + user: User( + id: member.user.id, + name: member.user.name, + image: member.user.image, + ), + ), + title: Text( + member.user.name ?? member.user.id, + style: context.appTextStyles.body, + ), + subtitle: Text( + '@${member.user.id}', + style: context.appTextStyles.footnote.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + trailing: Chip( + side: BorderSide.none, + visualDensity: VisualDensity.compact, + label: Text(member.role, style: context.appTextStyles.footnoteBold), + backgroundColor: context.appColors.accentInfo, + padding: const EdgeInsets.symmetric(horizontal: 8), + labelPadding: const EdgeInsets.symmetric(horizontal: 4), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ); + } +} + +class FollowRequestListItem extends StatelessWidget { + const FollowRequestListItem({ + super.key, + required this.followRequest, + required this.onAcceptPressed, + required this.onRejectPressed, + }); + + final FollowData followRequest; + final VoidCallback onAcceptPressed; + final VoidCallback onRejectPressed; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: UserAvatar.listTile( + user: User( + id: followRequest.sourceFeed.createdBy.id, + name: followRequest.sourceFeed.createdBy.name, + image: followRequest.sourceFeed.createdBy.image, + ), + ), + title: Text( + followRequest.sourceFeed.createdBy.name ?? + followRequest.sourceFeed.createdBy.id, + style: context.appTextStyles.body, + ), + subtitle: Text( + maxLines: 1, + '@${followRequest.sourceFeed.createdBy.id}', + overflow: TextOverflow.ellipsis, + style: context.appTextStyles.footnote.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + OutlinedButton( + onPressed: onRejectPressed, + style: OutlinedButton.styleFrom( + minimumSize: Size.zero, + foregroundColor: context.appColors.accentError, + side: BorderSide(color: context.appColors.accentError), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: context.appTextStyles.footnoteBold, + ), + child: const Text('Reject'), + ), + OutlinedButton( + onPressed: onAcceptPressed, + style: OutlinedButton.styleFrom( + minimumSize: Size.zero, + foregroundColor: context.appColors.accentPrimary, + side: BorderSide(color: context.appColors.accentPrimary), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: context.appTextStyles.footnoteBold, + ), + child: const Text('Accept'), + ), + ], + ), + ); + } +} + +class FollowingListItem extends StatelessWidget { + const FollowingListItem({ + super.key, + required this.follow, + required this.onUnfollowPressed, + }); + + final FollowData follow; + final VoidCallback onUnfollowPressed; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: UserAvatar.listTile( + user: User( + id: follow.targetFeed.createdBy.id, + name: follow.targetFeed.createdBy.name, + image: follow.targetFeed.createdBy.image, + ), + ), + title: Text( + follow.targetFeed.createdBy.name ?? follow.targetFeed.createdBy.id, + style: context.appTextStyles.body, + ), + subtitle: Text( + maxLines: 1, + '@${follow.targetFeed.createdBy.id}', + overflow: TextOverflow.ellipsis, + style: context.appTextStyles.footnote.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + trailing: OutlinedButton( + onPressed: onUnfollowPressed, + style: OutlinedButton.styleFrom( + minimumSize: Size.zero, + foregroundColor: context.appColors.accentError, + side: BorderSide(color: context.appColors.accentError), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: context.appTextStyles.footnoteBold, + ), + child: const Text('Unfollow'), + ), + ); + } +} + +class SuggestionListItem extends StatelessWidget { + const SuggestionListItem({ + super.key, + required this.suggestion, + required this.onFollowPressed, + }); + + final FeedData suggestion; + final VoidCallback onFollowPressed; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: UserAvatar.listTile( + user: User( + id: suggestion.createdBy.id, + name: suggestion.createdBy.name, + image: suggestion.createdBy.image, + ), + ), + title: Text( + suggestion.createdBy.name ?? suggestion.createdBy.id, + style: context.appTextStyles.body, + ), + subtitle: Text( + maxLines: 1, + '@${suggestion.createdBy.id}', + overflow: TextOverflow.ellipsis, + style: context.appTextStyles.footnote.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + trailing: OutlinedButton( + onPressed: onFollowPressed, + style: OutlinedButton.styleFrom( + minimumSize: Size.zero, + foregroundColor: context.appColors.accentPrimary, + side: BorderSide(color: context.appColors.accentPrimary), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: context.appTextStyles.footnoteBold, + ), + child: const Text('Follow'), + ), + ); + } +} diff --git a/sample_app/lib/screens/user_feed/profile/profile_section.dart b/sample_app/lib/screens/user_feed/profile/profile_section.dart new file mode 100644 index 00000000..66a7e526 --- /dev/null +++ b/sample_app/lib/screens/user_feed/profile/profile_section.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; + +import '../../../theme/extensions/theme_extensions.dart'; + +class ProfileSection extends StatelessWidget { + const ProfileSection({ + super.key, + required this.title, + required this.items, + required this.emptyMessage, + required this.itemBuilder, + }); + + final String title; + final List items; + final String emptyMessage; + final Widget Function(T item) itemBuilder; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + _SectionHeader( + title: title, + count: items.length, + ), + DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: context.appColors.borders), + borderRadius: BorderRadius.circular(12), + ), + child: Builder( + builder: (context) { + if (items.isEmpty) { + return _EmptyStateMessage(message: emptyMessage); + } + + return MediaQuery.removePadding( + context: context, + // Workaround for the bottom padding issue. + // Link: https://github.com/flutter/flutter/issues/156149 + removeTop: true, + removeBottom: true, + child: ListView.separated( + shrinkWrap: true, + itemCount: items.length, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + final item = items[index]; + return itemBuilder(item); + }, + separatorBuilder: (context, index) => Divider( + height: 1, + color: context.appColors.borders, + ), + ), + ); + }, + ), + ), + ], + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({ + required this.title, + required this.count, + }); + + final String title; + final int count; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: context.appTextStyles.headlineBold, + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: context.appColors.borders, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '$count', + style: context.appTextStyles.footnoteBold, + ), + ), + ], + ); + } +} + +class _EmptyStateMessage extends StatelessWidget { + const _EmptyStateMessage({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: Text( + message, + style: context.appTextStyles.footnote.copyWith( + color: context.appColors.textLowEmphasis, + ), + textAlign: TextAlign.center, + ), + ), + ); + } +} diff --git a/sample_app/lib/screens/user_feed/profile/user_profile.dart b/sample_app/lib/screens/user_feed/profile/user_profile.dart new file mode 100644 index 00000000..589594ad --- /dev/null +++ b/sample_app/lib/screens/user_feed/profile/user_profile.dart @@ -0,0 +1,157 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_state_notifier/flutter_state_notifier.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../../core/di/di_initializer.dart'; +import 'profile_header.dart'; +import 'profile_list_item.dart'; +import 'profile_section.dart'; + +int _sortFeedsByCreatorName(FeedData a, FeedData b) { + final nameA = a.createdBy.id; + final nameB = b.createdBy.id; + return nameA.toLowerCase().compareTo(nameB.toLowerCase()); +} + +class UserProfile extends StatefulWidget { + const UserProfile({ + super.key, + required this.userFeed, + this.scrollController, + }); + + final Feed userFeed; + final ScrollController? scrollController; + + @override + State createState() => _UserProfileState(); +} + +class _UserProfileState extends State { + StreamFeedsClient get client => locator(); + + List? followSuggestions; + + @override + void initState() { + super.initState(); + _queryFollowSuggestions(); + } + + @override + void didUpdateWidget(covariant UserProfile oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.userFeed != widget.userFeed) _queryFollowSuggestions(); + } + + Future _queryFollowSuggestions() async { + final result = await widget.userFeed.queryFollowSuggestions(); + if (mounted) _updateFollowSuggestions(result.getOrNull()); + } + + void _updateFollowSuggestions(List? suggestions) { + final updatedSuggestions = suggestions?.sorted(_sortFeedsByCreatorName); + setState(() => followSuggestions = updatedSuggestions); + } + + @override + Widget build(BuildContext context) { + return StateNotifierBuilder( + stateNotifier: widget.userFeed.notifier, + builder: (context, state, child) { + final feedMembers = state.members; + final followRequests = state.followRequests; + final following = state.following; + final followers = state.followers; + final currentUser = client.user; + + return SingleChildScrollView( + controller: widget.scrollController, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 24, + children: [ + // Profile Header Section + ProfileHeader( + user: currentUser, + membersCount: feedMembers.length, + followingCount: following.length, + followersCount: followers.length, + ), + + // Members Section + ProfileSection( + title: 'Members', + items: feedMembers, + emptyMessage: 'No members yet', + itemBuilder: (member) => MemberListItem(member: member), + ), + + // Follow Requests Section + ProfileSection( + title: 'Follow Requests', + items: followRequests, + emptyMessage: 'No pending requests', + itemBuilder: (followRequest) => FollowRequestListItem( + followRequest: followRequest, + onAcceptPressed: () => widget.userFeed.acceptFollow( + sourceFid: followRequest.sourceFeed.fid, + ), + onRejectPressed: () => widget.userFeed.rejectFollow( + sourceFid: followRequest.sourceFeed.fid, + ), + ), + ), + + // Following Section + ProfileSection( + title: 'Following', + items: following, + emptyMessage: 'Not following anyone yet', + itemBuilder: (follow) => FollowingListItem( + follow: follow, + onUnfollowPressed: () async { + final result = await widget.userFeed.unfollow( + targetFid: follow.targetFeed.fid, + ); + + // Add the unfollowed user back to suggestions + result.onSuccess( + (_) => _updateFollowSuggestions( + [...?followSuggestions, follow.targetFeed], + ), + ); + }, + ), + ), + + // Follow Suggestions Section + ProfileSection( + title: 'Suggested', + items: followSuggestions ?? [], + emptyMessage: 'No suggestions available', + itemBuilder: (suggestion) => SuggestionListItem( + suggestion: suggestion, + onFollowPressed: () async { + final result = await widget.userFeed.follow( + targetFid: suggestion.fid, + ); + + // Remove the followed user from suggestions + result.onSuccess( + (_) => _updateFollowSuggestions([ + ...?followSuggestions?.where((it) => it != suggestion), + ]), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/sample_app/lib/screens/user_feed/user_feed_screen.dart b/sample_app/lib/screens/user_feed/user_feed_screen.dart index bd5a9ec6..ebef47bc 100644 --- a/sample_app/lib/screens/user_feed/user_feed_screen.dart +++ b/sample_app/lib/screens/user_feed/user_feed_screen.dart @@ -3,16 +3,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_state_notifier/flutter_state_notifier.dart'; import 'package:stream_feeds/stream_feeds.dart'; -import '../../../theme/extensions/theme_extensions.dart'; -import '../../../widgets/user_avatar.dart'; import '../../app/content/auth_controller.dart'; import '../../core/di/di_initializer.dart'; +import '../../theme/extensions/theme_extensions.dart'; +import '../../widgets/breakpoint.dart'; +import '../../widgets/user_avatar.dart'; +import 'feed/user_feed.dart'; import 'notification/notification_feed.dart'; -import 'widgets/activity_comments_view.dart'; -import 'widgets/activity_content.dart'; +import 'profile/user_profile.dart'; import 'widgets/create_activity_bottom_sheet.dart'; -import 'widgets/user_feed_appbar.dart'; -import 'widgets/user_profile_view.dart'; @RoutePage() class UserFeedScreen extends StatefulWidget { @@ -30,7 +29,7 @@ class _UserFeedScreenState extends State { id: client.user.id, ); - late final feed = client.feedFromQuery( + late final userFeed = client.feedFromQuery( FeedQuery( fid: FeedId(group: 'user', id: client.user.id), data: FeedInputData( @@ -43,13 +42,13 @@ class _UserFeedScreenState extends State { @override void initState() { super.initState(); - feed.getOrCreate(); + userFeed.getOrCreate(); notificationFeed.getOrCreate(); } @override void dispose() { - feed.dispose(); + userFeed.dispose(); notificationFeed.dispose(); super.dispose(); } @@ -61,129 +60,30 @@ class _UserFeedScreenState extends State { @override Widget build(BuildContext context) { - final currentUser = client.user; + final breakpoint = Breakpoint.fromContext(context); - final wideScreen = MediaQuery.sizeOf(context).width > 600; - - return StateNotifierBuilder( - stateNotifier: feed.notifier, - builder: (context, state, child) { - final activities = state.activities; - final canLoadMore = state.canLoadMoreActivities; - - final feedWidget = activities.isEmpty - ? const EmptyActivities() - : RefreshIndicator( - onRefresh: () => feed.getOrCreate(), - child: ListView.separated( - itemCount: activities.length + 1, - separatorBuilder: (context, index) => Divider( - height: 1, - color: context.appColors.borders, - ), - itemBuilder: (context, index) { - if (index == activities.length) { - return canLoadMore - ? TextButton( - onPressed: () => feed.queryMoreActivities(), - child: const Text('Load more...'), - ) - : const Text('End of feed'); - } - - final activity = activities[index]; - final parentActivity = activity.parent; - final baseActivity = activity.parent ?? activity; - - return Padding( - padding: const EdgeInsets.all(8), - child: Column( - children: [ - if (parentActivity != null) ...[ - ActivityRepostIndicator( - user: activity.user, - data: parentActivity, - ), - const SizedBox(height: 8), - ], - ActivityContent( - user: baseActivity.user, - text: baseActivity.text ?? '', - attachments: baseActivity.attachments, - data: activity, - currentUserId: currentUser.id, - onCommentClick: () => - _onCommentClick(context, activity), - onHeartClick: (isAdding) => - _onHeartClick(activity, isAdding), - onRepostClick: (message) => - _onRepostClick(context, activity, message), - onBookmarkClick: () => - _onBookmarkClick(context, activity), - onDeleteClick: () {}, - onEditSave: (text) {}, - ), - ], - ), - ); - }, - ), - ); - - if (!wideScreen) { - return _buildScaffold( - context, - feedWidget, - onLogout: _onLogout, - onProfileTap: () { - _showProfileBottomSheet(context, client, feed); - }, - ); - } - - return _buildScaffold( - context, - Row( - children: [ - SizedBox( - width: 250, - child: UserProfileView(feedsClient: client, feed: feed), - ), - const SizedBox(width: 16), - Expanded(child: feedWidget), - ], - ), - onLogout: _onLogout, - ); - }, - ); - } - - Widget _buildScaffold( - BuildContext context, - Widget body, { - VoidCallback? onLogout, - VoidCallback? onProfileTap, - }) { return Scaffold( - appBar: UserFeedAppbar( - leading: GestureDetector( - onTap: onProfileTap, - child: Center( - child: UserAvatar.appBar(user: client.user), - ), - ), + appBar: AppBar( title: Text( 'Stream Feeds', style: context.appTextStyles.headlineBold, ), + leading: switch (breakpoint) { + Breakpoint.compact => GestureDetector( + onTap: () => _showUserProfile(context), + child: Center( + child: UserAvatar.appBar(user: client.user), + ), + ), + _ => null, + }, actions: [ StateNotifierBuilder( stateNotifier: notificationFeed.notifier, builder: (context, notificationState, _) { final status = notificationState.notificationStatus; return IconButton( - onPressed: _showNotificationFeedModal, + onPressed: _showNotificationFeed, icon: Badge( isLabelVisible: (status?.unseen ?? 0) > 0, backgroundColor: context.appColors.accentError, @@ -196,7 +96,7 @@ class _UserFeedScreenState extends State { }, ), IconButton( - onPressed: onLogout, + onPressed: _onLogout, icon: Icon( Icons.logout, color: context.appColors.textLowEmphasis, @@ -204,7 +104,29 @@ class _UserFeedScreenState extends State { ), ], ), - body: body, + body: Row( + children: [ + ...?switch (breakpoint) { + Breakpoint.compact => null, + _ => [ + Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 280, + maxWidth: 420, + ), + child: UserProfile(userFeed: userFeed), + ), + ), + VerticalDivider( + width: 8, + color: context.appColors.borders, + ), + ], + }, + Flexible(child: UserFeed(userFeed: userFeed)), + ].nonNulls.toList(), + ), floatingActionButton: FloatingActionButton( elevation: 4, onPressed: _showCreateActivityBottomSheet, @@ -215,60 +137,34 @@ class _UserFeedScreenState extends State { ); } - void _showProfileBottomSheet( - BuildContext context, - StreamFeedsClient client, - Feed feed, - ) { - showModalBottomSheet( - context: context, - builder: (context) => UserProfileView(feedsClient: client, feed: feed), - ); - } - - void _onCommentClick(BuildContext context, ActivityData activity) { - showModalBottomSheet( + Future _showUserProfile(BuildContext context) { + return showModalBottomSheet( context: context, - builder: (context) => ActivityCommentsView( - activityId: activity.id, - feed: feed, - client: client, + useSafeArea: true, + isScrollControlled: true, + backgroundColor: context.appColors.appBg, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + builder: (context) => DraggableScrollableSheet( + snap: true, + expand: false, + snapSizes: const [0.5, 1], + builder: (context, scrollController) { + return UserProfile( + userFeed: userFeed, + scrollController: scrollController, + ); + }, ), ); } - void _onHeartClick(ActivityData activity, bool isAdding) { - if (isAdding) { - feed.addReaction( - activityId: activity.id, - request: const AddReactionRequest(type: 'heart'), - ); - } else { - feed.deleteReaction( - activityId: activity.id, - type: 'heart', - ); - } - } - - void _onRepostClick( - BuildContext context, - ActivityData activity, - String? message, - ) { - feed.repost(activityId: activity.id, text: message); - } - - void _onBookmarkClick(BuildContext context, ActivityData activity) { - if (activity.ownBookmarks.isNotEmpty) { - feed.deleteBookmark(activityId: activity.id); - } else { - feed.addBookmark(activityId: activity.id); - } - } - - Future _showNotificationFeedModal() { - return showModalBottomSheet( + Future _showNotificationFeed() { + return showModalBottomSheet( context: context, useSafeArea: true, isScrollControlled: true, @@ -296,16 +192,23 @@ class _UserFeedScreenState extends State { Future _showCreateActivityBottomSheet() async { final request = await showModalBottomSheet( context: context, + useSafeArea: true, isScrollControlled: true, - backgroundColor: Colors.transparent, + backgroundColor: context.appColors.appBg, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), builder: (context) => CreateActivityBottomSheet( currentUser: client.user, - feedId: feed.query.fid, + feedId: userFeed.query.fid, ), ); if (request == null) return; - final result = await feed.addActivity(request: request); + final result = await userFeed.addActivity(request: request); switch (result) { case Success(): @@ -329,39 +232,3 @@ class _UserFeedScreenState extends State { } } } - -class ActivityRepostIndicator extends StatelessWidget { - const ActivityRepostIndicator({ - super.key, - required this.user, - required this.data, - }); - - final UserData user; - final ActivityData data; - - @override - Widget build(BuildContext context) { - return Row( - children: [ - const Icon( - Icons.repeat, - size: 16, - ), - const SizedBox(width: 4), - Text('${user.name} reposted'), - ], - ); - } -} - -class EmptyActivities extends StatelessWidget { - const EmptyActivities({super.key}); - - @override - Widget build(BuildContext context) { - return const Center( - child: Text('No activities yet. Start by creating a post!'), - ); - } -} diff --git a/sample_app/lib/screens/user_feed/widgets/activity_comments_view.dart b/sample_app/lib/screens/user_feed/widgets/activity_comments_view.dart deleted file mode 100644 index 4107a11e..00000000 --- a/sample_app/lib/screens/user_feed/widgets/activity_comments_view.dart +++ /dev/null @@ -1,380 +0,0 @@ -// ignore_for_file: avoid_positional_boolean_parameters - -import 'package:flutter/material.dart'; -import 'package:flutter_state_notifier/flutter_state_notifier.dart'; -import 'package:stream_feeds/stream_feeds.dart'; - -import '../../../theme/extensions/theme_extensions.dart'; -import '../../../utils/date_time_extensions.dart'; -import '../../../widgets/action_button.dart'; -import '../../../widgets/user_avatar.dart'; - -class ActivityCommentsView extends StatefulWidget { - const ActivityCommentsView({ - required this.activityId, - required this.feed, - required this.client, - super.key, - }); - final String activityId; - final Feed feed; - final StreamFeedsClient client; - - @override - State createState() => _ActivityCommentsViewState(); -} - -class _ActivityCommentsViewState extends State { - late Activity activity; - RemoveListener? _removeFeedListener; - late List capabilities; - - @override - void initState() { - super.initState(); - _getActivity(); - _observeFeedCapabilities(); - } - - @override - void didUpdateWidget(covariant ActivityCommentsView oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.activityId != widget.activityId || - oldWidget.feed != widget.feed) { - activity.dispose(); - _getActivity(); - } - if (oldWidget.feed != widget.feed) { - _observeFeedCapabilities(); - } - } - - @override - void dispose() { - _removeFeedListener?.call(); - activity.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Padding( - padding: const EdgeInsets.all(16), - child: StateNotifierBuilder( - stateNotifier: activity.notifier, - builder: (context, state, child) { - return CommentsList( - totalComments: state.activity?.commentCount ?? 0, - comments: state.comments, - onHeartClick: _onHeartClick, - onLoadMore: - state.canLoadMoreComments ? activity.queryMoreComments : null, - onReplyClick: (comment) => _reply(context, comment), - onLongPressComment: (comment) => - _onLongPressComment(context, comment), - ); - }, - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: () => _reply(context, null), - backgroundColor: context.appColors.accentPrimary, - foregroundColor: context.appColors.appBg, - child: const Icon(Icons.add), - ), - ); - } - - void _observeFeedCapabilities() { - _removeFeedListener?.call(); - _removeFeedListener = widget.feed.notifier.addListener(_onFeedStateChange); - } - - void _onFeedStateChange(FeedState state) { - capabilities = state.ownCapabilities; - } - - Future _getActivity() async { - activity = widget.client.activity( - activityId: widget.activityId, - fid: widget.feed.fid, - ); - await activity.get(); - } - - void _onHeartClick(ThreadedCommentData comment, bool isAdding) { - const type = 'heart'; - - if (isAdding) { - activity.addCommentReaction( - commentId: comment.id, - request: const AddCommentReactionRequest(type: type), - ); - } else { - activity.deleteCommentReaction( - comment.id, - type, - ); - } - } - - Future _reply( - BuildContext context, - ThreadedCommentData? parentComment, - ) async { - final text = await _displayTextInputDialog(context, title: 'Add comment'); - if (text == null) return; - - await activity.addComment( - request: ActivityAddCommentRequest( - comment: text, - parentId: parentComment?.id, - activityId: activity.activityId, - ), - ); - } - - void _onLongPressComment(BuildContext context, ThreadedCommentData comment) { - final isOwnComment = comment.user.id == widget.client.user.id; - if (!isOwnComment) return; - final canEdit = capabilities.contains(FeedOwnCapability.updateComment); - final canDelete = capabilities.contains(FeedOwnCapability.deleteComment); - if (!canEdit && !canDelete) return; - - final chooseActionDialog = SimpleDialog( - children: [ - if (canEdit) - SimpleDialogOption( - child: const Text('Edit'), - onPressed: () { - Navigator.pop(context); - _editComment(context, comment); - }, - ), - if (canDelete) - SimpleDialogOption( - child: const Text('Delete'), - onPressed: () { - activity.deleteComment(comment.id); - Navigator.pop(context); - }, - ), - ], - ); - - showDialog( - context: context, - builder: (context) { - return chooseActionDialog; - }, - ); - } - - Future _editComment( - BuildContext context, - ThreadedCommentData comment, - ) async { - final text = await _displayTextInputDialog( - context, - title: 'Edit comment', - initialText: comment.text, - positiveAction: 'Edit', - ); - - if (text == null) return; - - await activity.updateComment( - comment.id, - ActivityUpdateCommentRequest(comment: text), - ); - } - - Future _displayTextInputDialog( - BuildContext context, { - required String title, - String? initialText, - String positiveAction = 'Add', - }) async { - final textFieldController = TextEditingController(); - textFieldController.text = initialText ?? ''; - return showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text(title), - content: TextField(controller: textFieldController), - actions: [ - TextButton( - child: const Text('Cancel'), - onPressed: () { - Navigator.pop(context); - }, - ), - TextButton( - child: Text(positiveAction), - onPressed: () { - Navigator.pop(context, textFieldController.text); - }, - ), - ], - ); - }, - ); - } -} - -class CommentsList extends StatelessWidget { - const CommentsList({ - super.key, - required this.totalComments, - required this.comments, - required this.onHeartClick, - required this.onLoadMore, - required this.onReplyClick, - required this.onLongPressComment, - }); - - final int totalComments; - final List comments; - final void Function(ThreadedCommentData comment, bool isAdding) onHeartClick; - final ValueSetter onReplyClick; - final ValueSetter onLongPressComment; - final VoidCallback? onLoadMore; - - @override - Widget build(BuildContext context) { - return ListView.separated( - separatorBuilder: (context, index) => const Divider(), - itemCount: comments.length + 2, - itemBuilder: (context, index) { - if (index == 0) { - return Text( - 'Comments', - style: context.appTextStyles.headlineBold, - ); - } - - if (index == comments.length + 1) { - if (onLoadMore != null) { - return TextButton( - onPressed: onLoadMore, - child: const Text('Load more...'), - ); - } - return Padding( - padding: const EdgeInsets.all(16), - child: - Text('End of comments', style: context.appTextStyles.footnote), - ); - } - - final comment = comments[index - 1]; - - return CommentWidget( - comment: comment, - onHeartClick: onHeartClick, - onReplyClick: onReplyClick, - onLongPressComment: onLongPressComment, - ); - }, - ); - } -} - -class CommentWidget extends StatelessWidget { - const CommentWidget({ - super.key, - required this.comment, - required this.onHeartClick, - required this.onReplyClick, - required this.onLongPressComment, - }); - final ThreadedCommentData comment; - final ValueSetter onReplyClick; - final void Function(ThreadedCommentData comment, bool isAdding) onHeartClick; - final ValueSetter onLongPressComment; - - @override - Widget build(BuildContext context) { - final user = comment.user; - final heartsCount = comment.reactionGroups['heart']?.count ?? 0; - final hasOwnHeart = comment.ownReactions.any((it) => it.type == 'heart'); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), - GestureDetector( - onLongPress: () => onLongPressComment(comment), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - UserAvatar.appBar( - user: User(id: user.id, name: user.name, image: user.image), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - user.name ?? user.id, - style: context.appTextStyles.footnoteBold, - ), - Text( - comment.createdAt.displayRelativeTime, - style: context.appTextStyles.footnote, - ), - const SizedBox(height: 8), - Text(comment.text ?? ''), - ], - ), - ), - ], - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton( - onPressed: () => onReplyClick(comment), - style: TextButton.styleFrom( - foregroundColor: context.appColors.textLowEmphasis, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Text( - 'Reply', - style: context.appTextStyles.footnote.copyWith( - color: context.appColors.textLowEmphasis, - ), - ), - ), - ActionButton( - icon: Icon( - hasOwnHeart - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - ), - count: heartsCount, - color: hasOwnHeart ? context.appColors.accentError : null, - onTap: () => onHeartClick(comment, !hasOwnHeart), - ), - ], - ), - for (final reply in comment.replies ?? []) - Padding( - padding: const EdgeInsets.only(left: 32), - child: CommentWidget( - comment: reply, - onHeartClick: onHeartClick, - onReplyClick: onReplyClick, - onLongPressComment: onLongPressComment, - ), - ), - ], - ); - } -} diff --git a/sample_app/lib/screens/user_feed/widgets/create_activity_bottom_sheet.dart b/sample_app/lib/screens/user_feed/widgets/create_activity_bottom_sheet.dart index 97fea273..917cb9ab 100644 --- a/sample_app/lib/screens/user_feed/widgets/create_activity_bottom_sheet.dart +++ b/sample_app/lib/screens/user_feed/widgets/create_activity_bottom_sheet.dart @@ -50,12 +50,9 @@ class _CreateActivityBottomSheetState extends State { @override Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: context.appColors.appBg, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - ), - child: SafeArea( + return SafeArea( + child: Padding( + padding: MediaQuery.of(context).viewInsets, child: Column( spacing: 16, mainAxisSize: MainAxisSize.min, @@ -77,8 +74,8 @@ class _CreateActivityBottomSheetState extends State { onAttachmentsSelected: _addAttachments, ), - // Spacing at the bottom - const SizedBox(height: 16), + // Bottom padding to avoid being too close to screen edge + const SizedBox(height: 8), ], ), ), diff --git a/sample_app/lib/screens/user_feed/widgets/user_feed_appbar.dart b/sample_app/lib/screens/user_feed/widgets/user_feed_appbar.dart deleted file mode 100644 index 625c6d84..00000000 --- a/sample_app/lib/screens/user_feed/widgets/user_feed_appbar.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../theme/extensions/theme_extensions.dart'; - -class UserFeedAppbar extends StatelessWidget implements PreferredSizeWidget { - const UserFeedAppbar({ - super.key, - required this.title, - this.leading, - this.centerTitle, - required this.actions, - }); - - final Widget title; - final Widget? leading; - final bool? centerTitle; - final List actions; - - @override - Widget build(BuildContext context) { - return AppBar( - elevation: 0, - backgroundColor: context.appColors.barsBg, - foregroundColor: context.appColors.textHighEmphasis, - leading: leading, - actions: actions, - centerTitle: centerTitle, - title: title, - ); - } - - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); -} diff --git a/sample_app/lib/screens/user_feed/widgets/user_profile_view.dart b/sample_app/lib/screens/user_feed/widgets/user_profile_view.dart deleted file mode 100644 index decc2131..00000000 --- a/sample_app/lib/screens/user_feed/widgets/user_profile_view.dart +++ /dev/null @@ -1,246 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_state_notifier/flutter_state_notifier.dart'; -import 'package:stream_feeds/stream_feeds.dart'; - -import '../../../theme/extensions/theme_extensions.dart'; -import '../../../widgets/user_avatar.dart'; - -class UserProfileView extends StatefulWidget { - const UserProfileView({ - super.key, - required this.feedsClient, - required this.feed, - }); - - final StreamFeedsClient feedsClient; - final Feed feed; - - @override - State createState() => _UserProfileViewState(); -} - -class _UserProfileViewState extends State { - List? followSuggestions; - - @override - void initState() { - super.initState(); - widget.feed.queryFollowSuggestions(); - _queryFollowSuggestions(); - } - - @override - void didUpdateWidget(covariant UserProfileView oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.feed != widget.feed) { - _queryFollowSuggestions(); - } - } - - Future _queryFollowSuggestions() async { - followSuggestions = null; - final result = await widget.feed.queryFollowSuggestions(); - setState(() { - followSuggestions = result.getOrNull(); - }); - } - - @override - Widget build(BuildContext context) { - return StateNotifierBuilder( - stateNotifier: widget.feed.notifier, - builder: (context, state, child) { - final feedMembers = state.members; - final following = state.following; - final followers = state.followers; - - return ListView( - children: [ - Container( - alignment: Alignment.center, - padding: const EdgeInsets.all(16), - child: Text( - 'User Profile', - style: context.appTextStyles.headlineBold, - ), - ), - ProfileItem.text( - title: 'Feed members', - value: feedMembers.isEmpty - ? 'No feed members' - : feedMembers - .map((member) => member.user.name ?? member.user.id) - .join(', '), - ), - const Divider(), - ProfileItem( - title: 'Following', - child: following.isEmpty - ? const Text('You are not following anyone') - : Column( - children: following.map((follow) { - final feed = follow.targetFeed; - - return _FollowerItem( - follower: feed, - buttonText: 'Unfollow', - onButtonPressed: () => - widget.feed.unfollow(targetFid: feed.fid), - ); - }).toList(), - ), - ), - const Divider(), - ProfileItem( - title: 'Followers', - child: followers.isEmpty - ? const Text('You have no followers') - : Column( - children: following.map((follow) { - final feed = follow.sourceFeed; - - return _FollowerItem( - follower: feed, - buttonText: 'Remove follower', - onButtonPressed: () => - widget.feed.unfollow(targetFid: feed.fid), - ); - }).toList(), - ), - ), - if (followSuggestions != null) ...[ - const Divider(), - _FollowSuggestionsWidget( - followSuggestions: followSuggestions!, - onFollowPressed: (suggestion) { - widget.feed.follow(targetFid: suggestion.fid); - setState(() { - followSuggestions = followSuggestions - ?.where((it) => it != suggestion) - .toList(); - }); - }, - ), - ], - ], - ); - }, - ); - } -} - -class _FollowerItem extends StatelessWidget { - const _FollowerItem({ - required this.follower, - required this.buttonText, - required this.onButtonPressed, - }); - final FeedData follower; - final String buttonText; - final VoidCallback onButtonPressed; - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded(child: Text(follower.createdBy.name ?? follower.createdBy.id)), - TextButton(onPressed: onButtonPressed, child: Text(buttonText)), - ], - ); - } -} - -class _FollowSuggestionsWidget extends StatelessWidget { - const _FollowSuggestionsWidget({ - required this.followSuggestions, - required this.onFollowPressed, - }); - final List followSuggestions; - final ValueSetter onFollowPressed; - - @override - Widget build(BuildContext context) { - return ProfileItem( - title: 'Who to follow', - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: followSuggestions - .map( - (suggestion) => _FollowSuggestionWidget( - owner: suggestion.createdBy, - followedFeed: suggestion.fid, - onFollowPressed: () => onFollowPressed(suggestion), - ), - ) - .toList(), - ), - ), - ); - } -} - -class _FollowSuggestionWidget extends StatelessWidget { - const _FollowSuggestionWidget({ - required this.owner, - required this.followedFeed, - required this.onFollowPressed, - }); - final UserData owner; - final FeedId followedFeed; - final VoidCallback onFollowPressed; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - UserAvatar.small( - user: User(id: owner.id, name: owner.name, image: owner.image), - ), - Text(owner.name ?? owner.id), - TextButton(onPressed: onFollowPressed, child: const Text('Follow')), - ], - ), - ); - } -} - -class ProfileItem extends StatelessWidget { - const ProfileItem({ - super.key, - required this.title, - required this.child, - }); - - factory ProfileItem.text({ - required String title, - required String value, - }) { - return ProfileItem( - title: title, - child: Text(value), - ); - } - - final String title; - final Widget child; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: context.appTextStyles.bodyBold), - const SizedBox(height: 8), - child, - ], - ), - ); - } -} diff --git a/sample_app/lib/widgets/breakpoint.dart b/sample_app/lib/widgets/breakpoint.dart new file mode 100644 index 00000000..eeda1a32 --- /dev/null +++ b/sample_app/lib/widgets/breakpoint.dart @@ -0,0 +1,26 @@ +import 'package:flutter/widgets.dart'; + +/// Standard responsive breakpoints following Material Design guidelines. +enum Breakpoint { + /// Compact screens (phones) - 0 to 599dp + compact, + + /// Medium screens (tablets, small laptops) - 600 to 839dp + medium, + + /// Expanded screens (large tablets, desktops) - 840dp and up + expanded; + + /// Gets the current breakpoint for the given width. + static Breakpoint fromWidth(double width) { + if (width < 600) return Breakpoint.compact; + if (width < 840) return Breakpoint.medium; + return Breakpoint.expanded; + } + + /// Gets the current breakpoint from context. + static Breakpoint fromContext(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + return fromWidth(width); + } +}