feat(profile): wire up comments tab with actor.getComments endpoint

Integrate the new social.coves.actor.getComments backend endpoint
into the profile page's Comments tab:

- Add getActorComments() method to CovesApiService
- Add CommentsState model with immutable list (List.unmodifiable)
- Add ActorCommentsResponse model with proper documentation
- Add loadComments/loadMoreComments/retryComments to UserProfileProvider
- Wire up lazy loading in profile screen (loads on first tab switch)
- Display comments using existing CommentCard widget
- Handle pagination, loading states, and errors
- Properly handle 404 as "User not found" (not empty state)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+429 -14
lib
+104
lib/models/comment.dart
··· 171 171 /// AT-URI of the vote record (if backend provides it) 172 172 final String? voteUri; 173 173 } 174 + 175 + /// Sentinel value for copyWith to distinguish "not provided" from "null" 176 + const _sentinel = Object(); 177 + 178 + /// State container for a comments list (e.g., actor comments) 179 + /// 180 + /// Holds all state for a paginated comments list including loading states, 181 + /// pagination, and errors. 182 + /// 183 + /// The [comments] list is immutable - callers cannot modify it externally. 184 + class CommentsState { 185 + /// Creates a new CommentsState with an immutable comments list. 186 + CommentsState({ 187 + List<CommentView> comments = const [], 188 + this.cursor, 189 + this.hasMore = true, 190 + this.isLoading = false, 191 + this.isLoadingMore = false, 192 + this.error, 193 + }) : comments = List.unmodifiable(comments); 194 + 195 + /// Create a default empty state 196 + factory CommentsState.initial() { 197 + return CommentsState(); 198 + } 199 + 200 + /// Unmodifiable list of comments 201 + final List<CommentView> comments; 202 + 203 + /// Pagination cursor for next page 204 + final String? cursor; 205 + 206 + /// Whether more pages are available 207 + final bool hasMore; 208 + 209 + /// Initial load in progress 210 + final bool isLoading; 211 + 212 + /// Pagination (load more) in progress 213 + final bool isLoadingMore; 214 + 215 + /// Error message if any 216 + final String? error; 217 + 218 + /// Create a copy with modified fields (immutable updates) 219 + /// 220 + /// Nullable fields (cursor, error) use a sentinel pattern to distinguish 221 + /// between "not provided" and "explicitly set to null". 222 + CommentsState copyWith({ 223 + List<CommentView>? comments, 224 + Object? cursor = _sentinel, 225 + bool? hasMore, 226 + bool? isLoading, 227 + bool? isLoadingMore, 228 + Object? error = _sentinel, 229 + }) { 230 + return CommentsState( 231 + comments: comments ?? this.comments, 232 + cursor: cursor == _sentinel ? this.cursor : cursor as String?, 233 + hasMore: hasMore ?? this.hasMore, 234 + isLoading: isLoading ?? this.isLoading, 235 + isLoadingMore: isLoadingMore ?? this.isLoadingMore, 236 + error: error == _sentinel ? this.error : error as String?, 237 + ); 238 + } 239 + } 240 + 241 + /// Response from social.coves.actor.getComments endpoint. 242 + /// 243 + /// Returns a flat list of comments by a specific user for their profile page. 244 + /// The endpoint returns an empty array when the user has no comments, 245 + /// and 404 when the user doesn't exist. 246 + class ActorCommentsResponse { 247 + ActorCommentsResponse({required this.comments, this.cursor}); 248 + 249 + /// Parses the JSON response from the API. 250 + /// 251 + /// Handles null comments array gracefully by returning an empty list. 252 + factory ActorCommentsResponse.fromJson(Map<String, dynamic> json) { 253 + final commentsData = json['comments']; 254 + final List<CommentView> commentsList; 255 + 256 + if (commentsData == null) { 257 + commentsList = []; 258 + } else { 259 + commentsList = 260 + (commentsData as List<dynamic>) 261 + .map((item) => CommentView.fromJson(item as Map<String, dynamic>)) 262 + .toList(); 263 + } 264 + 265 + return ActorCommentsResponse( 266 + comments: commentsList, 267 + cursor: json['cursor'] as String?, 268 + ); 269 + } 270 + 271 + /// List of comments by the actor, ordered newest first. 272 + final List<CommentView> comments; 273 + 274 + /// Pagination cursor for fetching the next page of comments. 275 + /// Null when there are no more comments to fetch. 276 + final String? cursor; 277 + }
+143 -5
lib/providers/user_profile_provider.dart
··· 1 1 import 'package:flutter/foundation.dart'; 2 2 3 + import '../models/comment.dart'; 3 4 import '../models/feed_state.dart'; 4 5 import '../models/post.dart'; 5 6 import '../models/user_profile.dart'; ··· 62 63 // Posts feed state (reusing FeedState pattern) 63 64 FeedState _postsState = FeedState.initial(); 64 65 66 + // Comments feed state 67 + CommentsState _commentsState = CommentsState.initial(); 68 + 65 69 // LRU profile cache keyed by DID (max 50 entries) 66 70 static const int _maxCacheSize = 50; 67 71 final Map<String, UserProfile> _profileCache = {}; ··· 102 106 String? get profileError => _profileError; 103 107 String? get currentProfileDid => _currentProfileDid; 104 108 FeedState get postsState => _postsState; 109 + CommentsState get commentsState => _commentsState; 105 110 106 111 /// Check if currently viewing own profile 107 112 bool get isOwnProfile { ··· 120 125 _cacheAccessOrder.clear(); 121 126 _profile = null; 122 127 _postsState = FeedState.initial(); 128 + _commentsState = CommentsState.initial(); 123 129 _currentProfileDid = null; 124 130 notifyListeners(); 125 131 } ··· 276 282 debugPrint('❌ Auth required to load posts'); 277 283 } 278 284 } on NotFoundException { 279 - // Author posts endpoint not implemented yet - show empty state 285 + // 404 means the actor doesn't exist (not "no posts") 286 + // Empty posts are returned as an empty array, not 404 280 287 _postsState = currentState.copyWith( 281 - posts: [], 282 - hasMore: false, 283 - error: null, 288 + error: 'User not found', 284 289 isLoading: false, 285 290 isLoadingMore: false, 286 291 ); 287 292 288 293 if (kDebugMode) { 289 - debugPrint('⚠️ Author posts endpoint not available'); 294 + debugPrint('❌ Actor not found when loading posts'); 290 295 } 291 296 } on NetworkException catch (e) { 292 297 _postsState = currentState.copyWith( ··· 339 344 await loadPosts(refresh: false); 340 345 } 341 346 347 + /// Load comments by the current profile's author 348 + /// 349 + /// Parameters: 350 + /// - [refresh]: Reload from beginning instead of paginating 351 + Future<void> loadComments({bool refresh = false}) async { 352 + if (_currentProfileDid == null) { 353 + _commentsState = _commentsState.copyWith( 354 + error: 'No profile loaded', 355 + isLoading: false, 356 + isLoadingMore: false, 357 + ); 358 + notifyListeners(); 359 + return; 360 + } 361 + if (_commentsState.isLoading || _commentsState.isLoadingMore) return; 362 + 363 + final currentState = _commentsState; 364 + 365 + try { 366 + if (refresh) { 367 + _commentsState = currentState.copyWith(isLoading: true, error: null); 368 + } else { 369 + if (!currentState.hasMore) return; 370 + _commentsState = currentState.copyWith(isLoadingMore: true); 371 + } 372 + notifyListeners(); 373 + 374 + final response = await _apiService.getActorComments( 375 + actor: _currentProfileDid!, 376 + cursor: refresh ? null : currentState.cursor, 377 + ); 378 + 379 + final List<CommentView> newComments; 380 + if (refresh) { 381 + newComments = response.comments; 382 + } else { 383 + newComments = [...currentState.comments, ...response.comments]; 384 + } 385 + 386 + _commentsState = currentState.copyWith( 387 + comments: newComments, 388 + cursor: response.cursor, 389 + hasMore: response.cursor != null, 390 + error: null, 391 + isLoading: false, 392 + isLoadingMore: false, 393 + ); 394 + 395 + if (kDebugMode) { 396 + debugPrint( 397 + '✅ Author comments loaded: ${newComments.length} comments total', 398 + ); 399 + } 400 + } on AuthenticationException { 401 + _commentsState = currentState.copyWith( 402 + error: 'Please sign in to view comments', 403 + isLoading: false, 404 + isLoadingMore: false, 405 + ); 406 + 407 + if (kDebugMode) { 408 + debugPrint('❌ Auth required to load comments'); 409 + } 410 + } on NotFoundException { 411 + // 404 means the actor doesn't exist (not "no comments") 412 + // Empty comments are returned as an empty array, not 404 413 + _commentsState = currentState.copyWith( 414 + error: 'User not found', 415 + isLoading: false, 416 + isLoadingMore: false, 417 + ); 418 + 419 + if (kDebugMode) { 420 + debugPrint('❌ Actor not found when loading comments'); 421 + } 422 + } on NetworkException catch (e) { 423 + _commentsState = currentState.copyWith( 424 + error: 'Network error. Check your connection.', 425 + isLoading: false, 426 + isLoadingMore: false, 427 + ); 428 + 429 + if (kDebugMode) { 430 + debugPrint('❌ Network error loading comments: ${e.message}'); 431 + } 432 + } on ApiException catch (e) { 433 + _commentsState = currentState.copyWith( 434 + error: e.message, 435 + isLoading: false, 436 + isLoadingMore: false, 437 + ); 438 + 439 + if (kDebugMode) { 440 + debugPrint('❌ Failed to load author comments: ${e.message}'); 441 + } 442 + } on FormatException catch (e) { 443 + _commentsState = currentState.copyWith( 444 + error: 'Invalid data received from server', 445 + isLoading: false, 446 + isLoadingMore: false, 447 + ); 448 + 449 + if (kDebugMode) { 450 + debugPrint('❌ Format error loading comments: $e'); 451 + } 452 + } on Exception catch (e) { 453 + _commentsState = currentState.copyWith( 454 + error: 'Failed to load comments. Please try again.', 455 + isLoading: false, 456 + isLoadingMore: false, 457 + ); 458 + 459 + if (kDebugMode) { 460 + debugPrint('❌ Unexpected error loading comments: $e'); 461 + } 462 + } 463 + 464 + notifyListeners(); 465 + } 466 + 467 + /// Load more comments (pagination) 468 + Future<void> loadMoreComments() async { 469 + await loadComments(refresh: false); 470 + } 471 + 342 472 /// Clear current profile and reset state 343 473 void clearProfile() { 344 474 _profile = null; 345 475 _currentProfileDid = null; 346 476 _postsState = FeedState.initial(); 477 + _commentsState = CommentsState.initial(); 347 478 _profileError = null; 348 479 _isLoadingProfile = false; 349 480 notifyListeners(); ··· 381 512 _postsState = _postsState.copyWith(error: null); 382 513 notifyListeners(); 383 514 await loadPosts(refresh: true); 515 + } 516 + 517 + /// Retry loading comments after error 518 + Future<void> retryComments() async { 519 + _commentsState = _commentsState.copyWith(error: null); 520 + notifyListeners(); 521 + await loadComments(refresh: true); 384 522 } 385 523 386 524 @override
+114 -9
lib/screens/home/profile_screen.dart
··· 6 6 import 'package:share_plus/share_plus.dart'; 7 7 8 8 import '../../constants/app_colors.dart'; 9 + import '../../models/comment.dart'; 9 10 import '../../providers/auth_provider.dart'; 10 11 import '../../providers/user_profile_provider.dart'; 12 + import '../../widgets/comment_card.dart'; 11 13 import '../../widgets/loading_error_states.dart'; 12 14 import '../../widgets/post_card.dart'; 13 15 import '../../widgets/primary_button.dart'; ··· 29 31 30 32 class _ProfileScreenState extends State<ProfileScreen> { 31 33 int _selectedTabIndex = 0; 34 + bool _commentsLoadedOnce = false; 32 35 33 36 @override 34 37 void initState() { ··· 42 45 void didUpdateWidget(ProfileScreen oldWidget) { 43 46 super.didUpdateWidget(oldWidget); 44 47 if (oldWidget.actor != widget.actor) { 48 + // Reset comments loaded flag when viewing a different profile 49 + _commentsLoadedOnce = false; 45 50 _loadProfile(); 46 51 } 47 52 } 48 53 54 + void _onTabChanged(int index) { 55 + setState(() { 56 + _selectedTabIndex = index; 57 + }); 58 + 59 + // Lazy load comments when first switching to Comments tab 60 + if (index == 1 && !_commentsLoadedOnce) { 61 + _commentsLoadedOnce = true; 62 + final profileProvider = context.read<UserProfileProvider>(); 63 + profileProvider.loadComments(refresh: true); 64 + } 65 + } 66 + 49 67 Future<void> _loadProfile() async { 50 68 final authProvider = context.read<AuthProvider>(); 51 69 final profileProvider = context.read<UserProfileProvider>(); ··· 182 200 final actor = widget.actor ?? authProvider.did; 183 201 if (actor != null) { 184 202 await profileProvider.loadProfile(actor, forceRefresh: true); 185 - await profileProvider.loadPosts(refresh: true); 203 + // Refresh the active tab content 204 + if (_selectedTabIndex == 0) { 205 + await profileProvider.loadPosts(refresh: true); 206 + } else if (_selectedTabIndex == 1) { 207 + await profileProvider.loadComments(refresh: true); 208 + } 186 209 } 187 210 }, 188 211 child: CustomScrollView( ··· 275 298 color: AppColors.background, 276 299 child: _ProfileTabBar( 277 300 selectedIndex: _selectedTabIndex, 278 - onTabChanged: (index) { 279 - setState(() { 280 - _selectedTabIndex = index; 281 - }); 282 - }, 301 + onTabChanged: _onTabChanged, 283 302 ), 284 303 ), 285 304 ), ··· 287 306 // Content based on selected tab 288 307 if (_selectedTabIndex == 0) 289 308 _buildPostsList(profileProvider) 309 + else if (_selectedTabIndex == 1) 310 + _buildCommentsList(profileProvider) 290 311 else 291 - _buildComingSoonPlaceholder( 292 - _selectedTabIndex == 1 ? 'Comments' : 'Likes', 293 - ), 312 + _buildComingSoonPlaceholder('Likes'), 294 313 ], 295 314 ), 296 315 ), ··· 425 444 ); 426 445 } 427 446 447 + Widget _buildCommentsList(UserProfileProvider profileProvider) { 448 + final commentsState = profileProvider.commentsState; 449 + 450 + // Loading state for comments 451 + if (commentsState.isLoading && commentsState.comments.isEmpty) { 452 + return const SliverFillRemaining( 453 + child: Center( 454 + child: CircularProgressIndicator(color: AppColors.primary), 455 + ), 456 + ); 457 + } 458 + 459 + // Error state for comments 460 + if (commentsState.error != null && commentsState.comments.isEmpty) { 461 + return SliverFillRemaining( 462 + child: Center( 463 + child: InlineError( 464 + message: commentsState.error!, 465 + onRetry: () => profileProvider.retryComments(), 466 + ), 467 + ), 468 + ); 469 + } 470 + 471 + // Empty state 472 + if (commentsState.comments.isEmpty && !commentsState.isLoading) { 473 + return const SliverFillRemaining( 474 + child: Center( 475 + child: Text( 476 + 'No comments yet', 477 + style: TextStyle(fontSize: 16, color: AppColors.textSecondary), 478 + ), 479 + ), 480 + ); 481 + } 482 + 483 + // Comments list 484 + final showLoadingSlot = 485 + commentsState.isLoadingMore || commentsState.error != null; 486 + 487 + return SliverList( 488 + delegate: SliverChildBuilderDelegate((context, index) { 489 + // Load more when reaching end 490 + if (index == commentsState.comments.length - 3 && 491 + commentsState.hasMore) { 492 + profileProvider.loadMoreComments(); 493 + } 494 + 495 + // Show loading indicator or error at the end 496 + if (index == commentsState.comments.length) { 497 + if (commentsState.isLoadingMore) { 498 + return const InlineLoading(); 499 + } 500 + if (commentsState.error != null) { 501 + return InlineError( 502 + message: commentsState.error!, 503 + onRetry: () => profileProvider.loadMoreComments(), 504 + ); 505 + } 506 + return const SizedBox.shrink(); 507 + } 508 + 509 + final comment = commentsState.comments[index]; 510 + return _ProfileCommentCard(comment: comment); 511 + }, childCount: commentsState.comments.length + (showLoadingSlot ? 1 : 0)), 512 + ); 513 + } 514 + 428 515 Widget _buildComingSoonPlaceholder(String feature) { 429 516 return SliverFillRemaining( 430 517 child: Center( ··· 591 678 return child != oldDelegate.child; 592 679 } 593 680 } 681 + 682 + /// A simplified comment card for the profile comments list 683 + /// 684 + /// Displays a flat comment without threading since these are shown in 685 + /// a profile context without parent/child relationships visible. 686 + class _ProfileCommentCard extends StatelessWidget { 687 + const _ProfileCommentCard({required this.comment}); 688 + 689 + final CommentView comment; 690 + 691 + @override 692 + Widget build(BuildContext context) { 693 + return CommentCard( 694 + comment: comment, 695 + depth: 0, 696 + ); 697 + } 698 + }
+68
lib/services/coves_api_service.dart
··· 661 661 } 662 662 } 663 663 664 + /// Get comments by a specific actor 665 + /// 666 + /// Fetches comments created by a specific user for their profile page. 667 + /// 668 + /// Parameters: 669 + /// - [actor]: User's DID or handle (required) 670 + /// - [community]: Filter to comments in a specific community (optional) 671 + /// - [limit]: Number of comments per page (default: 50, max: 100) 672 + /// - [cursor]: Pagination cursor from previous response 673 + /// 674 + /// Throws: 675 + /// - `NotFoundException` if the actor does not exist 676 + /// - `AuthenticationException` if authentication is required/expired 677 + /// - `ApiException` for other API errors 678 + Future<ActorCommentsResponse> getActorComments({ 679 + required String actor, 680 + String? community, 681 + int limit = 50, 682 + String? cursor, 683 + }) async { 684 + try { 685 + if (kDebugMode) { 686 + debugPrint('📡 Fetching comments for actor: $actor'); 687 + } 688 + 689 + final queryParams = <String, dynamic>{ 690 + 'actor': actor, 691 + 'limit': limit, 692 + }; 693 + 694 + if (community != null) { 695 + queryParams['community'] = community; 696 + } 697 + 698 + if (cursor != null) { 699 + queryParams['cursor'] = cursor; 700 + } 701 + 702 + final response = await _dio.get( 703 + '/xrpc/social.coves.actor.getComments', 704 + queryParameters: queryParams, 705 + ); 706 + 707 + final data = response.data; 708 + if (data is! Map<String, dynamic>) { 709 + throw FormatException('Expected Map but got ${data.runtimeType}'); 710 + } 711 + 712 + if (kDebugMode) { 713 + debugPrint( 714 + '✅ Actor comments fetched: ' 715 + '${data['comments']?.length ?? 0} comments', 716 + ); 717 + } 718 + 719 + return ActorCommentsResponse.fromJson(data); 720 + } on DioException catch (e) { 721 + _handleDioException(e, 'actor comments'); 722 + } on FormatException { 723 + rethrow; 724 + } on Exception catch (e) { 725 + if (kDebugMode) { 726 + debugPrint('❌ Error parsing actor comments response: $e'); 727 + } 728 + throw ApiException('Failed to parse server response', originalError: e); 729 + } 730 + } 731 + 664 732 /// Handle Dio exceptions with specific error types 665 733 /// 666 734 /// Converts generic DioException into specific typed exceptions