feat: add comment models and API service

- Add CommentView, ThreadViewComment, and CommentsResponse models
- Implement getComments API endpoint in CovesApiService
- Support optional Dio injection for testing
- Handle nested comment threads with proper JSON parsing
- Add support for comment stats, viewer state, and parent tracking

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

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

Changed files
+249 -10
lib
+177
lib/models/comment.dart
···
+
// Comment data models for Coves
+
//
+
// These models match the backend response structure from:
+
// /xrpc/social.coves.community.comment.getComments
+
+
import 'post.dart';
+
+
class CommentsResponse {
+
CommentsResponse({
+
required this.post,
+
this.cursor,
+
required this.comments,
+
});
+
+
factory CommentsResponse.fromJson(Map<String, dynamic> json) {
+
// Handle null comments array from backend
+
final commentsData = json['comments'];
+
final List<ThreadViewComment> commentsList;
+
+
if (commentsData == null) {
+
// Backend returned null, use empty list
+
commentsList = [];
+
} else {
+
// Parse comment items
+
commentsList =
+
(commentsData as List<dynamic>)
+
.map(
+
(item) =>
+
ThreadViewComment.fromJson(item as Map<String, dynamic>),
+
)
+
.toList();
+
}
+
+
return CommentsResponse(
+
post: json['post'],
+
cursor: json['cursor'] as String?,
+
comments: commentsList,
+
);
+
}
+
+
final dynamic post;
+
final String? cursor;
+
final List<ThreadViewComment> comments;
+
}
+
+
class ThreadViewComment {
+
ThreadViewComment({
+
required this.comment,
+
this.replies,
+
this.hasMore = false,
+
});
+
+
factory ThreadViewComment.fromJson(Map<String, dynamic> json) {
+
return ThreadViewComment(
+
comment: CommentView.fromJson(json['comment'] as Map<String, dynamic>),
+
replies: json['replies'] != null
+
? (json['replies'] as List<dynamic>)
+
.map(
+
(item) =>
+
ThreadViewComment.fromJson(item as Map<String, dynamic>),
+
)
+
.toList()
+
: null,
+
hasMore: json['hasMore'] as bool? ?? false,
+
);
+
}
+
+
final CommentView comment;
+
final List<ThreadViewComment>? replies;
+
final bool hasMore;
+
}
+
+
class CommentView {
+
CommentView({
+
required this.uri,
+
required this.cid,
+
required this.content,
+
this.contentFacets,
+
required this.createdAt,
+
required this.indexedAt,
+
required this.author,
+
required this.post,
+
this.parent,
+
required this.stats,
+
this.viewer,
+
this.embed,
+
});
+
+
factory CommentView.fromJson(Map<String, dynamic> json) {
+
return CommentView(
+
uri: json['uri'] as String,
+
cid: json['cid'] as String,
+
content: json['content'] as String,
+
contentFacets: json['contentFacets'] != null
+
? (json['contentFacets'] as List<dynamic>)
+
.map((f) => PostFacet.fromJson(f as Map<String, dynamic>))
+
.toList()
+
: null,
+
createdAt: DateTime.parse(json['createdAt'] as String),
+
indexedAt: DateTime.parse(json['indexedAt'] as String),
+
author: AuthorView.fromJson(json['author'] as Map<String, dynamic>),
+
post: CommentRef.fromJson(json['post'] as Map<String, dynamic>),
+
parent: json['parent'] != null
+
? CommentRef.fromJson(json['parent'] as Map<String, dynamic>)
+
: null,
+
stats: CommentStats.fromJson(json['stats'] as Map<String, dynamic>),
+
viewer: json['viewer'] != null
+
? CommentViewerState.fromJson(json['viewer'] as Map<String, dynamic>)
+
: null,
+
embed: json['embed'],
+
);
+
}
+
+
final String uri;
+
final String cid;
+
final String content;
+
final List<PostFacet>? contentFacets;
+
final DateTime createdAt;
+
final DateTime indexedAt;
+
final AuthorView author;
+
final CommentRef post;
+
final CommentRef? parent;
+
final CommentStats stats;
+
final CommentViewerState? viewer;
+
final dynamic embed;
+
}
+
+
class CommentRef {
+
CommentRef({
+
required this.uri,
+
required this.cid,
+
});
+
+
factory CommentRef.fromJson(Map<String, dynamic> json) {
+
return CommentRef(
+
uri: json['uri'] as String,
+
cid: json['cid'] as String,
+
);
+
}
+
+
final String uri;
+
final String cid;
+
}
+
+
class CommentStats {
+
CommentStats({
+
this.upvotes = 0,
+
this.downvotes = 0,
+
this.score = 0,
+
});
+
+
factory CommentStats.fromJson(Map<String, dynamic> json) {
+
return CommentStats(
+
upvotes: json['upvotes'] as int? ?? 0,
+
downvotes: json['downvotes'] as int? ?? 0,
+
score: json['score'] as int? ?? 0,
+
);
+
}
+
+
final int upvotes;
+
final int downvotes;
+
final int score;
+
}
+
+
class CommentViewerState {
+
CommentViewerState({
+
this.vote,
+
});
+
+
factory CommentViewerState.fromJson(Map<String, dynamic> json) {
+
return CommentViewerState(
+
vote: json['vote'] as String?,
+
);
+
}
+
+
final String? vote;
+
}
+72 -10
lib/services/coves_api_service.dart
···
import 'package:flutter/foundation.dart';
import '../config/oauth_config.dart';
+
import '../models/comment.dart';
import '../models/post.dart';
import 'api_exceptions.dart';
···
/// rotates tokens automatically (~1 hour expiry), and caching tokens would
/// cause 401 errors after the first token expires.
class CovesApiService {
-
CovesApiService({Future<String?> Function()? tokenGetter})
-
: _tokenGetter = tokenGetter {
-
_dio = Dio(
-
BaseOptions(
-
baseUrl: OAuthConfig.apiUrl,
-
connectTimeout: const Duration(seconds: 30),
-
receiveTimeout: const Duration(seconds: 30),
-
headers: {'Content-Type': 'application/json'},
-
),
-
);
+
CovesApiService({
+
Future<String?> Function()? tokenGetter,
+
Dio? dio,
+
}) : _tokenGetter = tokenGetter {
+
_dio = dio ??
+
Dio(
+
BaseOptions(
+
baseUrl: OAuthConfig.apiUrl,
+
connectTimeout: const Duration(seconds: 30),
+
receiveTimeout: const Duration(seconds: 30),
+
headers: {'Content-Type': 'application/json'},
+
),
+
);
// Add auth interceptor FIRST to add bearer token
_dio.interceptors.add(
···
return TimelineResponse.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
_handleDioException(e, 'discover feed');
+
}
+
}
+
+
/// Get comments for a post (authenticated)
+
///
+
/// Fetches threaded comments for a specific post.
+
/// Requires authentication.
+
///
+
/// Parameters:
+
/// - [postUri]: Post URI (required)
+
/// - [sort]: 'hot', 'top', or 'new' (default: 'hot')
+
/// - [timeframe]: 'hour', 'day', 'week', 'month', 'year', 'all'
+
/// - [depth]: Maximum nesting depth for replies (default: 10)
+
/// - [limit]: Number of comments per page (default: 50, max: 100)
+
/// - [cursor]: Pagination cursor from previous response
+
Future<CommentsResponse> getComments({
+
required String postUri,
+
String sort = 'hot',
+
String? timeframe,
+
int depth = 10,
+
int limit = 50,
+
String? cursor,
+
}) async {
+
try {
+
if (kDebugMode) {
+
debugPrint('📡 Fetching comments: postUri=$postUri, sort=$sort');
+
}
+
+
final queryParams = <String, dynamic>{
+
'post': postUri,
+
'sort': sort,
+
'depth': depth,
+
'limit': limit,
+
};
+
+
if (timeframe != null) {
+
queryParams['timeframe'] = timeframe;
+
}
+
+
if (cursor != null) {
+
queryParams['cursor'] = cursor;
+
}
+
+
final response = await _dio.get(
+
'/xrpc/social.coves.community.comment.getComments',
+
queryParameters: queryParams,
+
);
+
+
if (kDebugMode) {
+
debugPrint(
+
'✅ Comments fetched: '
+
'${response.data['comments']?.length ?? 0} comments',
+
);
+
}
+
+
return CommentsResponse.fromJson(response.data as Map<String, dynamic>);
+
} on DioException catch (e) {
+
_handleDioException(e, 'comments');
}
}