|
| 1 | +import 'package:flutter/material.dart'; |
| 2 | +import 'package:flutter_state_notifier/flutter_state_notifier.dart'; |
| 3 | +import 'package:stream_feeds/stream_feeds.dart'; |
| 4 | + |
| 5 | +import '../../../core/di/di_initializer.dart'; |
| 6 | +import '../../../theme/extensions/theme_extensions.dart'; |
| 7 | +import 'user_comments_item.dart'; |
| 8 | + |
| 9 | +class UserComments extends StatefulWidget { |
| 10 | + const UserComments({ |
| 11 | + required this.activityId, |
| 12 | + required this.feed, |
| 13 | + this.scrollController, |
| 14 | + super.key, |
| 15 | + }); |
| 16 | + |
| 17 | + final String activityId; |
| 18 | + final Feed feed; |
| 19 | + final ScrollController? scrollController; |
| 20 | + |
| 21 | + @override |
| 22 | + State<UserComments> createState() => _UserCommentsState(); |
| 23 | +} |
| 24 | + |
| 25 | +class _UserCommentsState extends State<UserComments> { |
| 26 | + StreamFeedsClient get client => locator<StreamFeedsClient>(); |
| 27 | + |
| 28 | + late Activity activity; |
| 29 | + RemoveListener? _removeFeedListener; |
| 30 | + late List<FeedOwnCapability> capabilities; |
| 31 | + |
| 32 | + @override |
| 33 | + void initState() { |
| 34 | + super.initState(); |
| 35 | + _getActivity(); |
| 36 | + _observeFeedCapabilities(); |
| 37 | + } |
| 38 | + |
| 39 | + @override |
| 40 | + void didUpdateWidget(covariant UserComments oldWidget) { |
| 41 | + super.didUpdateWidget(oldWidget); |
| 42 | + if (oldWidget.activityId != widget.activityId || |
| 43 | + oldWidget.feed != widget.feed) { |
| 44 | + activity.dispose(); |
| 45 | + _getActivity(); |
| 46 | + } |
| 47 | + if (oldWidget.feed != widget.feed) { |
| 48 | + _observeFeedCapabilities(); |
| 49 | + } |
| 50 | + } |
| 51 | + |
| 52 | + @override |
| 53 | + void dispose() { |
| 54 | + _removeFeedListener?.call(); |
| 55 | + activity.dispose(); |
| 56 | + super.dispose(); |
| 57 | + } |
| 58 | + |
| 59 | + @override |
| 60 | + Widget build(BuildContext context) { |
| 61 | + return StateNotifierBuilder( |
| 62 | + stateNotifier: activity.notifier, |
| 63 | + builder: (context, state, child) { |
| 64 | + final comments = state.comments; |
| 65 | + final activity = state.activity; |
| 66 | + final canLoadMore = state.canLoadMoreComments; |
| 67 | + |
| 68 | + return Column( |
| 69 | + children: [ |
| 70 | + _buildHeader( |
| 71 | + context, |
| 72 | + activity, |
| 73 | + comments, |
| 74 | + ), |
| 75 | + Expanded( |
| 76 | + child: _buildUserCommentsList( |
| 77 | + context, |
| 78 | + comments, |
| 79 | + canLoadMore, |
| 80 | + ), |
| 81 | + ), |
| 82 | + ], |
| 83 | + ); |
| 84 | + }, |
| 85 | + ); |
| 86 | + } |
| 87 | + |
| 88 | + Widget _buildHeader( |
| 89 | + BuildContext context, |
| 90 | + ActivityData? activity, |
| 91 | + List<ThreadedCommentData> comments, |
| 92 | + ) { |
| 93 | + final totalComments = activity?.commentCount ?? 0; |
| 94 | + |
| 95 | + return Container( |
| 96 | + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), |
| 97 | + decoration: BoxDecoration( |
| 98 | + border: Border( |
| 99 | + bottom: BorderSide(color: context.appColors.borders), |
| 100 | + ), |
| 101 | + ), |
| 102 | + child: Row( |
| 103 | + mainAxisAlignment: MainAxisAlignment.spaceBetween, |
| 104 | + children: [ |
| 105 | + Column( |
| 106 | + crossAxisAlignment: CrossAxisAlignment.start, |
| 107 | + children: [ |
| 108 | + Text( |
| 109 | + 'Comments', |
| 110 | + style: context.appTextStyles.headlineBold, |
| 111 | + ), |
| 112 | + if (totalComments > 0) |
| 113 | + Text( |
| 114 | + '$totalComments ${totalComments == 1 ? 'comment' : 'comments'}', |
| 115 | + style: context.appTextStyles.footnote.copyWith( |
| 116 | + color: context.appColors.textLowEmphasis, |
| 117 | + ), |
| 118 | + ), |
| 119 | + ], |
| 120 | + ), |
| 121 | + OutlinedButton.icon( |
| 122 | + onPressed: _onReplyClick, |
| 123 | + icon: const Icon(Icons.chat_bubble_outline_rounded), |
| 124 | + label: const Text('Add'), |
| 125 | + style: OutlinedButton.styleFrom( |
| 126 | + minimumSize: Size.zero, |
| 127 | + iconColor: context.appColors.accentPrimary, |
| 128 | + foregroundColor: context.appColors.accentPrimary, |
| 129 | + side: BorderSide(color: context.appColors.accentPrimary), |
| 130 | + padding: const EdgeInsets.symmetric( |
| 131 | + horizontal: 12, |
| 132 | + vertical: 6, |
| 133 | + ), |
| 134 | + tapTargetSize: MaterialTapTargetSize.shrinkWrap, |
| 135 | + textStyle: context.appTextStyles.footnoteBold, |
| 136 | + ), |
| 137 | + ), |
| 138 | + ], |
| 139 | + ), |
| 140 | + ); |
| 141 | + } |
| 142 | + |
| 143 | + Widget _buildUserCommentsList( |
| 144 | + BuildContext context, |
| 145 | + List<ThreadedCommentData> comments, |
| 146 | + bool canLoadMore, |
| 147 | + ) { |
| 148 | + if (comments.isEmpty) return const EmptyComments(); |
| 149 | + |
| 150 | + return RefreshIndicator( |
| 151 | + onRefresh: _getActivity, |
| 152 | + child: ListView.separated( |
| 153 | + padding: const EdgeInsets.symmetric(horizontal: 16), |
| 154 | + controller: widget.scrollController, |
| 155 | + itemCount: comments.length + 1, |
| 156 | + separatorBuilder: (context, index) => Divider( |
| 157 | + height: 1, |
| 158 | + color: context.appColors.borders, |
| 159 | + ), |
| 160 | + itemBuilder: (context, index) { |
| 161 | + if (index == comments.length) { |
| 162 | + return switch (canLoadMore) { |
| 163 | + true => TextButton( |
| 164 | + onPressed: activity.queryMoreComments, |
| 165 | + child: const Text('Load more...'), |
| 166 | + ), |
| 167 | + false => const Padding( |
| 168 | + padding: EdgeInsets.all(16), |
| 169 | + child: Center( |
| 170 | + child: Text('End of comments'), |
| 171 | + ), |
| 172 | + ), |
| 173 | + }; |
| 174 | + } |
| 175 | + |
| 176 | + final comment = comments[index]; |
| 177 | + |
| 178 | + return UserCommentItem( |
| 179 | + comment: comment, |
| 180 | + onHeartClick: _onHeartClick, |
| 181 | + onReplyClick: _onReplyClick, |
| 182 | + onLongPressComment: _onLongPressComment, |
| 183 | + ); |
| 184 | + }, |
| 185 | + ), |
| 186 | + ); |
| 187 | + } |
| 188 | + |
| 189 | + void _observeFeedCapabilities() { |
| 190 | + _removeFeedListener?.call(); |
| 191 | + _removeFeedListener = widget.feed.notifier.addListener(_onFeedStateChange); |
| 192 | + } |
| 193 | + |
| 194 | + void _onFeedStateChange(FeedState state) { |
| 195 | + capabilities = state.ownCapabilities; |
| 196 | + } |
| 197 | + |
| 198 | + Future<void> _getActivity() async { |
| 199 | + activity = client.activity( |
| 200 | + activityId: widget.activityId, |
| 201 | + fid: widget.feed.fid, |
| 202 | + ); |
| 203 | + |
| 204 | + await activity.get(); |
| 205 | + } |
| 206 | + |
| 207 | + void _onHeartClick(ThreadedCommentData comment, bool isAdding) { |
| 208 | + const type = 'heart'; |
| 209 | + |
| 210 | + if (isAdding) { |
| 211 | + activity.addCommentReaction( |
| 212 | + commentId: comment.id, |
| 213 | + request: const AddCommentReactionRequest(type: type), |
| 214 | + ); |
| 215 | + } else { |
| 216 | + activity.deleteCommentReaction( |
| 217 | + comment.id, |
| 218 | + type, |
| 219 | + ); |
| 220 | + } |
| 221 | + } |
| 222 | + |
| 223 | + Future<void> _onReplyClick([ThreadedCommentData? parentComment]) async { |
| 224 | + final text = await _displayTextInputDialog(context, title: 'Add comment'); |
| 225 | + if (text == null) return; |
| 226 | + |
| 227 | + await activity.addComment( |
| 228 | + request: ActivityAddCommentRequest( |
| 229 | + comment: text, |
| 230 | + parentId: parentComment?.id, |
| 231 | + activityId: activity.activityId, |
| 232 | + ), |
| 233 | + ); |
| 234 | + } |
| 235 | + |
| 236 | + void _onLongPressComment(ThreadedCommentData comment) { |
| 237 | + final isOwnComment = comment.user.id == client.user.id; |
| 238 | + if (!isOwnComment) return; |
| 239 | + final canEdit = capabilities.contains(FeedOwnCapability.updateComment); |
| 240 | + final canDelete = capabilities.contains(FeedOwnCapability.deleteComment); |
| 241 | + if (!canEdit && !canDelete) return; |
| 242 | + |
| 243 | + final chooseActionDialog = SimpleDialog( |
| 244 | + children: [ |
| 245 | + if (canEdit) |
| 246 | + SimpleDialogOption( |
| 247 | + child: const Text('Edit'), |
| 248 | + onPressed: () { |
| 249 | + Navigator.pop(context); |
| 250 | + _editComment(context, comment); |
| 251 | + }, |
| 252 | + ), |
| 253 | + if (canDelete) |
| 254 | + SimpleDialogOption( |
| 255 | + child: const Text('Delete'), |
| 256 | + onPressed: () { |
| 257 | + activity.deleteComment(comment.id); |
| 258 | + Navigator.pop(context); |
| 259 | + }, |
| 260 | + ), |
| 261 | + ], |
| 262 | + ); |
| 263 | + |
| 264 | + showDialog<void>( |
| 265 | + context: context, |
| 266 | + builder: (context) { |
| 267 | + return chooseActionDialog; |
| 268 | + }, |
| 269 | + ); |
| 270 | + } |
| 271 | + |
| 272 | + Future<void> _editComment( |
| 273 | + BuildContext context, |
| 274 | + ThreadedCommentData comment, |
| 275 | + ) async { |
| 276 | + final text = await _displayTextInputDialog( |
| 277 | + context, |
| 278 | + title: 'Edit comment', |
| 279 | + initialText: comment.text, |
| 280 | + positiveAction: 'Edit', |
| 281 | + ); |
| 282 | + |
| 283 | + if (text == null) return; |
| 284 | + |
| 285 | + await activity.updateComment( |
| 286 | + comment.id, |
| 287 | + ActivityUpdateCommentRequest(comment: text), |
| 288 | + ); |
| 289 | + } |
| 290 | + |
| 291 | + Future<String?> _displayTextInputDialog( |
| 292 | + BuildContext context, { |
| 293 | + required String title, |
| 294 | + String? initialText, |
| 295 | + String positiveAction = 'Add', |
| 296 | + }) async { |
| 297 | + final textFieldController = TextEditingController(); |
| 298 | + textFieldController.text = initialText ?? ''; |
| 299 | + return showDialog<String>( |
| 300 | + context: context, |
| 301 | + builder: (context) { |
| 302 | + return AlertDialog( |
| 303 | + title: Text(title), |
| 304 | + content: TextField(controller: textFieldController), |
| 305 | + actions: <Widget>[ |
| 306 | + TextButton( |
| 307 | + child: const Text('Cancel'), |
| 308 | + onPressed: () { |
| 309 | + Navigator.pop(context); |
| 310 | + }, |
| 311 | + ), |
| 312 | + TextButton( |
| 313 | + child: Text(positiveAction), |
| 314 | + onPressed: () { |
| 315 | + Navigator.pop(context, textFieldController.text); |
| 316 | + }, |
| 317 | + ), |
| 318 | + ], |
| 319 | + ); |
| 320 | + }, |
| 321 | + ); |
| 322 | + } |
| 323 | +} |
| 324 | + |
| 325 | +class EmptyComments extends StatelessWidget { |
| 326 | + const EmptyComments({super.key}); |
| 327 | + |
| 328 | + @override |
| 329 | + Widget build(BuildContext context) { |
| 330 | + return Center( |
| 331 | + child: Column( |
| 332 | + mainAxisAlignment: MainAxisAlignment.center, |
| 333 | + children: [ |
| 334 | + Icon( |
| 335 | + size: 64, |
| 336 | + Icons.chat_bubble_outline_rounded, |
| 337 | + color: context.appColors.textLowEmphasis, |
| 338 | + ), |
| 339 | + const SizedBox(height: 16), |
| 340 | + Text( |
| 341 | + 'No comments yet', |
| 342 | + style: context.appTextStyles.headline.copyWith( |
| 343 | + color: context.appColors.textLowEmphasis, |
| 344 | + ), |
| 345 | + ), |
| 346 | + const SizedBox(height: 8), |
| 347 | + Text( |
| 348 | + 'Be the first to share your thoughts!', |
| 349 | + style: context.appTextStyles.body.copyWith( |
| 350 | + color: context.appColors.textLowEmphasis, |
| 351 | + ), |
| 352 | + textAlign: TextAlign.center, |
| 353 | + ), |
| 354 | + ], |
| 355 | + ), |
| 356 | + ); |
| 357 | + } |
| 358 | +} |
0 commit comments